1
0

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.
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.

View File

@@ -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<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.
@@ -36,21 +55,9 @@ pub struct AffiliateCode {
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(),
}
}
/// 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>
@@ -62,6 +69,7 @@ impl From<crate::database::models::affiliate_code_item::DBAffiliateCode>
Self {
id: data.id.into(),
affiliate: data.affiliate.into(),
source_name: data.source_name,
}
}
}

View File

@@ -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<AdminAffiliateCode>);
async fn admin_get_all(
async fn get_all(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<AdminGetAllResponse>, ApiError> {
) -> Result<HttpResponse, ApiError> {
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::<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)]
struct AdminCreateRequest {
affiliate: UserId,
#[derive(Deserialize)]
struct CreateRequest {
affiliate: Option<UserId>,
source_name: String,
}
#[derive(Serialize)]
struct AdminCreateResponse(AdminAffiliateCode);
async fn admin_create(
async fn create(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<AdminCreateRequest>,
) -> Result<Json<AdminCreateResponse>, ApiError> {
body: web::Json<CreateRequest>,
) -> Result<HttpResponse, ApiError> {
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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<Json<AdminGetResponse>, ApiError> {
) -> Result<HttpResponse, ApiError> {
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<PgPool>,
@@ -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<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)]
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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<SelfPatchRequest>,
body: web::Json<PatchRequest>,
) -> Result<HttpResponse, ApiError> {
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<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)
}
}