From 2af7ecc0779255ccfeda1a5897f66acc0b06d271 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 3 Oct 2025 15:24:15 +0100 Subject: [PATCH] Fix affiliate PUT API (#4456) * Fix affiliate PUT API * PR fixes * wip: merge affiliate code endpoints --- CLAUDE.md | 7 + apps/labrinth/src/models/v3/affiliate_code.rs | 38 ++- .../labrinth/src/routes/internal/affiliate.rs | 273 +++++++----------- 3 files changed, 139 insertions(+), 179 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9253d7c7..172f4d8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,15 @@ Before a pull request can be opened, run `cargo clippy -p labrinth --all-targets Use `cargo test -p labrinth --all-targets` to test your changes. All tests must pass, otherwise CI will fail. +To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`. Make sure to NEVER run `cargo sqlx prepare --workspace`. + Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services. +When the user refers to "performing pre-PR checks", do the following: +- Run clippy as described above +- DO NOT run tests unless explicitly requested (they take a long time) +- Prepare the sqlx cache + ### Clickhouse Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing. diff --git a/apps/labrinth/src/models/v3/affiliate_code.rs b/apps/labrinth/src/models/v3/affiliate_code.rs index dd23ae57..dfe9150f 100644 --- a/apps/labrinth/src/models/v3/affiliate_code.rs +++ b/apps/labrinth/src/models/v3/affiliate_code.rs @@ -19,6 +19,25 @@ pub struct AdminAffiliateCode { pub created_by: UserId, /// User who refers the purchaser. pub affiliate: UserId, + /// Affiliate-defined name for this affiliate code - where the click came + /// from. + pub source_name: String, +} + +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(), + source_name: data.source_name, + } + } } /// Affiliate code used to track referral purchases. @@ -36,21 +55,9 @@ pub struct AffiliateCode { 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(), - } - } + /// Affiliate-defined name for this affiliate code - where the click came + /// from. + pub source_name: String, } impl From @@ -62,6 +69,7 @@ impl From Self { id: data.id.into(), affiliate: data.affiliate.into(), + source_name: data.source_name, } } } diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs index c73858e6..c48cc5af 100644 --- a/apps/labrinth/src/routes/internal/affiliate.rs +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -12,13 +12,10 @@ use crate::{ }, queue::session::AuthQueue, }; -use actix_web::{ - HttpRequest, HttpResponse, - web::{self, Json}, -}; +use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; use chrono::Utc; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use sqlx::PgPool; use crate::routes::ApiError; @@ -26,25 +23,20 @@ use crate::routes::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("affiliate") - .route("/admin", web::get().to(admin_get_all)) - .route("/admin", web::put().to(admin_create)) - .route("/admin/{id}", web::get().to(admin_get)) - .route("/admin/{id}", web::delete().to(admin_delete)) - .route("/self", web::get().to(self_get_all)) - .route("/self", web::put().to(self_patch)) - .route("/self/{id}", web::delete().to(self_delete)), + .route("", web::get().to(get_all)) + .route("", web::put().to(create)) + .route("/{id}", web::get().to(get)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}", web::patch().to(patch)), ); } -#[derive(Serialize)] -struct AdminGetAllResponse(Vec); - -async fn admin_get_all( +async fn get_all( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result, ApiError> { +) -> Result { let (_, user) = get_user_from_headers( &req, &**pool, @@ -54,38 +46,42 @@ async fn admin_get_all( ) .await?; - if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to read all affiliate codes!" - .to_string(), - )); + if user.role.is_admin() { + let codes = DBAffiliateCode::get_all(&**pool).await?; + let codes = codes + .into_iter() + .map(AdminAffiliateCode::from) + .collect::>(); + Ok(HttpResponse::Ok().json(codes)) + } else if user.badges.contains(Badges::AFFILIATE) { + let codes = + DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool) + .await?; + let codes = codes + .into_iter() + .map(AffiliateCode::from) + .collect::>(); + Ok(HttpResponse::Ok().json(codes)) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to view affiliate codes!".to_string(), + )) } - - let codes = DBAffiliateCode::get_all(&**pool).await?; - let codes = codes - .into_iter() - .map(AdminAffiliateCode::from) - .collect::>(); - - Ok(Json(AdminGetAllResponse(codes))) } -#[derive(Serialize, Deserialize)] -struct AdminCreateRequest { - affiliate: UserId, +#[derive(Deserialize)] +struct CreateRequest { + affiliate: Option, source_name: String, } -#[derive(Serialize)] -struct AdminCreateResponse(AdminAffiliateCode); - -async fn admin_create( +async fn create( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, - body: web::Json, -) -> Result, ApiError> { + body: web::Json, +) -> Result { let (_, creator) = get_user_from_headers( &req, &**pool, @@ -95,7 +91,10 @@ async fn admin_create( ) .await?; - if !creator.role.is_admin() { + let is_admin = creator.role.is_admin(); + let is_affiliate = creator.badges.contains(Badges::AFFILIATE); + + if !is_admin && !is_affiliate { return Err(ApiError::CustomAuthentication( "You do not have permission to create an affiliate code!" .to_string(), @@ -103,15 +102,26 @@ async fn admin_create( } 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 affiliate_id = if is_admin { + if let Some(affiliate) = body.affiliate { + DBUserId::from(affiliate) + } else { + creator_id + } + } else { + creator_id }; + if affiliate_id != creator_id { + 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 = @@ -129,19 +139,20 @@ async fn admin_create( transaction.commit().await?; - Ok(Json(AdminCreateResponse(AdminAffiliateCode::from(code)))) + if is_admin { + Ok(HttpResponse::Created().json(AdminAffiliateCode::from(code))) + } else { + Ok(HttpResponse::Created().json(AffiliateCode::from(code))) + } } -#[derive(Serialize)] -struct AdminGetResponse(AdminAffiliateCode); - -async fn admin_get( +async fn get( req: HttpRequest, path: web::Path<(AffiliateCodeId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result, ApiError> { +) -> Result { let (_, user) = get_user_from_headers( &req, &**pool, @@ -151,26 +162,30 @@ async fn admin_get( ) .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(AdminGetResponse(model))) + let is_admin = user.role.is_admin(); + let is_owner = model.affiliate == DBUserId::from(user.id); + + if is_admin || is_owner { + if is_admin { + Ok(HttpResponse::Ok().json(AdminAffiliateCode::from(model))) + } else { + Ok(HttpResponse::Ok().json(AffiliateCode::from(model))) + } + } else { + Err(ApiError::NotFound) + } } else { Err(ApiError::NotFound) } } -async fn admin_delete( +async fn delete( req: HttpRequest, path: web::Path<(AffiliateCodeId,)>, pool: web::Data, @@ -186,74 +201,43 @@ async fn admin_delete( ) .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 let Some(model) = + DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await? + { + let is_admin = user.role.is_admin(); + let is_owner = model.affiliate == DBUserId::from(user.id); - if result.is_some() { - Ok(HttpResponse::NoContent().finish()) + if is_admin || is_owner { + let result = + DBAffiliateCode::remove(affiliate_code_id, &**pool).await?; + if result.is_some() { + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } } else { Err(ApiError::NotFound) } } -#[derive(Serialize)] -struct SelfGetAllResponse(Vec); - -async fn self_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.badges.contains(Badges::AFFILIATE) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to view your affiliate codes!" - .to_string(), - )); - } - - let codes = - DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool) - .await?; - - let codes = codes - .into_iter() - .map(AffiliateCode::from) - .collect::>(); - - Ok(Json(SelfGetAllResponse(codes))) -} - #[derive(Deserialize)] -struct SelfPatchRequest { - id: AffiliateCodeId, +struct PatchRequest { source_name: String, } -async fn self_patch( +async fn patch( req: HttpRequest, + path: web::Path<(AffiliateCodeId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, - body: web::Json, + body: web::Json, ) -> Result { let (_, user) = get_user_from_headers( &req, @@ -264,23 +248,26 @@ async fn self_patch( ) .await?; - if !user.badges.contains(Badges::AFFILIATE) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to update your affiliate codes!" - .to_string(), - )); - } - - let affiliate_code_id = DBAffiliateCodeId::from(body.id); + let (affiliate_code_id,) = path.into_inner(); + let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); let existing_code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) .await? .ok_or(ApiError::NotFound)?; - if existing_code.affiliate != DBUserId::from(user.id) { + let is_admin = user.role.is_admin(); + let is_owner = existing_code.affiliate == DBUserId::from(user.id); + + if !is_admin && !is_owner { return Err(ApiError::NotFound); } + if !is_admin && !user.badges.contains(Badges::AFFILIATE) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to update affiliate codes!".to_string(), + )); + } + DBAffiliateCode::update_source_name( affiliate_code_id, &body.source_name, @@ -290,45 +277,3 @@ async fn self_patch( Ok(HttpResponse::NoContent().finish()) } - -async fn self_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.badges.contains(Badges::AFFILIATE) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to delete your affiliate codes!" - .to_string(), - )); - } - - let (affiliate_code_id,) = path.into_inner(); - let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); - - let code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) - .await? - .ok_or(ApiError::NotFound)?; - - if code.affiliate != DBUserId::from(user.id) { - return Err(ApiError::NotFound); - } - - let result = DBAffiliateCode::remove(affiliate_code_id, &**pool).await?; - if result.is_some() { - Ok(HttpResponse::NoContent().finish()) - } else { - Err(ApiError::NotFound) - } -}