Initial affiliate codes implementation (#4382)

* Initial affiliate codes implementation

* some more docs to codes

* sqlx prepare

* Address PR comments

* Address more PR comments

* fix clippy

* Switch to using Json<T> for type-safe responses
This commit is contained in:
aecsocket
2025-09-18 16:43:34 +01:00
committed by GitHub
parent 6da190ed01
commit 4def0e8407
15 changed files with 607 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM affiliate_codes WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "10200da00caaa8f1c0976b9b705bf6d74a59bb62ee5da6dccffec99f36eddac7"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes WHERE affiliate = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "affiliate",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "6ef8402f0f0685acda118c024d82e31b1b235ab7c5ec00a86af4dfbe81342a58"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "affiliate",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "81cfa59dafa8dac87a917a3ddda93d412a579ab17d9dad754e7d9e26d78e0e80"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes ORDER BY created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "affiliate",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "b27a1495f0a937e1bae944cfe6e46a4f702536816e5259c4aeec8b905b507473"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM affiliate_codes WHERE id=$1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "b36457e1aa73b5517ca4f41e4144084a0aa996168d2660c13a1d4c9fc4594859"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO affiliate_codes (id, created_at, created_by, affiliate)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Timestamptz",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "d51d96a9771ce1c3a2987e0790ef25bc55122c934c73418a97ee9cd81f56b251"
}

View File

@@ -0,0 +1,9 @@
CREATE TABLE affiliate_codes (
id bigint PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by bigint NOT NULL REFERENCES users(id),
affiliate bigint NOT NULL REFERENCES users(id),
-- left nullable so we can explicitly set payouts if we need to,
-- and use a global default if unset
revenue_split float
);

View File

@@ -0,0 +1,117 @@
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use crate::database::models::{DBAffiliateCodeId, DBUserId, DatabaseError};
#[derive(Debug)]
pub struct DBAffiliateCode {
pub id: DBAffiliateCodeId,
pub created_at: DateTime<Utc>,
pub created_by: DBUserId,
pub affiliate: DBUserId,
}
impl DBAffiliateCode {
pub async fn get_by_id(
id: DBAffiliateCodeId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBAffiliateCode>, DatabaseError> {
let record = sqlx::query!(
"SELECT id, created_at, created_by, affiliate
FROM affiliate_codes WHERE id = $1",
id as DBAffiliateCodeId
)
.fetch_optional(exec)
.await?;
Ok(record.map(|record| DBAffiliateCode {
id: DBAffiliateCodeId(record.id),
created_at: record.created_at,
created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate),
}))
}
pub async fn get_by_affiliate(
affiliate: DBUserId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBAffiliateCode>, DatabaseError> {
let records = sqlx::query!(
"SELECT id, created_at, created_by, affiliate
FROM affiliate_codes WHERE affiliate = $1",
affiliate as DBUserId
)
.fetch(exec)
.map(|record| {
let record = record?;
Ok::<_, DatabaseError>(DBAffiliateCode {
id: DBAffiliateCodeId(record.id),
created_at: record.created_at,
created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate),
})
})
.try_collect::<Vec<_>>()
.await?;
Ok(records)
}
pub async fn insert(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"INSERT INTO affiliate_codes (id, created_at, created_by, affiliate)
VALUES ($1, $2, $3, $4)",
self.id as DBAffiliateCodeId,
self.created_at,
self.created_by as DBUserId,
self.affiliate as DBUserId
)
.execute(exec)
.await?;
Ok(())
}
pub async fn remove(
id: DBAffiliateCodeId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<()>, DatabaseError> {
let result = sqlx::query!(
"DELETE FROM affiliate_codes WHERE id = $1",
id as DBAffiliateCodeId
)
.execute(exec)
.await?;
if result.rows_affected() > 0 {
Ok(Some(()))
} else {
Ok(None)
}
}
pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBAffiliateCode>, DatabaseError> {
let records = sqlx::query!(
"SELECT id, created_at, created_by, affiliate
FROM affiliate_codes ORDER BY created_at DESC"
)
.fetch(exec)
.map(|record| {
let record = record?;
Ok::<_, DatabaseError>(DBAffiliateCode {
id: DBAffiliateCodeId(record.id),
created_at: record.created_at,
created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate),
})
})
.try_collect::<Vec<_>>()
.await?;
Ok(records)
}
}

