Fix affiliate PUT API (#4456)

* Fix affiliate PUT API

* PR fixes

* wip: merge affiliate code endpoints
This commit is contained in:
aecsocket
2025-10-03 15:24:15 +01:00
committed by GitHub
parent bea0ba017c
commit 2af7ecc077
3 changed files with 139 additions and 179 deletions

View File

@@ -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. 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. 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 ### Clickhouse
Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing. Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing.

View File

@@ -19,6 +19,25 @@ pub struct AdminAffiliateCode {
pub created_by: UserId, pub created_by: UserId,
/// User who refers the purchaser. /// User who refers the purchaser.
pub affiliate: UserId, pub affiliate: UserId,
/// Affiliate-defined name for this affiliate code - where the click came
/// from.
pub source_name: String,
}
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(),
source_name: data.source_name,
}
}
} }
/// Affiliate code used to track referral purchases. /// Affiliate code used to track referral purchases.
@@ -36,21 +55,9 @@ pub struct AffiliateCode {
pub id: AffiliateCodeId, pub id: AffiliateCodeId,
/// User who refers the purchaser. /// User who refers the purchaser.
pub affiliate: UserId, pub affiliate: UserId,
} /// Affiliate-defined name for this affiliate code - where the click came
/// from.
impl From<crate::database::models::affiliate_code_item::DBAffiliateCode> pub source_name: String,
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> impl From<crate::database::models::affiliate_code_item::DBAffiliateCode>
@@ -62,6 +69,7 @@ impl From<crate::database::models::affiliate_code_item::DBAffiliateCode>
Self { Self {
id: data.id.into(), id: data.id.into(),
affiliate: data.affiliate.into(), affiliate: data.affiliate.into(),
source_name: data.source_name,
} }
} }
} }

View File

@@ -12,13 +12,10 @@ use crate::{
}, },
queue::session::AuthQueue, queue::session::AuthQueue,
}; };
use actix_web::{ use actix_web::{HttpRequest, HttpResponse, web};
HttpRequest, HttpResponse,
web::{self, Json},
};
use ariadne::ids::UserId; use ariadne::ids::UserId;
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::routes::ApiError; use crate::routes::ApiError;
@@ -26,25 +23,20 @@ use crate::routes::ApiError;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("affiliate") web::scope("affiliate")
.route("/admin", web::get().to(admin_get_all)) .route("", web::get().to(get_all))
.route("/admin", web::put().to(admin_create)) .route("", web::put().to(create))
.route("/admin/{id}", web::get().to(admin_get)) .route("/{id}", web::get().to(get))
.route("/admin/{id}", web::delete().to(admin_delete)) .route("/{id}", web::delete().to(delete))
.route("/self", web::get().to(self_get_all)) .route("/{id}", web::patch().to(patch)),
.route("/self", web::put().to(self_patch))
.route("/self/{id}", web::delete().to(self_delete)),
); );
} }
#[derive(Serialize)] async fn get_all(
struct AdminGetAllResponse(Vec<AdminAffiliateCode>);
async fn admin_get_all(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<Json<AdminGetAllResponse>, ApiError> { ) -> Result<HttpResponse, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -54,38 +46,42 @@ async fn admin_get_all(
) )
.await?; .await?;
if !user.role.is_admin() { if user.role.is_admin() {
return Err(ApiError::CustomAuthentication( let codes = DBAffiliateCode::get_all(&**pool).await?;
"You do not have permission to read all affiliate codes!" let codes = codes
.to_string(), .into_iter()
)); .map(AdminAffiliateCode::from)
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
Ok(Json(AdminGetAllResponse(codes)))
} }
#[derive(Serialize, Deserialize)] #[derive(Deserialize)]
struct AdminCreateRequest { struct CreateRequest {
affiliate: UserId, affiliate: Option<UserId>,
source_name: String, source_name: String,
} }
#[derive(Serialize)] async fn create(
struct AdminCreateResponse(AdminAffiliateCode);
async fn admin_create(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
body: web::Json<AdminCreateRequest>, body: web::Json<CreateRequest>,
) -> Result<Json<AdminCreateResponse>, ApiError> { ) -> Result<HttpResponse, ApiError> {
let (_, creator) = get_user_from_headers( let (_, creator) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -95,7 +91,10 @@ async fn admin_create(
) )
.await?; .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( return Err(ApiError::CustomAuthentication(
"You do not have permission to create an affiliate code!" "You do not have permission to create an affiliate code!"
.to_string(), .to_string(),
@@ -103,15 +102,26 @@ async fn admin_create(
} }
let creator_id = DBUserId::from(creator.id); let creator_id = DBUserId::from(creator.id);
let affiliate_id = DBUserId::from(body.affiliate); let affiliate_id = if is_admin {
let Some(_affiliate_user) = if let Some(affiliate) = body.affiliate {
DBUser::get_id(affiliate_id, &**pool, &redis).await? DBUserId::from(affiliate)
else { } else {
return Err(ApiError::CustomAuthentication( creator_id
"Affiliate user not found!".to_string(), }
)); } 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 mut transaction = pool.begin().await?;
let affiliate_code_id = let affiliate_code_id =
@@ -129,19 +139,20 @@ async fn admin_create(
transaction.commit().await?; 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)] async fn get(
struct AdminGetResponse(AdminAffiliateCode);
async fn admin_get(
req: HttpRequest, req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>, path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<Json<AdminGetResponse>, ApiError> { ) -> Result<HttpResponse, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -151,26 +162,30 @@ async fn admin_get(
) )
.await?; .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,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
if let Some(model) = if let Some(model) =
DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await? DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await?
{ {
let model = AdminAffiliateCode::from(model); let is_admin = user.role.is_admin();
Ok(Json(AdminGetResponse(model))) 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 { } else {
Err(ApiError::NotFound) Err(ApiError::NotFound)
} }
} }
async fn admin_delete( async fn delete(
req: HttpRequest, req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>, path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
@@ -186,74 +201,43 @@ async fn admin_delete(
) )
.await?; .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,) = path.into_inner();
let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); 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() { if is_admin || is_owner {
Ok(HttpResponse::NoContent().finish()) 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 { } else {
Err(ApiError::NotFound) Err(ApiError::NotFound)
} }
} }
#[derive(Serialize)]
struct SelfGetAllResponse(Vec<AffiliateCode>);
async fn self_get_all(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<SelfGetAllResponse>, 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::<Vec<_>>();
Ok(Json(SelfGetAllResponse(codes)))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SelfPatchRequest { struct PatchRequest {
id: AffiliateCodeId,
source_name: String, source_name: String,
} }
async fn self_patch( async fn patch(
req: HttpRequest, req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
body: web::Json<SelfPatchRequest>, body: web::Json<PatchRequest>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
@@ -264,23 +248,26 @@ async fn self_patch(
) )
.await?; .await?;
if !user.badges.contains(Badges::AFFILIATE) { let (affiliate_code_id,) = path.into_inner();
return Err(ApiError::CustomAuthentication( let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id);
"You do not have permission to update your affiliate codes!"
.to_string(),
));
}
let affiliate_code_id = DBAffiliateCodeId::from(body.id);
let existing_code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) let existing_code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool)
.await? .await?
.ok_or(ApiError::NotFound)?; .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); 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( DBAffiliateCode::update_source_name(
affiliate_code_id, affiliate_code_id,
&body.source_name, &body.source_name,
@@ -290,45 +277,3 @@ async fn self_patch(
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} }
async fn self_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.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)
}
}