diff --git a/apps/labrinth/.sqlx/query-10200da00caaa8f1c0976b9b705bf6d74a59bb62ee5da6dccffec99f36eddac7.json b/apps/labrinth/.sqlx/query-10200da00caaa8f1c0976b9b705bf6d74a59bb62ee5da6dccffec99f36eddac7.json new file mode 100644 index 00000000..b71e02b5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-10200da00caaa8f1c0976b9b705bf6d74a59bb62ee5da6dccffec99f36eddac7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM affiliate_codes WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "10200da00caaa8f1c0976b9b705bf6d74a59bb62ee5da6dccffec99f36eddac7" +} diff --git a/apps/labrinth/.sqlx/query-6ef8402f0f0685acda118c024d82e31b1b235ab7c5ec00a86af4dfbe81342a58.json b/apps/labrinth/.sqlx/query-6ef8402f0f0685acda118c024d82e31b1b235ab7c5ec00a86af4dfbe81342a58.json new file mode 100644 index 00000000..3e5ac7b8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6ef8402f0f0685acda118c024d82e31b1b235ab7c5ec00a86af4dfbe81342a58.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-81cfa59dafa8dac87a917a3ddda93d412a579ab17d9dad754e7d9e26d78e0e80.json b/apps/labrinth/.sqlx/query-81cfa59dafa8dac87a917a3ddda93d412a579ab17d9dad754e7d9e26d78e0e80.json new file mode 100644 index 00000000..7b257455 --- /dev/null +++ b/apps/labrinth/.sqlx/query-81cfa59dafa8dac87a917a3ddda93d412a579ab17d9dad754e7d9e26d78e0e80.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-b27a1495f0a937e1bae944cfe6e46a4f702536816e5259c4aeec8b905b507473.json b/apps/labrinth/.sqlx/query-b27a1495f0a937e1bae944cfe6e46a4f702536816e5259c4aeec8b905b507473.json new file mode 100644 index 00000000..b19b23b2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b27a1495f0a937e1bae944cfe6e46a4f702536816e5259c4aeec8b905b507473.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-b36457e1aa73b5517ca4f41e4144084a0aa996168d2660c13a1d4c9fc4594859.json b/apps/labrinth/.sqlx/query-b36457e1aa73b5517ca4f41e4144084a0aa996168d2660c13a1d4c9fc4594859.json new file mode 100644 index 00000000..94ee4d84 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b36457e1aa73b5517ca4f41e4144084a0aa996168d2660c13a1d4c9fc4594859.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-d51d96a9771ce1c3a2987e0790ef25bc55122c934c73418a97ee9cd81f56b251.json b/apps/labrinth/.sqlx/query-d51d96a9771ce1c3a2987e0790ef25bc55122c934c73418a97ee9cd81f56b251.json new file mode 100644 index 00000000..5a0d6d37 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d51d96a9771ce1c3a2987e0790ef25bc55122c934c73418a97ee9cd81f56b251.json @@ -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" +} diff --git a/apps/labrinth/migrations/20250914190749_affiliate_codes.sql b/apps/labrinth/migrations/20250914190749_affiliate_codes.sql new file mode 100644 index 00000000..514370bd --- /dev/null +++ b/apps/labrinth/migrations/20250914190749_affiliate_codes.sql @@ -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 +); diff --git a/apps/labrinth/src/database/models/affiliate_code_item.rs b/apps/labrinth/src/database/models/affiliate_code_item.rs new file mode 100644 index 00000000..5a4a9e63 --- /dev/null +++ b/apps/labrinth/src/database/models/affiliate_code_item.rs @@ -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, + 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, 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, 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::>() + .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, 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, 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::>() + .await?; + + Ok(records) + } +} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 559faf58..483f6841 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -1,6 +1,6 @@ use super::DatabaseError; use crate::models::ids::{ - ChargeId, CollectionId, FileId, ImageId, NotificationId, + AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId, @@ -263,6 +263,10 @@ db_id_interface!( VersionId, generator: generate_version_id @ "versions", ); +db_id_interface!( + AffiliateCodeId, + generator: generate_affiliate_code_id @ "affiliate_codes", +); short_id_type!(CategoryId); short_id_type!(GameId); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 925e4ac5..25d77ad9 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -1,5 +1,6 @@ use thiserror::Error; +pub mod affiliate_code_item; pub mod categories; pub mod charge_item; pub mod collection_item; @@ -34,6 +35,7 @@ pub mod users_notifications_preferences_item; pub mod users_redeemals; pub mod version_item; +pub use affiliate_code_item::DBAffiliateCode; pub use collection_item::DBCollection; pub use ids::*; pub use image_item::DBImage; diff --git a/apps/labrinth/src/models/v3/affiliate_code.rs b/apps/labrinth/src/models/v3/affiliate_code.rs new file mode 100644 index 00000000..dd23ae57 --- /dev/null +++ b/apps/labrinth/src/models/v3/affiliate_code.rs @@ -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, + /// 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 + 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 + for AffiliateCode +{ + fn from( + data: crate::database::models::affiliate_code_item::DBAffiliateCode, + ) -> Self { + Self { + id: data.id.into(), + affiliate: data.affiliate.into(), + } + } +} diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index a9572971..7cb162ec 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -25,3 +25,4 @@ base62_id!(ThreadId); base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); base62_id!(VersionId); +base62_id!(AffiliateCodeId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index c51c026f..4016dea6 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -1,3 +1,4 @@ +pub mod affiliate_code; pub mod analytics; pub mod billing; pub mod collections; diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs new file mode 100644 index 00000000..be56d9b7 --- /dev/null +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -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); + +async fn code_get_all( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, 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::>(); + + 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, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, 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, + redis: web::Data, + session_queue: web::Data, +) -> Result, 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, + redis: web::Data, + session_queue: web::Data, +) -> Result { + 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); + +async fn self_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, 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::>(); + + Ok(Json(SelfGetResponse(codes))) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 073f08c1..2701c2a6 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod admin; +pub mod affiliate; pub mod billing; pub mod external_notifications; pub mod flows; @@ -28,6 +29,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(gdpr::config) .configure(statuses::config) .configure(medal::config) - .configure(external_notifications::config), + .configure(external_notifications::config) + .configure(affiliate::config), ); }