Allow users to manage their own affiliate codes (#4392)

* Allow users to manage their own affiliate codes

* Add a badge to restrict access to affiliate codes

* sqlx prepare and clippy
This commit is contained in:
aecsocket
2025-09-22 17:54:09 +01:00
committed by GitHub
parent afcdb1d0a1
commit 20281c4efc
9 changed files with 201 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes WHERE id = $1", "query": "SELECT id, created_at, created_by, affiliate, source_name\n FROM affiliate_codes WHERE affiliate = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -22,6 +22,11 @@
"ordinal": 3, "ordinal": 3,
"name": "affiliate", "name": "affiliate",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 4,
"name": "source_name",
"type_info": "Varchar"
} }
], ],
"parameters": { "parameters": {
@@ -33,8 +38,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "81cfa59dafa8dac87a917a3ddda93d412a579ab17d9dad754e7d9e26d78e0e80" "hash": "057b0fda8e0ad34fc880121b6461ddfc3c61d4a0bf95e376e24440b6a58d2844"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "INSERT INTO affiliate_codes (id, created_at, created_by, affiliate)\n VALUES ($1, $2, $3, $4)", "query": "INSERT INTO affiliate_codes (id, created_at, created_by, affiliate, source_name)\n VALUES ($1, $2, $3, $4, $5)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -8,10 +8,11 @@
"Int8", "Int8",
"Timestamptz", "Timestamptz",
"Int8", "Int8",
"Int8" "Int8",
"Varchar"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "d51d96a9771ce1c3a2987e0790ef25bc55122c934c73418a97ee9cd81f56b251" "hash": "0e6d18643a4a7834eb34fe519b073e290b1089d2d0cfdfdb45b5125a931d08ca"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes WHERE affiliate = $1", "query": "SELECT id, created_at, created_by, affiliate, source_name\n FROM affiliate_codes WHERE id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -22,6 +22,11 @@
"ordinal": 3, "ordinal": 3,
"name": "affiliate", "name": "affiliate",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 4,
"name": "source_name",
"type_info": "Varchar"
} }
], ],
"parameters": { "parameters": {
@@ -33,8 +38,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "6ef8402f0f0685acda118c024d82e31b1b235ab7c5ec00a86af4dfbe81342a58" "hash": "1bd7365eaeac25b1286030a900767eef3b1b6e200ab0dbb3a4274eeba95f9568"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, created_at, created_by, affiliate\n FROM affiliate_codes ORDER BY created_at DESC", "query": "SELECT id, created_at, created_by, affiliate, source_name\n FROM affiliate_codes ORDER BY created_at DESC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -22,6 +22,11 @@
"ordinal": 3, "ordinal": 3,
"name": "affiliate", "name": "affiliate",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 4,
"name": "source_name",
"type_info": "Varchar"
} }
], ],
"parameters": { "parameters": {
@@ -31,8 +36,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "b27a1495f0a937e1bae944cfe6e46a4f702536816e5259c4aeec8b905b507473" "hash": "cc95f1b143399b5ecebc91fa74820bfe8c1057c26471b17efa4213a09520a65e"
} }

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE affiliate_codes SET source_name = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "d307116366d03315a8a01d1b62c5ca81624a42d01a43e1d5adf8881f8a71d495"
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE affiliate_codes
ADD COLUMN source_name VARCHAR(255) NOT NULL DEFAULT '(unnamed)';

View File

