This commit is contained in:
Brandon Thomas committed 2026-05-13 20:24:09 -04:00
1 parent 673f20fca5
commit 47576f9e29
19 files changed
+476

No files matched your search

@@ -10,6 +10,7 @@ pub mod omni_gen;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod user_referral_codes;
pub mod users;
pub mod utils;
pub mod web_referrals;
@@ -0,0 +1,17 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use utoipa::ToSchema;
#[derive(Deserialize, ToSchema)]
pub struct CreateReferralCodeRequest {
/// The referral code string. Alphanumeric plus underscore, period, dash.
pub code: String,
}
#[derive(Serialize, ToSchema)]
pub struct CreateReferralCodeResponse {
pub success: bool,
pub token: UserReferralCodeToken,
pub code: String,
pub code_lowercase: String,
}
@@ -0,0 +1,13 @@
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use utoipa::ToSchema;
#[derive(Deserialize, ToSchema)]
pub struct DeleteReferralCodePathInfo {
pub token: UserReferralCodeToken,
}
#[derive(Serialize, ToSchema)]
pub struct DeleteReferralCodeResponse {
pub success: bool,
}
@@ -0,0 +1,19 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ListReferralCodesResponse {
pub success: bool,
pub referral_codes: Vec<ReferralCodeEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct ReferralCodeEntry {
pub token: UserReferralCodeToken,
pub code: String,
pub code_lowercase: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,3 @@
pub mod create_referral_code;
pub mod delete_referral_code;
pub mod list_referral_codes;
@@ -36,6 +36,7 @@ pub mod tts;
pub mod twitch;
pub mod unsubscribe_reason;
pub mod user_impersonation_requests;
pub mod user_referral_codes;
pub mod users;
pub mod vocoder;
pub mod voice_clone_requests;
@@ -0,0 +1,39 @@
use sqlx::{Executor, MySql};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
pub struct CreateReferralCodeArgs<'a> {
pub owner_user_token: &'a UserToken,
pub code: &'a str,
pub code_lowercase: &'a str,
}
pub async fn create_referral_code<'e, 'c: 'e, E>(
args: CreateReferralCodeArgs<'_>,
mysql_executor: E,
) -> Result<UserReferralCodeToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let token = UserReferralCodeToken::generate();
sqlx::query!(
r#"
INSERT INTO user_referral_codes (
token,
code,
code_lowercase,
owner_user_token
)
VALUES (?, ?, ?, ?)
"#,
token.as_str(),
args.code,
args.code_lowercase,
args.owner_user_token.as_str(),
)
.execute(mysql_executor)
.await?;
Ok(token)
}
@@ -0,0 +1,55 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
pub struct ReferralCodeRow {
pub token: UserReferralCodeToken,
pub code: String,
pub code_lowercase: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
struct RawRow {
token: UserReferralCodeToken,
code: String,
code_lowercase: String,
created_at: NaiveDateTime,
updated_at: NaiveDateTime,
}
pub async fn list_referral_codes_for_user<'e, 'c: 'e, E>(
owner_user_token: &UserToken,
mysql_executor: E,
) -> Result<Vec<ReferralCodeRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
RawRow,
r#"
SELECT
token as `token: UserReferralCodeToken`,
code,
code_lowercase,
created_at,
updated_at
FROM user_referral_codes
WHERE owner_user_token = ?
AND deleted_at IS NULL
ORDER BY created_at ASC
"#,
owner_user_token.as_str(),
)
.fetch_all(mysql_executor)
.await?;
Ok(rows.into_iter().map(|r| ReferralCodeRow {
token: r.token,
code: r.code,
code_lowercase: r.code_lowercase,
created_at: r.created_at.and_utc(),
updated_at: r.updated_at.and_utc(),
}).collect())
}
@@ -0,0 +1,3 @@
pub mod create_referral_code;
pub mod list_referral_codes_for_user;
pub mod soft_delete_referral_code;
@@ -0,0 +1,39 @@
use sqlx::{Executor, MySql};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
use uuid_utils::uuid::generate_random_uuid;
/// Soft-delete a referral code owned by the given user.
/// Sets `deleted_at` and replaces `code_lowercase` with random entropy
/// to free up the unique constraint for reuse.
/// Returns true if a row was updated.
pub async fn soft_delete_referral_code<'e, 'c: 'e, E>(
token: &UserReferralCodeToken,
owner_user_token: &UserToken,
mysql_executor: E,
) -> Result<bool, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
// Replace code_lowercase with random entropy (max 32 chars) to free uniqueness constraint.
let tombstone = format!("_deleted_{}", &generate_random_uuid()[..20]);
let result = sqlx::query!(
r#"
UPDATE user_referral_codes
SET
deleted_at = CURRENT_TIMESTAMP,
code_lowercase = ?
WHERE token = ?
AND owner_user_token = ?
AND deleted_at IS NULL
"#,
tombstone,
token.as_str(),
owner_user_token.as_str(),
)
.execute(mysql_executor)
.await?;
Ok(result.rows_affected() > 0)
}
@@ -316,6 +316,9 @@ use artcraft_api_defs::moderation::wallets::list_user_wallets::*;
use artcraft_api_defs::moderation::wallets::moderator_add_banked_balance_to_wallet::*;
use artcraft_api_defs::moderation::wallets::moderator_create_wallet_for_user::*;
use artcraft_api_defs::moderation::wallets::moderator_get_wallet::*;
use artcraft_api_defs::user_referral_codes::create_referral_code::*;
use artcraft_api_defs::user_referral_codes::delete_referral_code::*;
use artcraft_api_defs::user_referral_codes::list_referral_codes::*;
use artcraft_api_defs::web_referrals::log_web_referral::*;
use crate::http_server::endpoints::web_referrals::log_web_referral_handler::*;
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*;
@@ -536,6 +539,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::subscriptions::get_session_subscription_handler::get_session_subscription_handler,
// Web Referrals
crate::http_server::endpoints::web_referrals::log_web_referral_handler::log_web_referral_handler,
// User Referral Codes
crate::http_server::endpoints::user_referral_codes::create_referral_code_handler::create_referral_code_handler,
crate::http_server::endpoints::user_referral_codes::list_referral_codes_handler::list_referral_codes_handler,
crate::http_server::endpoints::user_referral_codes::delete_referral_code_handler::delete_referral_code_handler,
// Image Studio
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
@@ -1210,6 +1217,14 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// Web Referrals
LogWebReferralRequest,
LogWebReferralResponse,
// User Referral Codes
CreateReferralCodeRequest,
CreateReferralCodeResponse,
ListReferralCodesResponse,
ReferralCodeEntry,
DeleteReferralCodePathInfo,
DeleteReferralCodeResponse,
))
)]
pub struct ApiDoc;
@@ -25,6 +25,7 @@ pub mod trending;
pub mod tts;
pub mod user_bookmarks;
pub mod user_ratings;
pub mod user_referral_codes;
pub mod user_wallets;
pub mod users;
pub mod voice_clone_requests;
@@ -0,0 +1,110 @@
use std::sync::Arc;
use actix_web::web::{self, Json};
use actix_web::HttpRequest;
use log::warn;
use utoipa::ToSchema;
use artcraft_api_defs::user_referral_codes::create_referral_code::{CreateReferralCodeRequest, CreateReferralCodeResponse};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use mysql_queries::queries::user_referral_codes::create_referral_code::{create_referral_code, CreateReferralCodeArgs};
use mysql_queries::queries::user_referral_codes::list_referral_codes_for_user::list_referral_codes_for_user;
const MAX_ACTIVE_CODES: usize = 5;
/// Create a new referral code for the logged-in user.
#[utoipa::path(
post,
tag = "User Referral Codes",
path = "/v1/user_referral_codes/create",
request_body = CreateReferralCodeRequest,
responses(
(status = 200, description = "Success", body = CreateReferralCodeResponse),
(status = 400, description = "Bad input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn create_referral_code_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
request: web::Json<CreateReferralCodeRequest>,
) -> Result<Json<CreateReferralCodeResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await
.map_err(|e| AdvancedCommonWebError::from(e))?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) if !session.is_banned => session,
_ => return Err(AdvancedCommonWebError::NotAuthorized),
};
let user_token = &user_session.user_token;
// Validate code: trim, check length, check characters.
let code = request.code.trim().to_string();
if code.is_empty() {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Referral code cannot be empty".to_string(),
));
}
if code.len() > 32 {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Referral code must be 32 characters or fewer".to_string(),
));
}
if !code.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-') {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"Referral code may only contain letters, numbers, underscores, periods, and dashes".to_string(),
));
}
let code_lowercase = code.to_lowercase();
// Check the user doesn't already have too many active codes.
let existing = list_referral_codes_for_user(user_token, &mut *mysql_connection).await
.map_err(|e| AdvancedCommonWebError::from(e))?;
if existing.len() >= MAX_ACTIVE_CODES {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("You can have at most {} active referral codes. Delete one first.", MAX_ACTIVE_CODES),
));
}
// Insert the code. If code_lowercase is already taken, we get a duplicate key error.
let token = create_referral_code(
CreateReferralCodeArgs {
owner_user_token: user_token,
code: &code,
code_lowercase: &code_lowercase,
},
&mut *mysql_connection,
).await;
match token {
Ok(token) => Ok(Json(CreateReferralCodeResponse {
success: true,
token,
code,
code_lowercase,
})),
Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23000") => {
Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"This referral code is already in use".to_string(),
))
}
Err(e) => Err(AdvancedCommonWebError::from(e)),
}
}
@@ -0,0 +1,65 @@
use std::sync::Arc;
use actix_web::web::{self, Json};
use actix_web::HttpRequest;
use log::warn;
use artcraft_api_defs::user_referral_codes::delete_referral_code::{DeleteReferralCodePathInfo, DeleteReferralCodeResponse};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use mysql_queries::queries::user_referral_codes::soft_delete_referral_code::soft_delete_referral_code;
/// Delete (soft-delete) a referral code owned by the logged-in user.
#[utoipa::path(
delete,
tag = "User Referral Codes",
path = "/v1/user_referral_codes/code/{token}",
params(
("token" = UserReferralCodeToken, description = "The referral code token to delete"),
),
responses(
(status = 200, description = "Success", body = DeleteReferralCodeResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn delete_referral_code_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
path: web::Path<DeleteReferralCodePathInfo>,
) -> Result<Json<DeleteReferralCodeResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await
.map_err(|e| AdvancedCommonWebError::from(e))?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) if !session.is_banned => session,
_ => return Err(AdvancedCommonWebError::NotAuthorized),
};
let user_token = &user_session.user_token;
let deleted = soft_delete_referral_code(
&path.token,
user_token,
&mut *mysql_connection,
).await
.map_err(|e| AdvancedCommonWebError::from(e))?;
if !deleted {
return Err(AdvancedCommonWebError::NotAuthorized);
}
Ok(Json(DeleteReferralCodeResponse {
success: true,
}))
}
@@ -0,0 +1,61 @@
use std::sync::Arc;
use actix_web::web::{self, Json};
use actix_web::HttpRequest;
use log::warn;
use artcraft_api_defs::user_referral_codes::list_referral_codes::{ListReferralCodesResponse, ReferralCodeEntry};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use mysql_queries::queries::user_referral_codes::list_referral_codes_for_user::list_referral_codes_for_user;
/// List all active referral codes for the logged-in user.
#[utoipa::path(
get,
tag = "User Referral Codes",
path = "/v1/user_referral_codes/list",
responses(
(status = 200, description = "Success", body = ListReferralCodesResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn list_referral_codes_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListReferralCodesResponse>, AdvancedCommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await
.map_err(|e| AdvancedCommonWebError::from(e))?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
AdvancedCommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) if !session.is_banned => session,
_ => return Err(AdvancedCommonWebError::NotAuthorized),
};
let user_token = &user_session.user_token;
let rows = list_referral_codes_for_user(user_token, &mut *mysql_connection).await
.map_err(|e| AdvancedCommonWebError::from(e))?;
let referral_codes = rows.into_iter().map(|r| ReferralCodeEntry {
token: r.token,
code: r.code,
code_lowercase: r.code_lowercase,
created_at: r.created_at,
updated_at: r.updated_at,
}).collect();
Ok(Json(ListReferralCodesResponse {
success: true,
referral_codes,
}))
}
@@ -0,0 +1,3 @@
pub mod create_referral_code_handler;
pub mod delete_referral_code_handler;
pub mod list_referral_codes_handler;
@@ -15,6 +15,7 @@ use crate::http_server::routes::application_routes::subscription_routes::add_sub
use crate::http_server::routes::application_routes::tag_routes::add_tag_routes;
use crate::http_server::routes::application_routes::user_bookmarks_routes::add_user_bookmarks_routes;
use crate::http_server::routes::application_routes::user_rating_routes::add_user_rating_routes;
use crate::http_server::routes::application_routes::user_referral_code_routes::add_user_referral_code_routes;
use crate::http_server::routes::application_routes::user_routes::add_user_routes;
use crate::http_server::routes::application_routes::wallet_routes::add_wallet_routes;
use crate::http_server::routes::application_routes::web_referrals_routes::add_web_referrals_routes;
@@ -67,6 +68,7 @@ where
app = add_comments_routes(app); // /v1/comments/...
app = add_user_bookmarks_routes(app); // /v1/user_bookmarks/...
app = add_user_rating_routes(app); // /v1/user_rating/...
app = add_user_referral_code_routes(app); // /v1/user_referral_codes/...
app = add_user_routes(app); // /create_account, /session, /login, /logout, etc.
// Artcraft Billing pieces
@@ -15,6 +15,7 @@ mod subscription_routes;
mod tag_routes;
mod user_bookmarks_routes;
mod user_rating_routes;
mod user_referral_code_routes;
mod user_routes;
mod wallet_routes;
mod web_referrals_routes;
@@ -0,0 +1,28 @@
use actix_service::ServiceFactory;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::user_referral_codes::create_referral_code_handler::create_referral_code_handler;
use crate::http_server::endpoints::user_referral_codes::delete_referral_code_handler::delete_referral_code_handler;
use crate::http_server::endpoints::user_referral_codes::list_referral_codes_handler::list_referral_codes_handler;
pub fn add_user_referral_code_routes<T, B>(app: App<T>) -> App<T>
where
T: ServiceFactory<ServiceRequest, Config = (), Error = Error, Response = ServiceResponse<B>, InitError = ()>,
B: MessageBody,
{
app
.service(web::resource("/v1/user_referral_codes/create")
.route(web::post().to(create_referral_code_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/v1/user_referral_codes/list")
.route(web::get().to(list_referral_codes_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/v1/user_referral_codes/code/{token}")
.route(web::delete().to(delete_referral_code_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
}