Skip to main content

max / makenotwork

Add DB layer support for sandbox, termination, content removal, and promo tracking - SubscriptionStatus: add Trialing, Incomplete, IncompleteExpired variants - DbItem: add removed_by_admin, removal_reason, removed_at fields - DbUser: add is_sandbox, sandbox_expires_at, terminated_at fields - DbTransaction: add promo_code_id field - DbSubscription test fixture: add paused_at field - items: admin_remove_item, admin_restore_item, publish guards for removed items - users: create_sandbox_user, terminate_user, get_expired_sandbox/terminated_ids - projects: get_project_ids_for_user (lightweight, for cleanup) - promo_codes: release_use_count for stale reservation cleanup - subscriptions: paused_at filter on active subscription checks - transactions: promo_code_id in create, claim_free_with_promo_code reorder (claim first, increment code second, rollback on already-owned), cleanup_stale_pending for scheduler - pricing test fixture: add new item fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:41 UTC
Commit: e7052f4c23b6cbd3415432861c66351c9f485d71
Parent: 69240f6
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