@@ -9,6 +9,7 @@ pub struct DBAffiliateCode {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub created_by: DBUserId, pub created_by: DBUserId,
pub affiliate: DBUserId, pub affiliate: DBUserId,
pub source_name: String,
} }
impl DBAffiliateCode { impl DBAffiliateCode {
@@ -17,7 +18,7 @@ impl DBAffiliateCode {
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBAffiliateCode>, DatabaseError> { ) -> Result<Option<DBAffiliateCode>, DatabaseError> {
let record = sqlx::query!( let record = sqlx::query!(
"SELECT id, created_at, created_by, affiliate "SELECT id, created_at, created_by, affiliate, source_name
FROM affiliate_codes WHERE id = $1", FROM affiliate_codes WHERE id = $1",
id as DBAffiliateCodeId id as DBAffiliateCodeId
) )
@@ -29,6 +30,7 @@ impl DBAffiliateCode {
created_at: record.created_at, created_at: record.created_at,
created_by: DBUserId(record.created_by), created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate), affiliate: DBUserId(record.affiliate),
source_name: record.source_name,
})) }))
} }
@@ -37,7 +39,7 @@ impl DBAffiliateCode {
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBAffiliateCode>, DatabaseError> { ) -> Result<Vec<DBAffiliateCode>, DatabaseError> {
let records = sqlx::query!( let records = sqlx::query!(
"SELECT id, created_at, created_by, affiliate "SELECT id, created_at, created_by, affiliate, source_name
FROM affiliate_codes WHERE affiliate = $1", FROM affiliate_codes WHERE affiliate = $1",
affiliate as DBUserId affiliate as DBUserId
) )
@@ -49,6 +51,7 @@ impl DBAffiliateCode {
created_at: record.created_at, created_at: record.created_at,
created_by: DBUserId(record.created_by), created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate), affiliate: DBUserId(record.affiliate),
source_name: record.source_name,
}) })
}) })
.try_collect::<Vec<_>>() .try_collect::<Vec<_>>()
@@ -62,12 +65,13 @@ impl DBAffiliateCode {
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
sqlx::query!( sqlx::query!(
"INSERT INTO affiliate_codes (id, created_at, created_by, affiliate) "INSERT INTO affiliate_codes (id, created_at, created_by, affiliate, source_name)
VALUES ($1, $2, $3, $4)", VALUES ($1, $2, $3, $4, $5)",
self.id as DBAffiliateCodeId, self.id as DBAffiliateCodeId,
self.created_at, self.created_at,
self.created_by as DBUserId, self.created_by as DBUserId,
self.affiliate as DBUserId self.affiliate as DBUserId,
self.source_name
) )
.execute(exec) .execute(exec)
.await?; .await?;
@@ -92,11 +96,27 @@ impl DBAffiliateCode {
} }
} }
pub async fn update_source_name(
id: DBAffiliateCodeId,
source_name: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"UPDATE affiliate_codes SET source_name = $1 WHERE id = $2",
source_name,
id as DBAffiliateCodeId
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn get_all( pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBAffiliateCode>, DatabaseError> { ) -> Result<Vec<DBAffiliateCode>, DatabaseError> {
let records = sqlx::query!( let records = sqlx::query!(
"SELECT id, created_at, created_by, affiliate "SELECT id, created_at, created_by, affiliate, source_name
FROM affiliate_codes ORDER BY created_at DESC" FROM affiliate_codes ORDER BY created_at DESC"
) )
.fetch(exec) .fetch(exec)
@@ -107,6 +127,7 @@ impl DBAffiliateCode {
created_at: record.created_at, created_at: record.created_at,
created_by: DBUserId(record.created_by), created_by: DBUserId(record.created_by),
affiliate: DBUserId(record.affiliate), affiliate: DBUserId(record.affiliate),
source_name: record.source_name,
}) })
}) })
.try_collect::<Vec<_>>() .try_collect::<Vec<_>>()

View File

@@ -17,9 +17,7 @@ bitflags::bitflags! {
const ALPHA_TESTER = 1 << 4; const ALPHA_TESTER = 1 << 4;
const CONTRIBUTOR = 1 << 5; const CONTRIBUTOR = 1 << 5;
const TRANSLATOR = 1 << 6; const TRANSLATOR = 1 << 6;
const AFFILIATE = 1 << 7;
const ALL = 0b1111111;
const NONE = 0b0;
} }
} }
@@ -27,7 +25,7 @@ bitflags_serde_impl!(Badges, u64);
impl Default for Badges { impl Default for Badges {
fn default() -> Badges { fn default() -> Badges {
Badges::NONE Badges::empty()
} }
} }

View File