View File

@@ -1,6 +1,6 @@
use super::DatabaseError; use super::DatabaseError;
use crate::models::ids::{ use crate::models::ids::{
ChargeId, CollectionId, FileId, ImageId, NotificationId, AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId,
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId, ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
@@ -263,6 +263,10 @@ db_id_interface!(
VersionId, VersionId,
generator: generate_version_id @ "versions", generator: generate_version_id @ "versions",
); );
db_id_interface!(
AffiliateCodeId,
generator: generate_affiliate_code_id @ "affiliate_codes",
);
short_id_type!(CategoryId); short_id_type!(CategoryId);
short_id_type!(GameId); short_id_type!(GameId);

View File

@@ -1,5 +1,6 @@
use thiserror::Error; use thiserror::Error;
pub mod affiliate_code_item;
pub mod categories; pub mod categories;
pub mod charge_item; pub mod charge_item;
pub mod collection_item; pub mod collection_item;
@@ -34,6 +35,7 @@ pub mod users_notifications_preferences_item;
pub mod users_redeemals; pub mod users_redeemals;
pub mod version_item; pub mod version_item;
pub use affiliate_code_item::DBAffiliateCode;
pub use collection_item::DBCollection; pub use collection_item::DBCollection;
pub use ids::*; pub use ids::*;
pub use image_item::DBImage; pub use image_item::DBImage;

View File

@@ -0,0 +1,67 @@
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::models::ids::AffiliateCodeId;
/// Affiliate code used to track referral purchases.
///
/// See [`AffiliateCode`].
///
/// This struct contains information which should only be visible to admins.
#[derive(Serialize, Deserialize)]
pub struct AdminAffiliateCode {
/// Affiliate code ID.
pub id: AffiliateCodeId,
/// When the code was created.
pub created_at: DateTime<Utc>,
/// User who created the code.
pub created_by: UserId,
/// User who refers the purchaser.
pub affiliate: UserId,
}
/// Affiliate code used to track referral purchases.
///
/// When a user follows a URL with [`AffiliateCode::id`] as an affiliate
/// parameter, the code will be saved as a cookie. When the same user purchases
/// a product with an affiliate code cookie, the purchase under that code is
/// tracked.
///
/// This struct contains information which is allowed to be seen by an
/// affiliate.
#[derive(Serialize, Deserialize)]
pub struct AffiliateCode {
/// Affiliate code ID.
pub id: AffiliateCodeId,
/// User who refers the purchaser.
pub affiliate: UserId,
}
impl From<crate::database::models::affiliate_code_item::DBAffiliateCode>
for AdminAffiliateCode
{
fn from(
data: crate::database::models::affiliate_code_item::DBAffiliateCode,
) -> Self {
Self {
id: data.id.into(),
created_at: data.created_at,
created_by: data.created_by.into(),
affiliate: data.affiliate.into(),
}
}
}
impl From<crate::database::models::affiliate_code_item::DBAffiliateCode>
for AffiliateCode
{
fn from(
data: crate::database::models::affiliate_code_item::DBAffiliateCode,
) -> Self {
Self {
id: data.id.into(),
affiliate: data.affiliate.into(),
}
}
}

View File

@@ -25,3 +25,4 @@ base62_id!(ThreadId);
base62_id!(ThreadMessageId); base62_id!(ThreadMessageId);
base62_id!(UserSubscriptionId); base62_id!(UserSubscriptionId);
base62_id!(VersionId); base62_id!(VersionId);
base62_id!(AffiliateCodeId);

View File

@@ -1,3 +1,4 @@
pub mod affiliate_code;
pub mod analytics; pub mod analytics;
pub mod billing; pub mod billing;
pub mod collections; pub mod collections;

View File

@@ -0,0 +1,231 @@
use crate::{
auth::get_user_from_headers,
database::{
models::{DBAffiliateCode, DBAffiliateCodeId, DBUser, DBUserId},
redis::RedisPool,
},
models::{
ids::AffiliateCodeId,
pats::Scopes,
v3::affiliate_code::{AdminAffiliateCode, AffiliateCode},
},
queue::session::AuthQueue,
};
use actix_web::{
HttpRequest, HttpResponse,
web::{self, Json},
};
use ariadne::ids::UserId;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::routes::ApiError;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("affiliate")
.route("/code", web::get().to(code_get_all))
.route("/code", web::put().to(code_create))
.route("/code/{id}", web::get().to(code_get))
.route("/code/{id}", web::delete().to(code_delete))
.route("/self", web::get().to(self_get)),
);
}
#[derive(Serialize)]
struct CodeGetAllResponse(Vec<AdminAffiliateCode>);
async fn code_get_all(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<CodeGetAllResponse>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to read all affiliate codes!"
.to_string(),
));
}
let codes = DBAffiliateCode::get_all(&**pool).await?;
let codes = codes
.into_iter()
.map(AdminAffiliateCode::from)
.collect::<Vec<_>>();
Ok(Json(CodeGetAllResponse(codes)))
}
#[derive(Serialize, Deserialize)]
struct CodeCreateRequest {
affiliate: UserId,
}
#[derive(Serialize)]
struct CodeCreateResponse(AdminAffiliateCode);
async fn code_create(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<CodeCreateRequest>,
) -> Result<Json<CodeCreateResponse>, ApiError> {
let (_, creator) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?;
if !creator.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to create an affiliate code!"
.to_string(),
));
}
let creator_id = DBUserId::from(creator.id);
let affiliate_id = DBUserId::from(body.affiliate);
let Some(_affiliate_user) =
DBUser::get_id(affiliate_id, &**pool, &redis).await?
else {
return Err(ApiError::CustomAuthentication(
"Affiliate user not found!".to_string(),
));
};
let mut transaction = pool.begin().await?;
let affiliate_code_id =
crate::database::models::generate_affiliate_code_id(&mut transaction)
.await?;
let code = DBAffiliateCode {
id: affiliate_code_id,
created_at: Utc::now(),
created_by: creator_id,
affiliate: affiliate_id,
};
code.insert(&mut *transaction).await?;
transaction.commit().await?;
Ok(Json(CodeCreateResponse(AdminAffiliateCode::from(code))))
}
#[derive(Serialize)]
struct CodeGetResponse(AdminAffiliateCode);
async fn code_get(
req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<CodeGetResponse>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to read an affiliate code!".to_string(),
));
}
let (affiliate_code_id,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
if let Some(model) =
DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await?
{
let model = AdminAffiliateCode::from(model);
Ok(Json(CodeGetResponse(model)))
} else {
Err(ApiError::NotFound)
}
}
async fn code_delete(
req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to delete an affiliate code!"
.to_string(),
));
}
let (affiliate_code_id,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
let result = DBAffiliateCode::remove(affiliate_code_id, &**pool).await?;
if result.is_some() {
Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::NotFound)
}
}
#[derive(Serialize)]
struct SelfGetResponse(Vec<AffiliateCode>);
async fn self_get(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<SelfGetResponse>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?;
let codes =
DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool)
.await?;
let codes = codes
.into_iter()
.map(AffiliateCode::from)
.collect::<Vec<_>>();
Ok(Json(SelfGetResponse(codes)))
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod admin; pub(crate) mod admin;
pub mod affiliate;
pub mod billing; pub mod billing;
pub mod external_notifications; pub mod external_notifications;
pub mod flows; pub mod flows;
@@ -28,6 +29,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(gdpr::config) .configure(gdpr::config)
.configure(statuses::config) .configure(statuses::config)
.configure(medal::config) .configure(medal::config)
.configure(external_notifications::config), .configure(external_notifications::config)
.configure(affiliate::config),
); );
} }