better user referral program (#1512)

This commit is contained in:
Brandon Thomas
2026-05-13 22:01:01 -04:00
committed by GitHub
parent 0ec9c0ce16
commit 7f40326223
48 changed files with 1226 additions and 81 deletions
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UserReferralCodeToken`,\n owner_user_token as `owner_user_token: UserToken`,\n code,\n code_lowercase\nFROM user_referral_codes\nWHERE code_lowercase = ?\n AND deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "owner_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "code",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "code_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "56462cb07d49a001095ff118bfdf4fab4c30d1c7e6dca09a11e82f5956508e0b"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_referral_codes (\n token,\n code,\n code_lowercase,\n owner_user_token\n)\nVALUES (?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "5e2e68267e858b449de90dbf0d5b20d60b8cb82c4a4ddf56a49200ac92912817"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_referrals (\n invited_user_token,\n referrer_user_token,\n maybe_referral_code_token,\n maybe_referral_url,\n maybe_landing_url\n)\nVALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "acd6e67603ca687686513b080bbfa642d3e2ffe8c97b0f9dd646f3ef2f8d92df"
}
@@ -0,0 +1,69 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: UserReferralCodeToken`,\n code,\n code_lowercase,\n created_at,\n updated_at\nFROM user_referral_codes\nWHERE owner_user_token = ?\n AND deleted_at IS NULL\nORDER BY created_at ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "code",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "code_lowercase",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 4,
"name": "updated_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "b5ce1dffc1d6e1762844f3a040a9a9cba2a608931824d99eebba67a7d718719e"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE user_referral_codes\nSET\n deleted_at = CURRENT_TIMESTAMP,\n code_lowercase = ?\nWHERE token = ?\n AND owner_user_token = ?\n AND deleted_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "b8254ed110147c66fbeef783283ff4b2565b60c9443572c67091ecd60c9d97d6"
}
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_referrals;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_referrals (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user who was invited/referred.
invited_user_token VARCHAR(32) NOT NULL,
-- The user who referred the invited user.
referrer_user_token VARCHAR(32) NOT NULL,
-- OPTIONAL. The referral code token used, if any.
maybe_referral_code_token VARCHAR(32) DEFAULT NULL,
-- OPTIONAL. The referral URL the invited user arrived from.
maybe_referral_url VARCHAR(255) DEFAULT NULL,
-- OPTIONAL. The landing URL the invited user first visited.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY unique_invited_user_token (invited_user_token),
KEY index_referrer_user_token (referrer_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_referral_codes;
@@ -0,0 +1,34 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_referral_codes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Unique token identifier for this referral code record.
token VARCHAR(32) NOT NULL,
-- The human-readable referral code (e.g. a username or custom code).
-- This can be changed.
code VARCHAR(32) NOT NULL,
-- Lowercased version of `code` for case-insensitive uniqueness lookups.
code_lowercase VARCHAR(32) NOT NULL,
-- The user who owns this referral code.
owner_user_token VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft delete.
deleted_at DATETIME NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY unique_token (token),
UNIQUE KEY unique_code_lowercase (code_lowercase),
KEY index_owner_user_token (owner_user_token),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,33 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_referral_codes (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Unique token identifier for this referral code record.
token VARCHAR(32) NOT NULL,
-- The human-readable referral code (e.g. a username or custom code).
code VARCHAR(32) NOT NULL,
-- Lowercased version of `code` for case-insensitive uniqueness lookups.
code_lowercase VARCHAR(32) NOT NULL,
-- The user who owns this referral code.
owner_user_token VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Soft delete.
deleted_at DATETIME NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY unique_token (token),
UNIQUE KEY unique_code_lowercase (code_lowercase),
KEY index_owner_user_token (owner_user_token),
KEY index_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE user_referrals (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- The user who was invited/referred.
invited_user_token VARCHAR(32) NOT NULL,
-- The user who referred the invited user.
referrer_user_token VARCHAR(32) NOT NULL,
-- OPTIONAL. The referral code token used, if any.
maybe_referral_code_token VARCHAR(32) DEFAULT NULL,
-- OPTIONAL. The referral URL the invited user arrived from.
maybe_referral_url VARCHAR(255) DEFAULT NULL,
-- OPTIONAL. The landing URL the invited user first visited.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY unique_invited_user_token (invited_user_token),
KEY index_referrer_user_token (referrer_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -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;
@@ -23,6 +23,10 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
/// Optional: A referral code created by another user. If present, takes priority over
/// `maybe_referral_username` for resolving the referring user.
pub maybe_referral_code: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
@@ -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;
@@ -13,6 +13,7 @@ pub enum DatabaseInsertError {
/// An uncategorized non-database error occurred.
/// This will likely result in a 500 for the user.
#[deprecated(note = "This is not very semantic. Get rid of it. Stop using it.")]
AnyhowError(anyhow::Error),
}
@@ -36,6 +36,8 @@ pub mod tts;
pub mod twitch;
pub mod unsubscribe_reason;
pub mod user_impersonation_requests;
pub mod user_referral_codes;
pub mod user_referrals;
pub mod users;
pub mod vocoder;
pub mod voice_clone_requests;
@@ -0,0 +1,41 @@
use crate::errors::database_insert_error::DatabaseInsertError;
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, DatabaseInsertError>
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
.map_err(DatabaseInsertError::from)?;
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,55 @@
use sqlx::{Executor, MySql};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
pub struct ReferralCodeLookupResult {
pub token: UserReferralCodeToken,
pub owner_user_token: UserToken,
pub code: String,
pub code_lowercase: String,
}
struct RawRow {
token: UserReferralCodeToken,
owner_user_token: UserToken,
code: String,
code_lowercase: String,
}
/// Look up a non-deleted referral code by its lowercased code value.
pub async fn lookup_referral_code_by_code<'e, 'c: 'e, E>(
code_lowercase: &str,
mysql_executor: E,
) -> Result<Option<ReferralCodeLookupResult>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let result = sqlx::query_as!(
RawRow,
r#"
SELECT
token as `token: UserReferralCodeToken`,
owner_user_token as `owner_user_token: UserToken`,
code,
code_lowercase
FROM user_referral_codes
WHERE code_lowercase = ?
AND deleted_at IS NULL
LIMIT 1
"#,
code_lowercase,
)
.fetch_one(mysql_executor)
.await;
match result {
Ok(row) => Ok(Some(ReferralCodeLookupResult {
token: row.token,
owner_user_token: row.owner_user_token,
code: row.code,
code_lowercase: row.code_lowercase,
})),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(err) => Err(err),
}
}
@@ -0,0 +1,4 @@
pub mod create_referral_code;
pub mod list_referral_codes_for_user;
pub mod lookup_referral_code_by_code;
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)
}
@@ -0,0 +1,41 @@
use sqlx::{Executor, MySql};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
pub struct InsertUserReferralArgs<'a> {
pub invited_user_token: &'a UserToken,
pub referrer_user_token: &'a UserToken,
pub maybe_referral_code_token: Option<&'a UserReferralCodeToken>,
pub maybe_referral_url: Option<&'a str>,
pub maybe_landing_url: Option<&'a str>,
}
pub async fn insert_user_referral<'e, 'c: 'e, E>(
args: InsertUserReferralArgs<'_>,
mysql_executor: E,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query!(
r#"
INSERT INTO user_referrals (
invited_user_token,
referrer_user_token,
maybe_referral_code_token,
maybe_referral_url,
maybe_landing_url
)
VALUES (?, ?, ?, ?, ?)
"#,
args.invited_user_token.as_str(),
args.referrer_user_token.as_str(),
args.maybe_referral_code_token.map(|t| t.as_str()),
args.maybe_referral_url,
args.maybe_landing_url,
)
.execute(mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1 @@
pub mod insert_user_referral;
@@ -47,6 +47,7 @@ pub(crate) enum TokenPrefix {
User,
UserImpersonationRequest,
UserBookmark,
UserReferralCode,
UserSession,
UserSubscription,
VoiceConversionModel,
@@ -134,6 +135,7 @@ impl PrefixGenerator for TokenPrefix {
Self::User => "user_", // NB: Previously "U:"
Self::UserImpersonationRequest => "uimpr_",
Self::UserBookmark => "ub_",
Self::UserReferralCode => "urc_",
Self::UserSession => "session_",
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
Self::VoiceConversionModel => "vcm_",
@@ -31,6 +31,7 @@ pub mod twitch_oauth_tokens_grouping;
pub mod twitch_oauth_tokens_internal;
pub mod user_bookmarks;
pub mod user_impersonation_requests;
pub mod user_referral_codes;
pub mod user_sessions;
pub mod user_subscriptions;
pub mod users;
@@ -0,0 +1,15 @@
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// The primary key for user referral codes.
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct UserReferralCodeToken(pub String);
impl_mysql_token_from_row!(UserReferralCodeToken);
impl_string_token!(UserReferralCodeToken);
impl_crockford_generator!(UserReferralCodeToken, 18usize, TokenPrefix::UserReferralCode, CrockfordLower);
@@ -5,7 +5,11 @@ use crate::endpoints::checkout_with_user_signup::user_creation_case::user_creati
use crate::endpoints::checkout_with_user_signup::user_exists_case::user_exists_case;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use mysql_queries::queries::user_referral_codes::lookup_referral_code_by_code::lookup_referral_code_by_code;
use mysql_queries::queries::user_referrals::insert_user_referral::{insert_user_referral, InsertUserReferralArgs};
use mysql_queries::queries::users::user::get::get_user_token_by_username_with_executor::get_user_token_by_username_with_executor;
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::web::{Data, Json};
use actix_web::{web, HttpRequest, HttpResponse};
@@ -93,29 +97,12 @@ pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handl
let maybe_landing_url = request.maybe_landing_url.clone();
let maybe_referral_partner = request.maybe_referral_username.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s[..s.len().min(32)].to_string());
// Look up referring user by username (optional, fail-open).
let maybe_referral_user_token = match request.maybe_referral_username.as_deref() {
Some(raw) => {
let lookup_username = raw.trim().to_lowercase();
if lookup_username.is_empty() {
None
} else {
match get_user_token_by_username_with_executor(&lookup_username, &mut *mysql_connection).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
}
}
None => None,
};
// Resolve referral info from code (preferred) or username (fallback).
let referral_info = resolve_referral_info(
request.maybe_referral_code.as_deref(),
request.maybe_referral_username.as_deref(),
&mut mysql_connection,
).await;
let maybe_user_metadata = internal_user_lookup
.lookup_user_from_http_request_and_mysql_connection(&http_request, &mut mysql_connection)
@@ -128,16 +115,34 @@ pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handl
let creation_payload= match maybe_user_metadata {
None => {
info!("Creating new user, then creating checkout session...");
user_creation_case(
let payload = user_creation_case(
&http_request,
&price_id,
&mut mysql_connection,
&stripe_config,
maybe_referral_url,
maybe_landing_url,
maybe_referral_partner,
maybe_referral_user_token,
).await?
maybe_referral_url.clone(),
maybe_landing_url.clone(),
referral_info.maybe_referral_partner,
referral_info.maybe_referral_user_token.clone(),
).await?;
// Record the referral relationship if we resolved a referrer.
if let (Some(new_user), Some(referrer_user_token)) = (&payload.maybe_new_user_metadata, &referral_info.maybe_referral_user_token) {
if let Err(err) = insert_user_referral(
InsertUserReferralArgs {
invited_user_token: &new_user.user_token,
referrer_user_token,
maybe_referral_code_token: referral_info.maybe_referral_code_token.as_ref(),
maybe_referral_url: maybe_referral_url.as_deref(),
maybe_landing_url: maybe_landing_url.as_deref(),
},
&mut *mysql_connection,
).await {
warn!("Failed to insert user_referral record (continuing): {:?}", err);
}
}
payload
},
Some(user_metadata) => {
info!("Creating checkout session for user: {:?}", user_metadata.user_token_typed);
@@ -229,3 +234,75 @@ pub fn create_http_response_new_user(
.content_type(CONTENT_TYPE_APPLICATION_JSON)
.body(body))
}
struct ResolvedReferralInfo {
maybe_referral_partner: Option<String>,
maybe_referral_user_token: Option<UserToken>,
maybe_referral_code_token: Option<UserReferralCodeToken>,
}
/// Resolve referral info from code (preferred) or username (fallback).
async fn resolve_referral_info(
maybe_referral_code: Option<&str>,
maybe_referral_username: Option<&str>,
mysql_connection: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
) -> ResolvedReferralInfo {
// Try referral code first.
if let Some(raw_code) = maybe_referral_code {
let trimmed = raw_code.trim();
if !trimmed.is_empty() {
let code_lowercase = trimmed.to_lowercase();
match lookup_referral_code_by_code(&code_lowercase, &mut **mysql_connection).await {
Ok(Some(result)) => {
let partner = trimmed[..trimmed.len().min(32)].to_string();
return ResolvedReferralInfo {
maybe_referral_partner: Some(partner),
maybe_referral_user_token: Some(result.owner_user_token),
maybe_referral_code_token: Some(result.token),
};
}
Ok(None) => {
// Code not found — fall through to username lookup.
}
Err(err) => {
warn!("Referral code lookup failed (continuing): {:?}", err);
}
}
}
}
// Fall back to referral username.
if let Some(raw) = maybe_referral_username {
let trimmed = raw.trim();
let maybe_partner = if trimmed.is_empty() {
None
} else {
Some(trimmed[..trimmed.len().min(32)].to_string())
};
let lookup_username = trimmed.to_lowercase();
let maybe_user_token = if lookup_username.is_empty() {
None
} else {
match get_user_token_by_username_with_executor(&lookup_username, &mut **mysql_connection).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
};
return ResolvedReferralInfo {
maybe_referral_partner: maybe_partner,
maybe_referral_user_token: maybe_user_token,
maybe_referral_code_token: None,
};
}
ResolvedReferralInfo {
maybe_referral_partner: None,
maybe_referral_user_token: None,
maybe_referral_code_token: None,
}
}
@@ -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,116 @@
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::errors::database_insert_error::DatabaseInsertError;
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 result = create_referral_code(
CreateReferralCodeArgs {
owner_user_token: user_token,
code: &code,
code_lowercase: &code_lowercase,
},
&mut *mysql_connection,
).await;
let token = match result {
Ok(token) => token,
Err(err) => return match err {
DatabaseInsertError::DuplicateKeyError => {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(
"This referral code is already in use".to_string(),
));
},
DatabaseInsertError::SqlxError(e) => Err(AdvancedCommonWebError::from(e)),
DatabaseInsertError::AnyhowError(e) => Err(AdvancedCommonWebError::from_anyhow_error(e)),
},
};
Ok(Json(CreateReferralCodeResponse {
success: true,
token,
code,
code_lowercase,
}))
}
@@ -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;
@@ -7,9 +7,8 @@ use std::collections::HashMap;
use std::fmt;
use std::fmt::Formatter;
use crate::util::cleaners::sanitize_referral_username::sanitize_referral_username;
use crate::util::lookup::resolve_referral_info::resolve_referral_info;
use crate::http_server::validations::is_reserved_username::is_reserved_username;
use mysql_queries::queries::users::user::get::get_user_token_by_username_with_executor::get_user_token_by_username_with_executor;
use crate::http_server::validations::validate_passwords::validate_passwords;
use crate::http_server::validations::validate_username::validate_username;
use crate::util::enroll_in_studio::enroll_in_studio;
@@ -24,6 +23,7 @@ use http_server_common::response::serialize_as_json_error::serialize_as_json_err
use log::{info, warn};
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
use mysql_queries::queries::users::user::create::create_account_error::CreateAccountError;
use mysql_queries::queries::user_referrals::insert_user_referral::{insert_user_referral, InsertUserReferralArgs};
use mysql_queries::queries::users::user::create::create_account_from_email_and_password::{create_account_from_email_and_password, CreateAccountFromEmailPasswordArgs};
use mysql_queries::queries::users::user_sessions::create_user_session_with_executor::create_user_session_with_executor;
use password::bcrypt_hash_password::bcrypt_hash_password;
@@ -54,6 +54,10 @@ pub struct CreateAccountRequest {
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
/// Optional: A referral code created by another user. If present, takes priority over
/// `maybe_referral_username` for resolving the referring user.
pub maybe_referral_code: Option<String>,
}
#[derive(ToSchema, Serialize)]
@@ -224,24 +228,12 @@ pub async fn create_account_handler(
CreateAccountErrorResponse::server_error()
})?;
// Look up referring user by username (optional, fail-open).
let maybe_referral_user_token = match request.maybe_referral_username.as_deref() {
Some(raw) => {
let lookup_username = raw.trim().to_lowercase();
if lookup_username.is_empty() {
None
} else {
match get_user_token_by_username_with_executor(&lookup_username, &mut *mysql_connection).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
}
}
None => None,
};
// Resolve referral info from code (preferred) or username (fallback).
let referral_info = resolve_referral_info(
request.maybe_referral_code.as_deref(),
request.maybe_referral_username.as_deref(),
&mut mysql_connection,
).await;
let create_account_result = create_account_from_email_and_password(
CreateAccountFromEmailPasswordArgs {
@@ -252,10 +244,10 @@ pub async fn create_account_handler(
password_hash: &password_hash,
ip_address: &ip_address,
maybe_source,
maybe_referral_url,
maybe_landing_url,
maybe_referral_partner: request.maybe_referral_username.as_deref().and_then(sanitize_referral_username),
maybe_referral_user_token: maybe_referral_user_token.as_ref(),
maybe_referral_url: maybe_referral_url.clone(),
maybe_landing_url: maybe_landing_url.clone(),
maybe_referral_partner: referral_info.maybe_referral_partner,
maybe_referral_user_token: referral_info.maybe_referral_user_token.as_ref(),
maybe_user_token: None, // NB: This parameter is for internal testing only
},
&mut mysql_connection,
@@ -291,6 +283,22 @@ pub async fn create_account_handler(
info!("new user id: {}", new_user_data.user_id);
// Record the referral relationship if we resolved a referrer.
if let Some(referrer_user_token) = &referral_info.maybe_referral_user_token {
if let Err(err) = insert_user_referral(
InsertUserReferralArgs {
invited_user_token: &new_user_data.user_token,
referrer_user_token,
maybe_referral_code_token: referral_info.maybe_referral_code_token.as_ref(),
maybe_referral_url: maybe_referral_url.as_deref(),
maybe_landing_url: maybe_landing_url.as_deref(),
},
&mut *mysql_connection,
).await {
warn!("Failed to insert user_referral record (continuing): {:?}", err);
}
}
let session_token = create_user_session_with_executor(
&new_user_data.user_token,
&ip_address,
@@ -6,7 +6,8 @@
use std::fmt;
use std::fmt::Formatter;
use crate::util::cleaners::sanitize_referral_username::sanitize_referral_username;
use crate::util::lookup::resolve_referral_info::resolve_referral_info;
use mysql_queries::queries::user_referrals::insert_user_referral::{insert_user_referral, InsertUserReferralArgs};
use crate::http_server::endpoints::users::google_sso::check_claims::check_claims;
use crate::http_server::endpoints::users::google_sso::handle_existing_sso_account::{handle_existing_sso_account, ExistingAccountArgs};
use crate::http_server::endpoints::users::google_sso::handle_new_sso_account::{handle_new_sso_account, NewSsoArgs};
@@ -20,7 +21,6 @@ use http_server_common::request::get_request_ip::get_request_ip;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use log::{info, warn};
use mysql_queries::queries::google_sign_in_accounts::get_google_sign_in_account_by_subject::get_google_sign_in_account;
use mysql_queries::queries::users::user::get::get_user_token_by_username_with_executor::get_user_token_by_username_with_executor;
use mysql_queries::queries::users::user_sessions::create_user_session_with_transactor::create_user_session_with_transactor;
use mysql_queries::utils::transactor::Transactor;
use sqlx::{Acquire, MySqlPool};
@@ -93,6 +93,10 @@ pub struct GoogleCreateAccountRequest {
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
/// Optional: A referral code created by another user. If present, takes priority over
/// `maybe_referral_username` for resolving the referring user.
pub maybe_referral_code: Option<String>,
}
#[derive(ToSchema, Serialize)]
@@ -256,27 +260,12 @@ pub async fn google_sso_handler(
let maybe_landing_url = request.maybe_landing_url.clone();
let maybe_referral_partner = request.maybe_referral_username.as_deref()
.and_then(sanitize_referral_username);
// Look up referring user by username (optional, fail-open).
let maybe_referral_user_token = match request.maybe_referral_username.as_deref() {
Some(raw) => {
let lookup_username = raw.trim().to_lowercase();
if lookup_username.is_empty() {
None
} else {
match get_user_token_by_username_with_executor(&lookup_username, &mut *mysql_connection).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
}
}
None => None,
};
// Resolve referral info from code (preferred) or username (fallback).
let referral_info = resolve_referral_info(
request.maybe_referral_code.as_deref(),
request.maybe_referral_username.as_deref(),
&mut mysql_connection,
).await;
let result = handle_new_sso_account(NewSsoArgs {
http_request: &http_request,
@@ -284,12 +273,30 @@ pub async fn google_sso_handler(
claims_subject: &claims_subject,
claims_email_address: &claims_email_address,
mysql_connection: &mut mysql_connection,
maybe_referral_url,
maybe_landing_url,
maybe_referral_partner,
maybe_referral_user_token,
maybe_referral_url: maybe_referral_url.clone(),
maybe_landing_url: maybe_landing_url.clone(),
maybe_referral_partner: referral_info.maybe_referral_partner,
maybe_referral_user_token: referral_info.maybe_referral_user_token.clone(),
}).await?;
// Record the referral relationship if this is a genuinely new account.
if result.is_new_account {
if let Some(referrer_user_token) = &referral_info.maybe_referral_user_token {
if let Err(err) = insert_user_referral(
InsertUserReferralArgs {
invited_user_token: &result.user_token,
referrer_user_token,
maybe_referral_code_token: referral_info.maybe_referral_code_token.as_ref(),
maybe_referral_url: maybe_referral_url.as_deref(),
maybe_landing_url: maybe_landing_url.as_deref(),
},
&mut *mysql_connection,
).await {
warn!("Failed to insert user_referral record (continuing): {:?}", err);
}
}
}
user_token = result.user_token;
maybe_user_display_name = Some(result.user_display_name);
username_not_yet_customized = result.username_is_not_customized;
@@ -27,6 +27,7 @@ pub struct NewSsoAccountInfo {
pub user_token: UserToken,
pub user_display_name: String,
pub username_is_not_customized: bool,
pub is_new_account: bool,
}
pub async fn handle_new_sso_account(
@@ -58,5 +58,6 @@ pub async fn handle_new_sso_account_for_existing_user(
user_token: args.user_account.token,
user_display_name: args.user_account.display_name,
username_is_not_customized: args.user_account.username_is_not_customized,
is_new_account: false,
})
}
@@ -129,6 +129,7 @@ pub async fn handle_new_sso_account_for_new_user(
user_token,
user_display_name,
username_is_not_customized: true, // New account with random username
is_new_account: true,
})
}
@@ -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()))
)
}
@@ -1,2 +1,3 @@
pub mod empty_media_file_token_to_null;
pub mod sanitize_referral_code;
pub mod sanitize_referral_username;
@@ -0,0 +1,46 @@
const MAX_REFERRAL_PARTNER_LENGTH: usize = 32;
/// Sanitize a referral code for storage
/// in the `users.maybe_referral_partner` column.
/// Trims whitespace and truncates to 32 characters.
/// Returns None if empty after trimming.
pub fn sanitize_referral_code(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let truncated = &trimmed[..trimmed.len().min(MAX_REFERRAL_PARTNER_LENGTH)];
Some(truncated.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_returns_none() {
assert_eq!(sanitize_referral_code(""), None);
}
#[test]
fn whitespace_only_returns_none() {
assert_eq!(sanitize_referral_code(" "), None);
}
#[test]
fn trims_whitespace() {
assert_eq!(sanitize_referral_code(" PROMO-2026 "), Some("PROMO-2026".to_string()));
}
#[test]
fn truncates_long_values() {
let long = "a".repeat(50);
let result = sanitize_referral_code(&long).unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn passes_through_normal_values() {
assert_eq!(sanitize_referral_code("summer_sale"), Some("summer_sale".to_string()));
}
}
@@ -1,4 +1,5 @@
pub mod fetch_all_required_media_files;
pub mod lookup_image_urls_as_optional_list;
pub mod lookup_image_urls_as_map;
pub mod lookup_media_file_urls_as_map;
pub mod lookup_media_file_urls_as_map;
pub mod resolve_referral_info;
@@ -0,0 +1,94 @@
use log::warn;
use mysql_queries::queries::user_referral_codes::lookup_referral_code_by_code::lookup_referral_code_by_code;
use mysql_queries::queries::users::user::get::get_user_token_by_username_with_executor::get_user_token_by_username_with_executor;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
use crate::util::cleaners::sanitize_referral_code::sanitize_referral_code;
use crate::util::cleaners::sanitize_referral_username::sanitize_referral_username;
/// The resolved referral information to store on the new user record.
pub struct ResolvedReferralInfo {
/// The raw referral partner string (trimmed, max 32 chars) for `users.maybe_referral_partner`.
pub maybe_referral_partner: Option<String>,
/// The resolved user token of the referrer for `users.maybe_referral_user_token`.
pub maybe_referral_user_token: Option<UserToken>,
/// The referral code token used, if the referral was resolved via a code.
pub maybe_referral_code_token: Option<UserReferralCodeToken>,
}
/// Resolve referral info from either a referral code or a referral username.
///
/// If `maybe_referral_code` is present, try that first:
/// - Trim, lowercase, look up in `user_referral_codes` table
/// - If found: use the code's `owner_user_token` and the trimmed (pre-lowercase) code as partner
///
/// Otherwise, fall back to `maybe_referral_username`:
/// - Sanitize (trim + truncate to 32 chars) for `maybe_referral_partner`
/// - Trim + lowercase + look up user by username for `maybe_referral_user_token`
///
/// All lookups are fail-open: errors are logged and result in `None`.
pub async fn resolve_referral_info(
maybe_referral_code: Option<&str>,
maybe_referral_username: Option<&str>,
mysql_connection: &mut PoolConnection<MySql>,
) -> ResolvedReferralInfo {
// Try referral code first.
if let Some(raw_code) = maybe_referral_code {
let trimmed = raw_code.trim();
if !trimmed.is_empty() {
let code_lowercase = trimmed.to_lowercase();
match lookup_referral_code_by_code(&code_lowercase, &mut **mysql_connection).await {
Ok(Some(result)) => {
let partner = sanitize_referral_code(trimmed);
return ResolvedReferralInfo {
maybe_referral_partner: partner,
maybe_referral_user_token: Some(result.owner_user_token),
maybe_referral_code_token: Some(result.token),
};
}
Ok(None) => {
// Code not found — fall through to username lookup.
}
Err(err) => {
warn!("Referral code lookup failed (continuing): {:?}", err);
}
}
}
}
// Fall back to referral username.
if let Some(raw_username) = maybe_referral_username {
let maybe_referral_partner = sanitize_referral_username(raw_username);
let lookup_username = raw_username.trim().to_lowercase();
let maybe_referral_user_token = if lookup_username.is_empty() {
None
} else {
match get_user_token_by_username_with_executor(&lookup_username, &mut **mysql_connection).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
};
return ResolvedReferralInfo {
maybe_referral_partner,
maybe_referral_user_token,
maybe_referral_code_token: None,
};
}
ResolvedReferralInfo {
maybe_referral_partner: None,
maybe_referral_user_token: None,
maybe_referral_code_token: None,
}
}