@@ -7,6 +7,7 @@ use crate::{
models::{ models::{
ids::AffiliateCodeId, ids::AffiliateCodeId,
pats::Scopes, pats::Scopes,
users::Badges,
v3::affiliate_code::{AdminAffiliateCode, AffiliateCode}, v3::affiliate_code::{AdminAffiliateCode, AffiliateCode},
}, },
queue::session::AuthQueue, queue::session::AuthQueue,
@@ -25,23 +26,25 @@ 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("/code", web::get().to(code_get_all)) .route("/admin", web::get().to(admin_get_all))
.route("/code", web::put().to(code_create)) .route("/admin", web::put().to(admin_create))
.route("/code/{id}", web::get().to(code_get)) .route("/admin/{id}", web::get().to(admin_get))
.route("/code/{id}", web::delete().to(code_delete)) .route("/admin/{id}", web::delete().to(admin_delete))
.route("/self", web::get().to(self_get)), .route("/self", web::get().to(self_get_all))
.route("/self", web::put().to(self_patch))
.route("/self/{id}", web::delete().to(self_delete)),
); );
} }
#[derive(Serialize)] #[derive(Serialize)]
struct CodeGetAllResponse(Vec<AdminAffiliateCode>); struct AdminGetAllResponse(Vec<AdminAffiliateCode>);
async fn code_get_all( 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<CodeGetAllResponse>, ApiError> { ) -> Result<Json<AdminGetAllResponse>, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -64,24 +67,25 @@ async fn code_get_all(
.map(AdminAffiliateCode::from) .map(AdminAffiliateCode::from)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Json(CodeGetAllResponse(codes))) Ok(Json(AdminGetAllResponse(codes)))
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct CodeCreateRequest { struct AdminCreateRequest {
affiliate: UserId, affiliate: UserId,
source_name: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct CodeCreateResponse(AdminAffiliateCode); struct AdminCreateResponse(AdminAffiliateCode);
async fn code_create( 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<CodeCreateRequest>, body: web::Json<AdminCreateRequest>,
) -> Result<Json<CodeCreateResponse>, ApiError> { ) -> Result<Json<AdminCreateResponse>, ApiError> {
let (_, creator) = get_user_from_headers( let (_, creator) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -119,24 +123,25 @@ async fn code_create(
created_at: Utc::now(), created_at: Utc::now(),
created_by: creator_id, created_by: creator_id,
affiliate: affiliate_id, affiliate: affiliate_id,
source_name: body.source_name.clone(),
}; };
code.insert(&mut *transaction).await?; code.insert(&mut *transaction).await?;
transaction.commit().await?; transaction.commit().await?;
Ok(Json(CodeCreateResponse(AdminAffiliateCode::from(code)))) Ok(Json(AdminCreateResponse(AdminAffiliateCode::from(code))))
} }
#[derive(Serialize)] #[derive(Serialize)]
struct CodeGetResponse(AdminAffiliateCode); struct AdminGetResponse(AdminAffiliateCode);
async fn code_get( 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<CodeGetResponse>, ApiError> { ) -> Result<Json<AdminGetResponse>, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -159,13 +164,13 @@ async fn code_get(
DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await? DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await?
{ {
let model = AdminAffiliateCode::from(model); let model = AdminAffiliateCode::from(model);
Ok(Json(CodeGetResponse(model))) Ok(Json(AdminGetResponse(model)))
} else { } else {
Err(ApiError::NotFound) Err(ApiError::NotFound)
} }
} }
async fn code_delete( async fn admin_delete(
req: HttpRequest, req: HttpRequest,
path: web::Path<(AffiliateCodeId,)>, path: web::Path<(AffiliateCodeId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
@@ -201,14 +206,14 @@ async fn code_delete(
} }
#[derive(Serialize)] #[derive(Serialize)]
struct SelfGetResponse(Vec<AffiliateCode>); struct SelfGetAllResponse(Vec<AffiliateCode>);
async fn self_get( async fn self_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<SelfGetResponse>, ApiError> { ) -> Result<Json<SelfGetAllResponse>, ApiError> {
let (_, user) = get_user_from_headers( let (_, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
@@ -218,6 +223,13 @@ async fn self_get(
) )
.await?; .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 = let codes =
DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool) DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool)
.await?; .await?;
@@ -227,5 +239,96 @@ async fn self_get(
.map(AffiliateCode::from) .map(AffiliateCode::from)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Json(SelfGetResponse(codes))) Ok(Json(SelfGetAllResponse(codes)))
}
#[derive(Deserialize)]
struct SelfPatchRequest {
id: AffiliateCodeId,
source_name: String,
}
async fn self_patch(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
body: web::Json<SelfPatchRequest>,
) -> 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 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)
.await?
.ok_or(ApiError::NotFound)?;
if existing_code.affiliate != DBUserId::from(user.id) {
return Err(ApiError::NotFound);
}
DBAffiliateCode::update_source_name(
affiliate_code_id,
&body.source_name,
&**pool,
)
.await?;
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)
}
} }