referral program backend (#1509)

This commit is contained in:
Brandon Thomas
2026-05-13 13:08:01 -04:00
committed by GitHub
parent 4d4bfce3aa
commit 7355394494
23 changed files with 330 additions and 11 deletions
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::users::UserToken`\nFROM users\n WHERE username = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "566a2d56735180e0f17484789d0debd03c68bda20354882e111476bab6132723"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?,\n maybe_landing_url = ?\n ",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?,\n maybe_landing_url = ?,\n\n maybe_referral_partner = ?,\n maybe_referral_user_token = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 24
"Right": 26
},
"nullable": []
},
"hash": "3aacc0d270a9073ee5a38595cc32ab7793e0e8b9ce3ad81f554e7198fd0fd509"
"hash": "867b22640ae6c49c26973a32d36439933dceef881313a7f3385d61ebe5faeb34"
}
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN maybe_referral_partner;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN maybe_referral_partner VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN maybe_referral_user_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN maybe_referral_user_token VARCHAR(32) DEFAULT NULL;
@@ -202,9 +202,20 @@ CREATE TABLE users (
-- ========== TRACKING ==========
-- Where the user signed up from (NOT NECESSARILY AN ENUM!)
-- For now this will be "artcraft", "fakeyou", or "storyteller",
-- though we may extend or overload this to handle other cases or metadata.
-- Where the user signed up from
--
-- ArtCraft Values:
-- - "artcraft" - (deprecated)
-- - "artcraft_app" - Signup with the ArtCraft app
-- - "artcraft_get_web" - Signup with ArtCraft website signup flow
-- - "artcraft_get_s" - Signup with ArtCraft website Stripe Checkout Flow
-- - "artcraft_ai_s" - Signup with Artcraft.ai website Stripe checkout flow
-- - "artcraft_get_web" - Signup with ArtCraft.ai website signup flow
--
-- Other Sources:
-- - "fakeyou" - FakeYou signup
-- - "storyteller" - Storyteller.ai signup
--
maybe_source VARCHAR(255) DEFAULT NULL,
-- How users created their account
@@ -229,6 +240,13 @@ CREATE TABLE users (
-- This helps us know what marketing is effective.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
-- If a user referred this user, this is either the raw username or raw "referral code"
-- that this user used to sign up. This is not verified and can be used for debugging.
maybe_referral_partner VARCHAR(32) DEFAULT NULL,
-- If a user referred this user, we record the user token of the referrer.
maybe_referral_user_token VARCHAR(32) DEFAULT NULL,
-- ========== MODERATION DETAILS ==========
-- Different than deleted.
@@ -20,6 +20,9 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
@@ -64,6 +64,8 @@ async fn seed_user(
maybe_source: None,
maybe_referral_url: None,
maybe_landing_url: None,
maybe_referral_partner: None,
maybe_referral_user_token: None,
maybe_user_token: maybe_user_token.as_ref(),
}).await.map_err(|err| anyhow!("err: {:?}", err))?;
@@ -29,6 +29,12 @@ pub struct CreateAccountFromEmailPasswordArgs<'a> {
/// We can grab this from `window.location.href`.
pub maybe_landing_url: Option<String>,
/// If a user referred this user, this is the raw username or referral code.
pub maybe_referral_partner: Option<String>,
/// If a user referred this user, this is the user token of the referrer.
pub maybe_referral_user_token: Option<&'a UserToken>,
/// In production code, send this as `None`.
/// Only provide an external user token for db integration tests and db seeding tools.
/// This allows for knowing the user token a priori.
@@ -73,6 +79,8 @@ pub async fn create_account_from_email_and_password(
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
maybe_referral_partner: args.maybe_referral_partner,
maybe_referral_user_token: args.maybe_referral_user_token,
// NB: This is just for testing.
maybe_user_token: args.maybe_user_token,
@@ -27,6 +27,15 @@ pub struct CreateAccountFromGoogleSsoArgs<'a> {
/// Comma separated string of feature flags.
pub maybe_feature_flags: Option<&'a str>,
pub maybe_referral_url: Option<String>,
pub maybe_landing_url: Option<String>,
/// If a user referred this user, this is the raw username or referral code.
pub maybe_referral_partner: Option<String>,
/// If a user referred this user, this is the user token of the referrer.
pub maybe_referral_user_token: Option<&'a UserToken>,
}
pub async fn create_account_from_google_sso(
@@ -57,9 +66,10 @@ pub async fn create_account_from_google_sso(
password_hash: SSO_PASSWORD,
is_without_password: true,
// TODO: Necessary? We're not doing SSO for now.
maybe_referral_url: None,
maybe_landing_url: None,
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
maybe_referral_partner: args.maybe_referral_partner,
maybe_referral_user_token: args.maybe_referral_user_token,
// NB: This is just for testing.
maybe_user_token: None,
@@ -31,6 +31,12 @@ pub struct CreateAccountFromStripeCheckoutArgs<'a> {
/// Comma separated string of feature flags.
pub maybe_feature_flags: Option<&'a str>,
/// If a user referred this user, this is the raw username or referral code.
pub maybe_referral_partner: Option<String>,
/// If a user referred this user, this is the user token of the referrer.
pub maybe_referral_user_token: Option<&'a UserToken>,
}
pub async fn create_account_from_stripe_checkout(
@@ -72,6 +78,8 @@ pub async fn create_account_from_stripe_checkout(
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
maybe_referral_partner: args.maybe_referral_partner,
maybe_referral_user_token: args.maybe_referral_user_token,
// NB: This is just for testing.
maybe_user_token: None,
@@ -69,6 +69,13 @@ pub struct GenericCreateAccountArgs<'a> {
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
/// If a user referred this user, this is the raw username or referral code.
/// Trimmed and truncated to 32 characters.
pub maybe_referral_partner: Option<String>,
/// If a user referred this user, this is the user token of the referrer.
pub maybe_referral_user_token: Option<&'a UserToken>,
/// In production code, send this as `None`.
/// Only provide an external user token for db integration tests and db seeding tools.
/// This allows for knowing the user token a priori.
@@ -139,7 +146,10 @@ SET
maybe_signup_method = ?,
maybe_referral_url = ?,
maybe_landing_url = ?
maybe_landing_url = ?,
maybe_referral_partner = ?,
maybe_referral_user_token = ?
"#,
user_token.as_str(),
@@ -177,6 +187,9 @@ SET
maybe_referral_url,
maybe_landing_url,
args.maybe_referral_partner,
args.maybe_referral_user_token.map(|t| t.as_str()),
);
@@ -0,0 +1,36 @@
use sqlx::{Executor, MySql};
use tokens::tokens::users::UserToken;
pub async fn get_user_token_by_username_with_executor<'e, 'c: 'e, E>(
username: &str,
mysql_executor: E,
) -> Result<Option<UserToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let username = username.to_lowercase();
let result = sqlx::query_as!(
UsernameRecord,
r#"
SELECT
token as `token: tokens::tokens::users::UserToken`
FROM users
WHERE username = ?
LIMIT 1
"#,
username
)
.fetch_one(mysql_executor)
.await;
match result {
Ok(record) => Ok(Some(record.token)),
Err(sqlx::Error::RowNotFound) => Ok(None),
Err(err) => Err(err),
}
}
struct UsernameRecord {
token: UserToken,
}
@@ -1,4 +1,5 @@
pub mod get_user_token_by_username;
pub mod get_user_token_by_username_with_executor;
pub mod get_username_by_user_token;
pub mod lookup_user_for_login_by_email;
pub mod lookup_user_for_login_by_email_with_transactor;
@@ -5,6 +5,7 @@ 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::users::user::get::get_user_token_by_username_with_executor::get_user_token_by_username_with_executor;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::web::{Data, Json};
use actix_web::{web, HttpRequest, HttpResponse};
@@ -92,6 +93,30 @@ 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,
};
let maybe_user_metadata = internal_user_lookup
.lookup_user_from_http_request_and_mysql_connection(&http_request, &mut mysql_connection)
.await
@@ -110,6 +135,8 @@ pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handl
&stripe_config,
maybe_referral_url,
maybe_landing_url,
maybe_referral_partner,
maybe_referral_user_token,
).await?
},
Some(user_metadata) => {
@@ -21,6 +21,7 @@ use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSes
use stripe_shared::{CheckoutSession, CheckoutSessionMode, PriceId};
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
use users::email::generate_random_synthetic_email::generate_random_synthetic_email;
use tokens::tokens::users::UserToken;
use users::username::generate_random_username::generate_random_username;
pub (super) async fn user_creation_case(
@@ -30,6 +31,8 @@ pub (super) async fn user_creation_case(
stripe_config: &ArtcraftStripeConfigWithClient,
maybe_referral_url: Option<String>,
maybe_landing_url: Option<String>,
maybe_referral_partner: Option<String>,
maybe_referral_user_token: Option<UserToken>,
) -> Result<CreationPayload, CommonWebError> {
let mut transaction = mysql_connection.begin()
@@ -86,6 +89,8 @@ pub (super) async fn user_creation_case(
maybe_source,
maybe_referral_url: maybe_referral_url.clone(),
maybe_landing_url: maybe_landing_url.clone(),
maybe_referral_partner: maybe_referral_partner.clone(),
maybe_referral_user_token: maybe_referral_user_token.as_ref(),
},
Transactor::for_transaction(&mut transaction),
).await;
@@ -7,7 +7,9 @@ use std::collections::HashMap;
use std::fmt;
use std::fmt::Formatter;
use crate::util::cleaners::sanitize_referral_username::sanitize_referral_username;
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;
@@ -50,6 +52,9 @@ pub struct CreateAccountRequest {
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
}
#[derive(ToSchema, Serialize)]
@@ -213,6 +218,25 @@ pub async fn create_account_handler(
let maybe_landing_url = request.maybe_landing_url.clone();
// 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, &**mysql_pool).await {
Ok(token) => token,
Err(err) => {
warn!("Referral user lookup failed (continuing): {:?}", err);
None
}
}
}
}
None => None,
};
let create_account_result = create_account_from_email_and_password(
&mysql_pool,
CreateAccountFromEmailPasswordArgs {
@@ -225,6 +249,8 @@ pub async fn create_account_handler(
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_user_token: None, // NB: This parameter is for internal testing only
}
).await;
@@ -6,6 +6,7 @@
use std::fmt;
use std::fmt::Formatter;
use crate::util::cleaners::sanitize_referral_username::sanitize_referral_username;
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};
@@ -19,6 +20,7 @@ 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};
@@ -79,6 +81,18 @@ use utoipa::ToSchema;
#[derive(ToSchema, Deserialize)]
pub struct GoogleCreateAccountRequest {
pub google_credential: String,
/// Optional: The referral URL the user arrived from when first hitting the site.
/// The browser can send `document.referrer` to the backend.
/// If not provided, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// Optional: The URL where the user landed when they first arrived.
/// The browser can send `window.location.href`.
pub maybe_landing_url: Option<String>,
/// Optional: A referral username or code from a referring user.
pub maybe_referral_username: Option<String>,
}
#[derive(ToSchema, Serialize)]
@@ -233,12 +247,47 @@ pub async fn google_sso_handler(
username_not_yet_customized = false;
},
None => {
let maybe_referral_url = request.maybe_referral_url.clone()
.or_else(|| {
http_request.headers().get("referer")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
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,
};
let result = handle_new_sso_account(NewSsoArgs {
http_request: &http_request,
claims,
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,
}).await?;
user_token = result.user_token;
@@ -17,6 +17,10 @@ pub struct NewSsoArgs<'a> {
pub claims_subject: &'a str,
pub claims_email_address: &'a str,
pub mysql_connection: &'a mut PoolConnection<MySql>,
pub maybe_referral_url: Option<String>,
pub maybe_landing_url: Option<String>,
pub maybe_referral_partner: Option<String>,
pub maybe_referral_user_token: Option<UserToken>,
}
pub struct NewSsoAccountInfo {
@@ -63,6 +67,10 @@ pub async fn handle_new_sso_account(
claims_email_address: &args.claims_email_address,
user_email_address: &user_email_address,
mysql_connection: args.mysql_connection,
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
maybe_referral_partner: args.maybe_referral_partner,
maybe_referral_user_token: args.maybe_referral_user_token,
}).await
},
}
@@ -14,6 +14,7 @@ use mysql_queries::utils::transactor::Transactor;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
use tokens::tokens::users::UserToken;
use users::username::generate_random_username::generate_random_username;
pub struct CreateArgs<'a> {
@@ -23,6 +24,10 @@ pub struct CreateArgs<'a> {
pub claims_email_address: &'a str,
pub user_email_address: &'a str,
pub mysql_connection: &'a mut PoolConnection<MySql>,
pub maybe_referral_partner: Option<String>,
pub maybe_referral_url: Option<String>,
pub maybe_landing_url: Option<String>,
pub maybe_referral_user_token: Option<UserToken>,
}
pub async fn handle_new_sso_account_for_new_user(
args: CreateArgs<'_>
@@ -64,6 +69,10 @@ pub async fn handle_new_sso_account_for_new_user(
maybe_feature_flags: user_feature_flags.as_deref(),
ip_address: &ip_address,
maybe_source,
maybe_referral_partner: args.maybe_referral_partner.clone(),
maybe_referral_url: args.maybe_referral_url.clone(),
maybe_landing_url: args.maybe_landing_url.clone(),
maybe_referral_user_token: args.maybe_referral_user_token.as_ref(),
},
Transactor::for_transaction(&mut transaction),
).await;
@@ -1 +1,2 @@
pub mod empty_media_file_token_to_null;
pub mod empty_media_file_token_to_null;
pub mod sanitize_referral_username;
@@ -0,0 +1,46 @@
const MAX_REFERRAL_PARTNER_LENGTH: usize = 32;
/// Sanitize a referral username/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_username(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_username(""), None);
}
#[test]
fn whitespace_only_returns_none() {
assert_eq!(sanitize_referral_username(" "), None);
}
#[test]
fn trims_whitespace() {
assert_eq!(sanitize_referral_username(" alice "), Some("alice".to_string()));
}
#[test]
fn truncates_long_values() {
let long = "a".repeat(50);
let result = sanitize_referral_username(&long).unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn passes_through_normal_values() {
assert_eq!(sanitize_referral_username("bob123"), Some("bob123".to_string()));
}
}