diff --git a/.sqlx/query-56462cb07d49a001095ff118bfdf4fab4c30d1c7e6dca09a11e82f5956508e0b.json b/.sqlx/query-56462cb07d49a001095ff118bfdf4fab4c30d1c7e6dca09a11e82f5956508e0b.json new file mode 100644 index 0000000000..641d796a7e --- /dev/null +++ b/.sqlx/query-56462cb07d49a001095ff118bfdf4fab4c30d1c7e6dca09a11e82f5956508e0b.json @@ -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" +} diff --git a/.sqlx/query-5e2e68267e858b449de90dbf0d5b20d60b8cb82c4a4ddf56a49200ac92912817.json b/.sqlx/query-5e2e68267e858b449de90dbf0d5b20d60b8cb82c4a4ddf56a49200ac92912817.json new file mode 100644 index 0000000000..6960743768 --- /dev/null +++ b/.sqlx/query-5e2e68267e858b449de90dbf0d5b20d60b8cb82c4a4ddf56a49200ac92912817.json @@ -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" +} diff --git a/.sqlx/query-acd6e67603ca687686513b080bbfa642d3e2ffe8c97b0f9dd646f3ef2f8d92df.json b/.sqlx/query-acd6e67603ca687686513b080bbfa642d3e2ffe8c97b0f9dd646f3ef2f8d92df.json new file mode 100644 index 0000000000..09e25991bb --- /dev/null +++ b/.sqlx/query-acd6e67603ca687686513b080bbfa642d3e2ffe8c97b0f9dd646f3ef2f8d92df.json @@ -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" +} diff --git a/.sqlx/query-b5ce1dffc1d6e1762844f3a040a9a9cba2a608931824d99eebba67a7d718719e.json b/.sqlx/query-b5ce1dffc1d6e1762844f3a040a9a9cba2a608931824d99eebba67a7d718719e.json new file mode 100644 index 0000000000..fc3f415df6 --- /dev/null +++ b/.sqlx/query-b5ce1dffc1d6e1762844f3a040a9a9cba2a608931824d99eebba67a7d718719e.json @@ -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" +} diff --git a/.sqlx/query-b8254ed110147c66fbeef783283ff4b2565b60c9443572c67091ecd60c9d97d6.json b/.sqlx/query-b8254ed110147c66fbeef783283ff4b2565b60c9443572c67091ecd60c9d97d6.json new file mode 100644 index 0000000000..1e9d9c6852 --- /dev/null +++ b/.sqlx/query-b8254ed110147c66fbeef783283ff4b2565b60c9443572c67091ecd60c9d97d6.json @@ -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" +} diff --git a/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/down.sql b/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/down.sql new file mode 100644 index 0000000000..84dedb70b4 --- /dev/null +++ b/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS user_referrals; diff --git a/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/up.sql b/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/up.sql new file mode 100644 index 0000000000..b6bebd5d2f --- /dev/null +++ b/_database/sql/migrations/2026-05-13-225030-0000_user_referrals/up.sql @@ -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; diff --git a/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/down.sql b/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/down.sql new file mode 100644 index 0000000000..d80c3457fd --- /dev/null +++ b/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS user_referral_codes; diff --git a/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/up.sql b/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/up.sql new file mode 100644 index 0000000000..302edcc48e --- /dev/null +++ b/_database/sql/migrations/2026-05-13-225037-0000_user_referral_codes/up.sql @@ -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; diff --git a/_database/sql/migrations_squashed/user_tables/user_referral_codes.sql b/_database/sql/migrations_squashed/user_tables/user_referral_codes.sql new file mode 100644 index 0000000000..8ad1e01bcf --- /dev/null +++ b/_database/sql/migrations_squashed/user_tables/user_referral_codes.sql @@ -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; diff --git a/_database/sql/migrations_squashed/user_tables/user_referrals.sql b/_database/sql/migrations_squashed/user_tables/user_referrals.sql new file mode 100644 index 0000000000..b6bebd5d2f --- /dev/null +++ b/_database/sql/migrations_squashed/user_tables/user_referrals.sql @@ -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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index a161c4065f..3828a3aa94 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -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; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs index f37dd69155..8e4fbbdeae 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs @@ -23,6 +23,10 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest { /// Optional: A referral username or code from a referring user. pub maybe_referral_username: Option, + + /// 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, } #[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)] diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs new file mode 100644 index 0000000000..d41ed315b7 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/create_referral_code.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs new file mode 100644 index 0000000000..cebe7009cb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/delete_referral_code.rs @@ -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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs new file mode 100644 index 0000000000..fb1321ad68 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/list_referral_codes.rs @@ -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, +} + +#[derive(Serialize, ToSchema)] +pub struct ReferralCodeEntry { + pub token: UserReferralCodeToken, + pub code: String, + pub code_lowercase: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs new file mode 100644 index 0000000000..97274226d9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/user_referral_codes/mod.rs @@ -0,0 +1,3 @@ +pub mod create_referral_code; +pub mod delete_referral_code; +pub mod list_referral_codes; diff --git a/crates/schema/database/mysql_queries/src/errors/database_insert_error.rs b/crates/schema/database/mysql_queries/src/errors/database_insert_error.rs index 23e677975f..26bdf7c49c 100644 --- a/crates/schema/database/mysql_queries/src/errors/database_insert_error.rs +++ b/crates/schema/database/mysql_queries/src/errors/database_insert_error.rs @@ -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), } diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index b48a436b44..7be57c4bcb 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_queries/src/queries/user_referral_codes/create_referral_code.rs b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/create_referral_code.rs new file mode 100644 index 0000000000..30d1f95908 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/create_referral_code.rs @@ -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 +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) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_referral_codes/list_referral_codes_for_user.rs b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/list_referral_codes_for_user.rs new file mode 100644 index 0000000000..e4af63b789 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/list_referral_codes_for_user.rs @@ -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, + pub updated_at: DateTime, +} + +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, 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()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_referral_codes/lookup_referral_code_by_code.rs b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/lookup_referral_code_by_code.rs new file mode 100644 index 0000000000..2985f95196 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/lookup_referral_code_by_code.rs @@ -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, 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), + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_referral_codes/mod.rs b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/mod.rs new file mode 100644 index 0000000000..4d798a1fa9 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/mod.rs @@ -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; diff --git a/crates/schema/database/mysql_queries/src/queries/user_referral_codes/soft_delete_referral_code.rs b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/soft_delete_referral_code.rs new file mode 100644 index 0000000000..bd300d4e7f --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referral_codes/soft_delete_referral_code.rs @@ -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 +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) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_referrals/insert_user_referral.rs b/crates/schema/database/mysql_queries/src/queries/user_referrals/insert_user_referral.rs new file mode 100644 index 0000000000..2a64fffe41 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referrals/insert_user_referral.rs @@ -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(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_referrals/mod.rs b/crates/schema/database/mysql_queries/src/queries/user_referrals/mod.rs new file mode 100644 index 0000000000..6d0ef4ea86 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_referrals/mod.rs @@ -0,0 +1 @@ +pub mod insert_user_referral; diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index ae884e6feb..1019a0e65d 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -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_", diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index c80accb6d2..8a105ddc4d 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -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; diff --git a/crates/schema/public/tokens/src/tokens/user_referral_codes.rs b/crates/schema/public/tokens/src/tokens/user_referral_codes.rs new file mode 100644 index 0000000000..4e7815bc99 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_referral_codes.rs @@ -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); diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs index 34172e732c..58a2e80417 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs @@ -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, + maybe_referral_user_token: Option, + maybe_referral_code_token: Option, +} + +/// 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, +) -> 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, + } +} diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 41a4880537..e4e3c3f0fc 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index cfc5476cb7..f725950848 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs new file mode 100644 index 0000000000..e01aef6b75 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/create_referral_code_handler.rs @@ -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>, + request: web::Json, +) -> Result, 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, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/delete_referral_code_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/delete_referral_code_handler.rs new file mode 100644 index 0000000000..541d8a91b7 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/delete_referral_code_handler.rs @@ -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>, + path: web::Path, +) -> Result, 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, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/list_referral_codes_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/list_referral_codes_handler.rs new file mode 100644 index 0000000000..d5ebb1bdc2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/list_referral_codes_handler.rs @@ -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>, +) -> Result, 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, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/mod.rs new file mode 100644 index 0000000000..0a95d19a30 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/user_referral_codes/mod.rs @@ -0,0 +1,3 @@ +pub mod create_referral_code_handler; +pub mod delete_referral_code_handler; +pub mod list_referral_codes_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs index 75c3f9c526..c625dd9314 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs @@ -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, + + /// 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, } #[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, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs index 2c3f268feb..49e8114d50 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs @@ -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, + + /// 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, } #[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; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account.rs index 64f180763e..07d0fe32fc 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account.rs @@ -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( diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_existing_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_existing_user.rs index e80e67e0bf..93c078751e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_existing_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_existing_user.rs @@ -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, }) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs index b48fba607c..3cd5b99a6f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs @@ -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, }) } diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index f243cb42c2..cf3dc7c615 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -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 diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index 120b3fe977..dff9844eae 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -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; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_referral_code_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_referral_code_routes.rs new file mode 100644 index 0000000000..0507d56654 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_referral_code_routes.rs @@ -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(app: App) -> App +where + T: ServiceFactory, 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())) + ) +} diff --git a/crates/service/web/storyteller_web/src/util/cleaners/mod.rs b/crates/service/web/storyteller_web/src/util/cleaners/mod.rs index 4065a6179f..7ed199af19 100644 --- a/crates/service/web/storyteller_web/src/util/cleaners/mod.rs +++ b/crates/service/web/storyteller_web/src/util/cleaners/mod.rs @@ -1,2 +1,3 @@ pub mod empty_media_file_token_to_null; +pub mod sanitize_referral_code; pub mod sanitize_referral_username; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/util/cleaners/sanitize_referral_code.rs b/crates/service/web/storyteller_web/src/util/cleaners/sanitize_referral_code.rs new file mode 100644 index 0000000000..509697c16d --- /dev/null +++ b/crates/service/web/storyteller_web/src/util/cleaners/sanitize_referral_code.rs @@ -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 { + 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())); + } +} diff --git a/crates/service/web/storyteller_web/src/util/lookup/mod.rs b/crates/service/web/storyteller_web/src/util/lookup/mod.rs index 02e48cc7f4..d1e482e773 100644 --- a/crates/service/web/storyteller_web/src/util/lookup/mod.rs +++ b/crates/service/web/storyteller_web/src/util/lookup/mod.rs @@ -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; \ No newline at end of file +pub mod lookup_media_file_urls_as_map; +pub mod resolve_referral_info; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/util/lookup/resolve_referral_info.rs b/crates/service/web/storyteller_web/src/util/lookup/resolve_referral_info.rs new file mode 100644 index 0000000000..56bfae87ac --- /dev/null +++ b/crates/service/web/storyteller_web/src/util/lookup/resolve_referral_info.rs @@ -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, + + /// The resolved user token of the referrer for `users.maybe_referral_user_token`. + pub maybe_referral_user_token: Option, + + /// The referral code token used, if the referral was resolved via a code. + pub maybe_referral_code_token: Option, +} + +/// 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, +) -> 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, + } +}