max / makenotwork
12 files changed,
+297 insertions,
-25 deletions
| @@ -183,6 +183,12 @@ impl_str_enum!(FollowTargetType { | |||
| 183 | 183 | pub enum SubscriptionStatus { | |
| 184 | 184 | #[serde(rename = "active")] | |
| 185 | 185 | Active, | |
| 186 | + | #[serde(rename = "trialing")] | |
| 187 | + | Trialing, | |
| 188 | + | #[serde(rename = "incomplete")] | |
| 189 | + | Incomplete, | |
| 190 | + | #[serde(rename = "incomplete_expired")] | |
| 191 | + | IncompleteExpired, | |
| 186 | 192 | #[serde(rename = "past_due")] | |
| 187 | 193 | PastDue, | |
| 188 | 194 | #[serde(rename = "canceled")] | |
| @@ -193,6 +199,9 @@ pub enum SubscriptionStatus { | |||
| 193 | 199 | ||
| 194 | 200 | impl_str_enum!(SubscriptionStatus { | |
| 195 | 201 | Active => "active", | |
| 202 | + | Trialing => "trialing", | |
| 203 | + | Incomplete => "incomplete", | |
| 204 | + | IncompleteExpired => "incomplete_expired", | |
| 196 | 205 | PastDue => "past_due", | |
| 197 | 206 | Canceled => "canceled", | |
| 198 | 207 | Unpaid => "unpaid", | |
| @@ -928,6 +937,12 @@ mod tests { | |||
| 928 | 937 | fn subscription_status_round_trip() { | |
| 929 | 938 | assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due"); | |
| 930 | 939 | assert_eq!("canceled".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Canceled); | |
| 940 | + | assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing"); | |
| 941 | + | assert_eq!("trialing".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Trialing); | |
| 942 | + | assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete"); | |
| 943 | + | assert_eq!("incomplete".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Incomplete); | |
| 944 | + | assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired"); | |
| 945 | + | assert_eq!("incomplete_expired".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::IncompleteExpired); | |
| 931 | 946 | } | |
| 932 | 947 | ||
| 933 | 948 | #[test] | |
| @@ -1251,6 +1266,12 @@ mod tests { | |||
| 1251 | 1266 | assert_eq!(json, "\"past_due\""); | |
| 1252 | 1267 | let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); | |
| 1253 | 1268 | assert_eq!(back, s); | |
| 1269 | + | ||
| 1270 | + | let t = SubscriptionStatus::Trialing; | |
| 1271 | + | let json = serde_json::to_string(&t).unwrap(); | |
| 1272 | + | assert_eq!(json, "\"trialing\""); | |
| 1273 | + | let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); | |
| 1274 | + | assert_eq!(back, t); | |
| 1254 | 1275 | } | |
| 1255 | 1276 | ||
| 1256 | 1277 | // ── ItemType::wizard_group ── |
| @@ -234,7 +234,7 @@ pub async fn update_item( | |||
| 234 | 234 | description = COALESCE($4, description), | |
| 235 | 235 | price_cents = COALESCE($5, price_cents), | |
| 236 | 236 | item_type = COALESCE($6, item_type), | |
| 237 | - | is_public = COALESCE($7, is_public), | |
| 237 | + | is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END, | |
| 238 | 238 | pwyw_enabled = COALESCE($8, pwyw_enabled), | |
| 239 | 239 | pwyw_min_cents = COALESCE($9, pwyw_min_cents), | |
| 240 | 240 | publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END, | |
| @@ -277,7 +277,7 @@ pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> { | |||
| 277 | 277 | r#" | |
| 278 | 278 | UPDATE items | |
| 279 | 279 | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 280 | - | WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false | |
| 280 | + | WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false | |
| 281 | 281 | RETURNING * | |
| 282 | 282 | "#, | |
| 283 | 283 | ) | |
| @@ -669,6 +669,7 @@ pub async fn bulk_publish( | |||
| 669 | 669 | SET is_public = true, publish_at = NULL, updated_at = NOW() | |
| 670 | 670 | WHERE id = ANY($1) AND project_id = $2 | |
| 671 | 671 | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 672 | + | AND removed_by_admin = false | |
| 672 | 673 | "#, | |
| 673 | 674 | ) | |
| 674 | 675 | .bind(item_ids) | |
| @@ -1012,3 +1013,54 @@ pub async fn hide_all_items_for_user(pool: &PgPool, user_id: UserId) -> Result<u | |||
| 1012 | 1013 | ||
| 1013 | 1014 | Ok(result.rows_affected()) | |
| 1014 | 1015 | } | |
| 1016 | + | ||
| 1017 | + | /// Admin: remove an item (hide from public, record reason). The item stays in the DB | |
| 1018 | + | /// and the creator can see it in their dashboard with the removal reason. | |
| 1019 | + | #[tracing::instrument(skip_all)] | |
| 1020 | + | pub async fn admin_remove_item( | |
| 1021 | + | pool: &PgPool, | |
| 1022 | + | item_id: ItemId, | |
| 1023 | + | reason: &str, | |
| 1024 | + | ) -> Result<DbItem> { | |
| 1025 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 1026 | + | r#" | |
| 1027 | + | UPDATE items | |
| 1028 | + | SET removed_by_admin = true, | |
| 1029 | + | removal_reason = $2, | |
| 1030 | + | removed_at = NOW(), | |
| 1031 | + | is_public = false | |
| 1032 | + | WHERE id = $1 | |
| 1033 | + | RETURNING * | |
| 1034 | + | "#, | |
| 1035 | + | ) | |
| 1036 | + | .bind(item_id) | |
| 1037 | + | .bind(reason) | |
| 1038 | + | .fetch_one(pool) | |
| 1039 | + | .await?; | |
| 1040 | + | ||
| 1041 | + | Ok(item) | |
| 1042 | + | } | |
| 1043 | + | ||
| 1044 | + | /// Admin: restore a previously removed item. Clears the removal fields | |
| 1045 | + | /// but does NOT re-publish (creator must publish manually). | |
| 1046 | + | #[tracing::instrument(skip_all)] | |
| 1047 | + | pub async fn admin_restore_item( | |
| 1048 | + | pool: &PgPool, | |
| 1049 | + | item_id: ItemId, | |
| 1050 | + | ) -> Result<DbItem> { | |
| 1051 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 1052 | + | r#" | |
| 1053 | + | UPDATE items | |
| 1054 | + | SET removed_by_admin = false, | |
| 1055 | + | removal_reason = NULL, | |
| 1056 | + | removed_at = NULL | |
| 1057 | + | WHERE id = $1 | |
| 1058 | + | RETURNING * | |
| 1059 | + | "#, | |
| 1060 | + | ) | |
| 1061 | + | .bind(item_id) | |
| 1062 | + | .fetch_one(pool) | |
| 1063 | + | .await?; | |
| 1064 | + | ||
| 1065 | + | Ok(item) | |
| 1066 | + | } |
| @@ -104,6 +104,12 @@ pub struct DbItem { | |||
| 104 | 104 | pub video_width: Option<i32>, | |
| 105 | 105 | /// Video height in pixels. | |
| 106 | 106 | pub video_height: Option<i32>, | |
| 107 | + | /// Whether this item was removed by an admin (enforcement ladder step 2). | |
| 108 | + | pub removed_by_admin: bool, | |
| 109 | + | /// Admin-provided reason for removal (shown to the creator). | |
| 110 | + | pub removal_reason: Option<String>, | |
| 111 | + | /// When the admin removed this item. | |
| 112 | + | pub removed_at: Option<DateTime<Utc>>, | |
| 107 | 113 | } | |
| 108 | 114 | ||
| 109 | 115 | /// Content-type-specific data extracted from a `DbItem`. | |
| @@ -312,6 +318,9 @@ mod tests { | |||
| 312 | 318 | video_duration_seconds: None, | |
| 313 | 319 | video_width: None, | |
| 314 | 320 | video_height: None, | |
| 321 | + | removed_by_admin: false, | |
| 322 | + | removal_reason: None, | |
| 323 | + | removed_at: None, | |
| 315 | 324 | } | |
| 316 | 325 | } | |
| 317 | 326 |
| @@ -217,6 +217,7 @@ mod tests { | |||
| 217 | 217 | created_at: Utc::now(), | |
| 218 | 218 | updated_at: Utc::now(), | |
| 219 | 219 | item_id: None, | |
| 220 | + | paused_at: None, | |
| 220 | 221 | } | |
| 221 | 222 | } | |
| 222 | 223 |
| @@ -60,6 +60,8 @@ pub struct DbTransaction { | |||
| 60 | 60 | pub project_id: Option<ProjectId>, | |
| 61 | 61 | /// Parent bundle transaction that granted this child item. Nullable. | |
| 62 | 62 | pub parent_transaction_id: Option<TransactionId>, | |
| 63 | + | /// Promo code used for this purchase (for releasing reservations on stale cleanup). | |
| 64 | + | pub promo_code_id: Option<PromoCodeId>, | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | 67 | impl DbTransaction { | |
| @@ -151,6 +153,7 @@ mod tests { | |||
| 151 | 153 | share_contact: false, | |
| 152 | 154 | project_id: None, | |
| 153 | 155 | parent_transaction_id: None, | |
| 156 | + | promo_code_id: None, | |
| 154 | 157 | } | |
| 155 | 158 | } | |
| 156 | 159 |
| @@ -158,6 +158,13 @@ pub struct DbUser { | |||
| 158 | 158 | pub notify_tip: bool, | |
| 159 | 159 | /// When the user self-deactivated their account (None = active). | |
| 160 | 160 | pub deactivated_at: Option<DateTime<Utc>>, | |
| 161 | + | /// Whether this is an ephemeral sandbox account. | |
| 162 | + | pub is_sandbox: bool, | |
| 163 | + | /// When the sandbox session expires (cleanup deletes the user after this). | |
| 164 | + | pub sandbox_expires_at: Option<DateTime<Utc>>, | |
| 165 | + | /// When the admin permanently terminated this account (None = not terminated). | |
| 166 | + | /// User has 30 days from this timestamp to export data before deletion. | |
| 167 | + | pub terminated_at: Option<DateTime<Utc>>, | |
| 161 | 168 | } | |
| 162 | 169 | ||
| 163 | 170 | impl DbUser { | |
| @@ -267,6 +274,10 @@ mod tests { | |||
| 267 | 274 | grandfathered_until: None, | |
| 268 | 275 | tips_enabled: false, | |
| 269 | 276 | notify_tip: true, | |
| 277 | + | deactivated_at: None, | |
| 278 | + | is_sandbox: false, | |
| 279 | + | sandbox_expires_at: None, | |
| 280 | + | terminated_at: None, | |
| 270 | 281 | } | |
| 271 | 282 | } | |
| 272 | 283 |
| @@ -86,6 +86,19 @@ pub async fn get_public_project_by_user_and_slug( | |||
| 86 | 86 | Ok(project) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | /// Return just the IDs of all projects owned by a user (lightweight, for cleanup). | |
| 90 | + | #[tracing::instrument(skip_all)] | |
| 91 | + | pub async fn get_project_ids_for_user(pool: &PgPool, user_id: UserId) -> Result<Vec<ProjectId>> { | |
| 92 | + | let ids = sqlx::query_scalar::<_, ProjectId>( | |
| 93 | + | "SELECT id FROM projects WHERE user_id = $1", | |
| 94 | + | ) | |
| 95 | + | .bind(user_id) | |
| 96 | + | .fetch_all(pool) | |
| 97 | + | .await?; | |
| 98 | + | ||
| 99 | + | Ok(ids) | |
| 100 | + | } | |
| 101 | + | ||
| 89 | 102 | /// List all projects owned by a user, newest first. | |
| 90 | 103 | /// | |
| 91 | 104 | /// Capped at 500 as a safety limit. |
| @@ -171,6 +171,20 @@ pub async fn try_increment_use_count<'e>( | |||
| 171 | 171 | Ok(result.rows_affected() > 0) | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | /// Release a reserved use_count slot (decrement, clamped to 0). | |
| 175 | + | /// Called when a stale pending transaction with a reserved promo code is cleaned up. | |
| 176 | + | #[tracing::instrument(skip_all)] | |
| 177 | + | pub async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> { | |
| 178 | + | sqlx::query( | |
| 179 | + | "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1", | |
| 180 | + | ) | |
| 181 | + | .bind(id) | |
| 182 | + | .execute(pool) | |
| 183 | + | .await?; | |
| 184 | + | ||
| 185 | + | Ok(()) | |
| 186 | + | } | |
| 187 | + | ||
| 174 | 188 | /// Delete a promo code permanently. | |
| 175 | 189 | #[tracing::instrument(skip_all)] | |
| 176 | 190 | pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> { |
| @@ -370,7 +370,7 @@ pub async fn has_active_subscription_to_project( | |||
| 370 | 370 | project_id: ProjectId, | |
| 371 | 371 | ) -> Result<bool> { | |
| 372 | 372 | let count: i64 = sqlx::query_scalar( | |
| 373 | - | "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active'", | |
| 373 | + | "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL", | |
| 374 | 374 | ) | |
| 375 | 375 | .bind(user_id) | |
| 376 | 376 | .bind(project_id) | |
| @@ -430,7 +430,7 @@ pub async fn has_active_subscription_to_item( | |||
| 430 | 430 | item_id: super::ItemId, | |
| 431 | 431 | ) -> Result<bool> { | |
| 432 | 432 | let count: i64 = sqlx::query_scalar( | |
| 433 | - | "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active'", | |
| 433 | + | "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active' AND paused_at IS NULL", | |
| 434 | 434 | ) | |
| 435 | 435 | .bind(user_id) | |
| 436 | 436 | .bind(item_id) |
| @@ -20,6 +20,8 @@ pub struct CreateTransactionParams<'a> { | |||
| 20 | 20 | pub share_contact: bool, | |
| 21 | 21 | /// Set for project-level purchases; `None` for item purchases. | |
| 22 | 22 | pub project_id: Option<ProjectId>, | |
| 23 | + | /// Promo code used for this checkout (for releasing reservations on cleanup). | |
| 24 | + | pub promo_code_id: Option<PromoCodeId>, | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | /// Common parameters for claiming a free item (direct, discount code, or download code). | |
| @@ -42,8 +44,8 @@ pub async fn create_transaction( | |||
| 42 | 44 | ) -> Result<DbTransaction> { | |
| 43 | 45 | let tx = sqlx::query_as::<_, DbTransaction>( | |
| 44 | 46 | r#" | |
| 45 | - | INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id) | |
| 46 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) | |
| 47 | + | INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id, promo_code_id) | |
| 48 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) | |
| 47 | 49 | RETURNING * | |
| 48 | 50 | "#, | |
| 49 | 51 | ) | |
| @@ -57,6 +59,7 @@ pub async fn create_transaction( | |||
| 57 | 59 | .bind(params.seller_username) | |
| 58 | 60 | .bind(params.share_contact) | |
| 59 | 61 | .bind(params.project_id) | |
| 62 | + | .bind(params.promo_code_id) | |
| 60 | 63 | .fetch_one(pool) | |
| 61 | 64 | .await?; | |
| 62 | 65 | ||
| @@ -194,12 +197,14 @@ pub async fn claim_free_item<'e>( | |||
| 194 | 197 | Ok(result.rows_affected() > 0) | |
| 195 | 198 | } | |
| 196 | 199 | ||
| 197 | - | /// Atomically increment a promo code's use count and claim a free item. | |
| 200 | + | /// Atomically claim a free item and increment the promo code's use count. | |
| 198 | 201 | /// | |
| 199 | - | /// Wraps both operations in a single transaction so the use_count doesn't | |
| 200 | - | /// drift if the claim fails. Returns `(code_accepted, item_claimed)`: | |
| 202 | + | /// Claims the item FIRST (INSERT transaction), then increments use_count. | |
| 203 | + | /// If the user already owns the item (rows_affected == 0), rolls back without | |
| 204 | + | /// consuming the code. If the code limit is reached, rolls back the claim too. | |
| 205 | + | /// Returns `(code_accepted, item_claimed)`: | |
| 201 | 206 | /// - `code_accepted = false` → promo code hit its usage limit (nothing changed) | |
| 202 | - | /// - `item_claimed = false` → user already owns the item (code was still consumed) | |
| 207 | + | /// - `item_claimed = false` → user already owns the item (code was NOT consumed) | |
| 203 | 208 | #[tracing::instrument(skip_all)] | |
| 204 | 209 | pub async fn claim_free_with_promo_code( | |
| 205 | 210 | pool: &PgPool, | |
| @@ -208,18 +213,7 @@ pub async fn claim_free_with_promo_code( | |||
| 208 | 213 | ) -> Result<(bool, bool)> { | |
| 209 | 214 | let mut tx = pool.begin().await?; | |
| 210 | 215 | ||
| 211 | - | let result = sqlx::query( | |
| 212 | - | "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", | |
| 213 | - | ) | |
| 214 | - | .bind(promo_code_id) | |
| 215 | - | .execute(&mut *tx) | |
| 216 | - | .await?; | |
| 217 | - | ||
| 218 | - | if result.rows_affected() == 0 { | |
| 219 | - | tx.rollback().await?; | |
| 220 | - | return Ok((false, false)); | |
| 221 | - | } | |
| 222 | - | ||
| 216 | + | // Step 1: Attempt to claim the item first | |
| 223 | 217 | let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id); | |
| 224 | 218 | let result = sqlx::query( | |
| 225 | 219 | r#" | |
| @@ -239,11 +233,30 @@ pub async fn claim_free_with_promo_code( | |||
| 239 | 233 | .await?; | |
| 240 | 234 | ||
| 241 | 235 | let claimed = result.rows_affected() > 0; | |
| 242 | - | if claimed { | |
| 243 | - | crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?; | |
| 236 | + | ||
| 237 | + | // Step 2: If the user already owns the item, rollback without consuming the code | |
| 238 | + | if !claimed { | |
| 239 | + | tx.rollback().await?; | |
| 240 | + | return Ok((true, false)); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Step 3: Increment the promo code use count | |
| 244 | + | let code_result = sqlx::query( | |
| 245 | + | "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", | |
| 246 | + | ) | |
| 247 | + | .bind(promo_code_id) | |
| 248 | + | .execute(&mut *tx) | |
| 249 | + | .await?; | |
| 250 | + | ||
| 251 | + | // Step 4: If the code limit was reached, rollback the claim too | |
| 252 | + | if code_result.rows_affected() == 0 { | |
| 253 | + | tx.rollback().await?; | |
| 254 | + | return Ok((false, false)); | |
| 244 | 255 | } | |
| 256 | + | ||
| 257 | + | crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?; | |
| 245 | 258 | tx.commit().await?; | |
| 246 | - | Ok((true, claimed)) | |
| 259 | + | Ok((true, true)) | |
| 247 | 260 | } | |
| 248 | 261 | ||
| 249 | 262 | // ── Project purchases ── | |
| @@ -636,3 +649,30 @@ pub async fn get_shared_creators( | |||
| 636 | 649 | ||
| 637 | 650 | Ok(rows) | |
| 638 | 651 | } | |
| 652 | + | ||
| 653 | + | /// Delete stale pending transactions (older than the given threshold) and return | |
| 654 | + | /// the promo_code_ids that need their use_count decremented. | |
| 655 | + | /// | |
| 656 | + | /// Stripe checkout sessions expire after 24 hours, so pending transactions older | |
| 657 | + | /// than that will never complete. This releases the pending purchase uniqueness | |
| 658 | + | /// slot and any reserved promo code use_count. | |
| 659 | + | #[tracing::instrument(skip_all)] | |
| 660 | + | pub async fn cleanup_stale_pending( | |
| 661 | + | pool: &PgPool, | |
| 662 | + | older_than: chrono::Duration, | |
| 663 | + | ) -> Result<Vec<Option<super::PromoCodeId>>> { | |
| 664 | + | let cutoff = chrono::Utc::now() - older_than; | |
| 665 | + | let rows: Vec<(Option<super::PromoCodeId>,)> = sqlx::query_as( | |
| 666 | + | r#" | |
| 667 | + | DELETE FROM transactions | |
| 668 | + | WHERE status = 'pending' | |
| 669 | + | AND created_at < $1 | |
| 670 | + | RETURNING promo_code_id | |
| 671 | + | "#, | |
| 672 | + | ) | |
| 673 | + | .bind(cutoff) | |
| 674 | + | .fetch_all(pool) | |
| 675 | + | .await?; | |
| 676 | + | ||
| 677 | + | Ok(rows.into_iter().map(|(id,)| id).collect()) | |
| 678 | + | } |
| @@ -139,6 +139,37 @@ pub async fn reactivate_user(pool: &PgPool, id: UserId) -> Result<()> { | |||
| 139 | 139 | Ok(()) | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | /// Admin: permanently terminate an account (enforcement ladder step 4). | |
| 143 | + | /// The user has 30 days to export data. After that, the scheduler deletes the account. | |
| 144 | + | /// The account must already be suspended. | |
| 145 | + | #[tracing::instrument(skip_all)] | |
| 146 | + | pub async fn terminate_user(pool: &PgPool, id: UserId) -> Result<()> { | |
| 147 | + | sqlx::query( | |
| 148 | + | "UPDATE users SET terminated_at = NOW(), updated_at = NOW() WHERE id = $1", | |
| 149 | + | ) | |
| 150 | + | .bind(id) | |
| 151 | + | .execute(pool) | |
| 152 | + | .await?; | |
| 153 | + | ||
| 154 | + | Ok(()) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Get user IDs of terminated accounts whose 30-day export window has expired. | |
| 158 | + | #[tracing::instrument(skip_all)] | |
| 159 | + | pub async fn get_expired_terminated_ids(pool: &PgPool) -> Result<Vec<UserId>> { | |
| 160 | + | let ids: Vec<UserId> = sqlx::query_scalar( | |
| 161 | + | r#" | |
| 162 | + | SELECT id FROM users | |
| 163 | + | WHERE terminated_at IS NOT NULL | |
| 164 | + | AND terminated_at < NOW() - INTERVAL '30 days' | |
| 165 | + | "#, | |
| 166 | + | ) | |
| 167 | + | .fetch_all(pool) | |
| 168 | + | .await?; | |
| 169 | + | ||
| 170 | + | Ok(ids) | |
| 171 | + | } | |
| 172 | + | ||
| 142 | 173 | /// Permanently delete a user by ID. | |
| 143 | 174 | /// | |
| 144 | 175 | /// Explicitly removes fingerprint and streaming session records before | |
| @@ -159,6 +190,80 @@ pub async fn delete_user(pool: &PgPool, id: UserId) -> Result<()> { | |||
| 159 | 190 | Ok(()) | |
| 160 | 191 | } | |
| 161 | 192 | ||
| 193 | + | /// Create an ephemeral sandbox user. Returns the created row. | |
| 194 | + | /// | |
| 195 | + | /// The user gets `can_create_projects = true`, `email_verified = true`, | |
| 196 | + | /// a SmallFiles creator tier, and a tight storage cap. The row is | |
| 197 | + | /// automatically cleaned up by the scheduler after `sandbox_expires_at`. | |
| 198 | + | #[tracing::instrument(skip_all)] | |
| 199 | + | pub async fn create_sandbox_user( | |
| 200 | + | pool: &PgPool, | |
| 201 | + | username: &Username, | |
| 202 | + | email: &str, | |
| 203 | + | password_hash: &str, | |
| 204 | + | expiry_secs: i64, | |
| 205 | + | max_file_bytes: i64, | |
| 206 | + | ) -> Result<DbUser> { | |
| 207 | + | let user = sqlx::query_as::<_, DbUser>( | |
| 208 | + | r#" | |
| 209 | + | INSERT INTO users ( | |
| 210 | + | username, email, password_hash, | |
| 211 | + | is_sandbox, sandbox_expires_at, | |
| 212 | + | can_create_projects, email_verified, | |
| 213 | + | creator_tier, max_file_override_bytes | |
| 214 | + | ) | |
| 215 | + | VALUES ( | |
| 216 | + | $1, $2, $3, | |
| 217 | + | TRUE, NOW() + make_interval(secs => $4::float8), | |
| 218 | + | TRUE, TRUE, | |
| 219 | + | 'SmallFiles', $5 | |
| 220 | + | ) | |
| 221 | + | RETURNING * | |
| 222 | + | "#, | |
| 223 | + | ) | |
| 224 | + | .bind(username) | |
| 225 | + | .bind(email) | |
| 226 | + | .bind(password_hash) | |
| 227 | + | .bind(expiry_secs as f64) | |
| 228 | + | .bind(max_file_bytes) | |
| 229 | + | .fetch_one(pool) | |
| 230 | + | .await?; | |
| 231 | + | ||
| 232 | + | Ok(user) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Return IDs of sandbox users whose expiry has passed. | |
| 236 | + | #[tracing::instrument(skip_all)] | |
| 237 | + | pub async fn get_expired_sandbox_ids(pool: &PgPool) -> Result<Vec<UserId>> { | |
| 238 | + | let ids = sqlx::query_scalar::<_, UserId>( | |
| 239 | + | "SELECT id FROM users WHERE is_sandbox = TRUE AND sandbox_expires_at < NOW()", | |
| 240 | + | ) | |
| 241 | + | .fetch_all(pool) | |
| 242 | + | .await?; | |
| 243 | + | ||
| 244 | + | Ok(ids) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// Count active (non-expired) sandbox accounts created from a given IP. | |
| 248 | + | /// Used to enforce the per-IP concurrent sandbox cap. | |
| 249 | + | #[tracing::instrument(skip_all)] | |
| 250 | + | pub async fn count_active_sandboxes_by_ip(pool: &PgPool, ip: &str) -> Result<i64> { | |
| 251 | + | let count: i64 = sqlx::query_scalar( | |
| 252 | + | r#" | |
| 253 | + | SELECT COUNT(*) FROM users u | |
| 254 | + | JOIN user_sessions us ON us.user_id = u.id | |
| 255 | + | WHERE u.is_sandbox = TRUE | |
| 256 | + | AND u.sandbox_expires_at > NOW() | |
| 257 | + | AND us.ip_address = $1 | |
| 258 | + | "#, | |
| 259 | + | ) | |
| 260 | + | .bind(ip) | |
| 261 | + | .fetch_one(pool) | |
| 262 | + | .await?; | |
| 263 | + | ||
| 264 | + | Ok(count) | |
| 265 | + | } | |
| 266 | + | ||
| 162 | 267 | /// Update user's Stripe Connect account information after OAuth | |
| 163 | 268 | #[tracing::instrument(skip_all)] | |
| 164 | 269 | pub async fn update_user_stripe_account( |
| @@ -679,6 +679,9 @@ mod tests { | |||
| 679 | 679 | custom_license_text: None, | |
| 680 | 680 | ai_tier: db::AiTier::Handmade, | |
| 681 | 681 | ai_disclosure: None, | |
| 682 | + | removed_by_admin: false, | |
| 683 | + | removal_reason: None, | |
| 684 | + | removed_at: None, | |
| 682 | 685 | } | |
| 683 | 686 | } | |
| 684 | 687 |