max / makenotwork
76 files changed,
+1382 insertions,
-309 deletions
| @@ -142,7 +142,7 @@ async-stripe = { version = "1.0.0-rc.6", features = ["default-tls"] } | |||
| 142 | 142 | async-stripe-shared = { version = "1.0.0-rc.6", features = ["deserialize"] } | |
| 143 | 143 | async-stripe-billing = { version = "1.0.0-rc.6", features = ["deserialize", "subscription", "billing_portal_session"] } | |
| 144 | 144 | async-stripe-checkout = { version = "1.0.0-rc.6", features = ["deserialize", "checkout_session"] } | |
| 145 | - | async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer"] } | |
| 145 | + | async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer", "transfer_reversal"] } | |
| 146 | 146 | async-stripe-core = { version = "1.0.0-rc.6", features = ["deserialize", "balance", "refund", "customer"] } | |
| 147 | 147 | async-stripe-payment = { version = "1.0.0-rc.6", features = ["deserialize"] } | |
| 148 | 148 | async-stripe-product = { version = "1.0.0-rc.6", features = ["deserialize", "product", "price"] } |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | -- Distinguish suspension-hidden items from genuine drafts (Run 21, content | |
| 2 | + | -- exposure trio). | |
| 3 | + | -- | |
| 4 | + | -- The post-grace integrity sweep (`hide_all_items_for_users`) flips every | |
| 5 | + | -- currently-public item to `is_public = false`. On re-subscribe, | |
| 6 | + | -- `unhide_all_items_for_user` flipped ALL `is_public = false` items back to | |
| 7 | + | -- true — including items the creator never published (genuine drafts). A | |
| 8 | + | -- lapsed-then-renewed creator's private drafts were silently made public. | |
| 9 | + | -- | |
| 10 | + | -- This marker records WHICH items the sweep hid, so the unhide restores only | |
| 11 | + | -- those and leaves drafts alone. NULL = not hidden by the sweep (a draft or a | |
| 12 | + | -- live item); non-NULL = hidden by the sweep and eligible for restore. | |
| 13 | + | ALTER TABLE items ADD COLUMN IF NOT EXISTS hidden_by_suspension_at TIMESTAMPTZ; |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | -- Backfill missing `sync_app_usage_current` rows (Run 21, HIGH availability). | |
| 2 | + | -- | |
| 3 | + | -- Migration 117 seeded one usage row per app that existed at the time, but | |
| 4 | + | -- nothing inserted a row for apps created afterward — `create_sync_app` only | |
| 5 | + | -- wrote to `sync_apps`. Every billing/blob path locks the usage row with | |
| 6 | + | -- `SELECT ... FOR UPDATE` and errors when it's absent, so every app created | |
| 7 | + | -- between 117 and this migration 500s on its storage/billing surface. | |
| 8 | + | -- | |
| 9 | + | -- `create_sync_app` now inserts the usage row transactionally on create; this | |
| 10 | + | -- repairs the apps that slipped through in between. Idempotent. | |
| 11 | + | INSERT INTO sync_app_usage_current (app_id) | |
| 12 | + | SELECT id FROM sync_apps | |
| 13 | + | ON CONFLICT (app_id) DO NOTHING; |
| @@ -0,0 +1,23 @@ | |||
| 1 | + | -- Reverse settled platform-funded credits when their sale is refunded (Run 21, | |
| 2 | + | -- money-loss finding). | |
| 3 | + | -- | |
| 4 | + | -- When a Fan+ platform credit discounts a paid sale, the scheduler transfers | |
| 5 | + | -- the discounted amount MNW -> creator so the creator nets full price | |
| 6 | + | -- (migration 158). If that sale is later refunded, the buyer's charge is | |
| 7 | + | -- refunded but nothing reversed the MNW -> creator transfer: the creator kept | |
| 8 | + | -- the reimbursement for a returned item and MNW absorbed the loss silently. | |
| 9 | + | -- | |
| 10 | + | -- To reverse the transfer we need its Stripe id, which the settle path now | |
| 11 | + | -- captures (`platform_credit_transfer_id`). `platform_credit_reversed_at` marks | |
| 12 | + | -- a completed reversal so the reversal sweep runs exactly once per transaction. | |
| 13 | + | ALTER TABLE transactions | |
| 14 | + | ADD COLUMN IF NOT EXISTS platform_credit_transfer_id TEXT, | |
| 15 | + | ADD COLUMN IF NOT EXISTS platform_credit_reversed_at TIMESTAMPTZ; | |
| 16 | + | ||
| 17 | + | -- Drives the reversal sweep: settled credits on refunded transactions that | |
| 18 | + | -- haven't been reversed yet. | |
| 19 | + | CREATE INDEX IF NOT EXISTS idx_transactions_platform_credit_reversible | |
| 20 | + | ON transactions (completed_at) | |
| 21 | + | WHERE platform_credit_cents > 0 | |
| 22 | + | AND platform_credit_settled_at IS NOT NULL | |
| 23 | + | AND platform_credit_reversed_at IS NULL; |
| @@ -34,6 +34,18 @@ use sqlx::PgPool; | |||
| 34 | 34 | ||
| 35 | 35 | use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus}; | |
| 36 | 36 | ||
| 37 | + | /// Truncate `s` to at most `max` bytes on a char boundary (for display only). | |
| 38 | + | fn truncate_display(s: &str, max: usize) -> &str { | |
| 39 | + | if s.len() <= max { | |
| 40 | + | return s; | |
| 41 | + | } | |
| 42 | + | let mut end = max; | |
| 43 | + | while end > 0 && !s.is_char_boundary(end) { | |
| 44 | + | end -= 1; | |
| 45 | + | } | |
| 46 | + | &s[..end] | |
| 47 | + | } | |
| 48 | + | ||
| 37 | 49 | #[derive(Parser)] | |
| 38 | 50 | #[command(name = "mnw-admin", about = "MNW admin CLI")] | |
| 39 | 51 | struct Cli { | |
| @@ -172,7 +184,7 @@ async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> { | |||
| 172 | 184 | for entry in &entries { | |
| 173 | 185 | let pitch = entry.pitch.as_deref().unwrap_or("(invited)"); | |
| 174 | 186 | let pitch_short = if pitch.len() > 40 { | |
| 175 | - | format!("{}...", &pitch[..40]) | |
| 187 | + | format!("{}...", truncate_display(pitch, 40)) | |
| 176 | 188 | } else { | |
| 177 | 189 | pitch.to_string() | |
| 178 | 190 | }; | |
| @@ -401,7 +413,7 @@ async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> { | |||
| 401 | 413 | .unwrap_or_else(|| "-".to_string()); | |
| 402 | 414 | let appeal = user.appeal_text.as_deref().unwrap_or(""); | |
| 403 | 415 | let appeal_short = if appeal.len() > 50 { | |
| 404 | - | format!("{}...", &appeal[..50]) | |
| 416 | + | format!("{}...", truncate_display(appeal, 50)) | |
| 405 | 417 | } else { | |
| 406 | 418 | appeal.to_string() | |
| 407 | 419 | }; | |
| @@ -485,11 +497,11 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<( | |||
| 485 | 497 | let date = tx.created_at.format("%Y-%m-%d"); | |
| 486 | 498 | let title = tx.item_title.as_deref().unwrap_or("(deleted)"); | |
| 487 | 499 | let title_short = if title.len() > 28 { | |
| 488 | - | format!("{}...", &title[..25]) | |
| 500 | + | format!("{}...", truncate_display(title, 25)) | |
| 489 | 501 | } else { | |
| 490 | 502 | title.to_string() | |
| 491 | 503 | }; | |
| 492 | - | let amount = format!("${:.2}", tx.amount_cents.as_f64() / 100.0); | |
| 504 | + | let amount = makenotwork::formatting::format_revenue(tx.amount_cents.as_i64()); | |
| 493 | 505 | println!( | |
| 494 | 506 | "{:<12} {:<30} {:>10} {:<10}", | |
| 495 | 507 | date, title_short, amount, tx.status | |
| @@ -528,8 +540,8 @@ async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |||
| 528 | 540 | .item_id | |
| 529 | 541 | .map(|id| id.to_string()) | |
| 530 | 542 | .unwrap_or_default(); | |
| 531 | - | let title = csv_escape(row.item_title.as_deref().unwrap_or("")); | |
| 532 | - | let email = csv_escape(row.buyer_email.as_deref().unwrap_or("")); | |
| 543 | + | let title = makenotwork::formatting::sanitize_csv_cell(row.item_title.as_deref().unwrap_or("")); | |
| 544 | + | let email = makenotwork::formatting::sanitize_csv_cell(row.buyer_email.as_deref().unwrap_or("")); | |
| 533 | 545 | println!( | |
| 534 | 546 | "{},{},{},{},{},{}", | |
| 535 | 547 | date, item_id, title, row.amount_cents, row.status, email | |
| @@ -539,15 +551,6 @@ async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |||
| 539 | 551 | Ok(()) | |
| 540 | 552 | } | |
| 541 | 553 | ||
| 542 | - | /// Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline. | |
| 543 | - | fn csv_escape(s: &str) -> String { | |
| 544 | - | if s.contains(',') || s.contains('"') || s.contains('\n') { | |
| 545 | - | format!("\"{}\"", s.replace('"', "\"\"")) | |
| 546 | - | } else { | |
| 547 | - | s.to_string() | |
| 548 | - | } | |
| 549 | - | } | |
| 550 | - | ||
| 551 | 554 | // ── Storage audit command ── | |
| 552 | 555 | ||
| 553 | 556 | async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |
| @@ -594,7 +597,7 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> { | |||
| 594 | 597 | if let Some(key) = &row.s3_key { | |
| 595 | 598 | let label = format!("{} v{}", row.item_title, row.version_number); | |
| 596 | 599 | let label_short = if label.len() > 23 { | |
| 597 | - | format!("{}...", &label[..20]) | |
| 600 | + | format!("{}...", truncate_display(&label, 20)) | |
| 598 | 601 | } else { | |
| 599 | 602 | label | |
| 600 | 603 | }; |
| @@ -224,9 +224,15 @@ impl Config { | |||
| 224 | 224 | let stripe = StripeConfig::from_env(); | |
| 225 | 225 | ||
| 226 | 226 | // Load admin user ID - optional, if unset admin routes return 404 | |
| 227 | - | let admin_user_id = std::env::var("ADMIN_USER_ID") | |
| 228 | - | .ok() | |
| 229 | - | .and_then(|s| s.parse::<UserId>().ok()); | |
| 227 | + | let admin_user_id = std::env::var("ADMIN_USER_ID").ok().and_then(|s| { | |
| 228 | + | s.parse::<UserId>() | |
| 229 | + | .map_err(|_| { | |
| 230 | + | tracing::warn!( | |
| 231 | + | "ADMIN_USER_ID is set but is not a valid UserId — ignoring it; admin routes will return 404" | |
| 232 | + | ); | |
| 233 | + | }) | |
| 234 | + | .ok() | |
| 235 | + | }); | |
| 230 | 236 | ||
| 231 | 237 | // SyncKit JWT secret - optional, sync endpoints return 503 if unset. | |
| 232 | 238 | // When set it IS the HS256 symmetric signing key for SyncKit/OAuth | |
| @@ -562,10 +568,17 @@ impl ScanConfig { | |||
| 562 | 568 | .unwrap_or(true), | |
| 563 | 569 | abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()), | |
| 564 | 570 | metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()), | |
| 565 | - | yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES") | |
| 566 | - | .ok() | |
| 567 | - | .and_then(|v| v.parse().ok()) | |
| 568 | - | .unwrap_or(DEFAULT_YARA_MIN_RULE_FILES), | |
| 571 | + | yara_min_rule_files: match std::env::var("YARA_MIN_RULE_FILES") { | |
| 572 | + | Ok(v) => v.parse().unwrap_or_else(|_| { | |
| 573 | + | tracing::warn!( | |
| 574 | + | value = %v, | |
| 575 | + | "YARA_MIN_RULE_FILES is set but is not a valid number — using default {}", | |
| 576 | + | DEFAULT_YARA_MIN_RULE_FILES | |
| 577 | + | ); | |
| 578 | + | DEFAULT_YARA_MIN_RULE_FILES | |
| 579 | + | }), | |
| 580 | + | Err(_) => DEFAULT_YARA_MIN_RULE_FILES, | |
| 581 | + | }, | |
| 569 | 582 | }) | |
| 570 | 583 | } | |
| 571 | 584 | } |
| @@ -12,6 +12,27 @@ pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3; | |||
| 12 | 12 | pub const DB_MAX_LIFETIME_SECS: u64 = 1800; | |
| 13 | 13 | /// Prune idle connections after 10 minutes. | |
| 14 | 14 | pub const DB_IDLE_TIMEOUT_SECS: u64 = 600; | |
| 15 | + | /// Per-statement wall-clock ceiling, applied to every pooled connection via | |
| 16 | + | /// `SET statement_timeout`. `acquire_timeout` only bounds *getting* a | |
| 17 | + | /// connection, not *running* a query — without this a single wedged query pins | |
| 18 | + | /// its connection forever and 25 of them exhaust the pool with no recovery. | |
| 19 | + | /// Generous so legitimate maintenance sweeps and boot migrations aren't killed; | |
| 20 | + | /// a job that genuinely needs longer sets `SET LOCAL statement_timeout = 0` in | |
| 21 | + | /// its own transaction. | |
| 22 | + | pub const DB_STATEMENT_TIMEOUT_SECS: u64 = 120; | |
| 23 | + | /// Ceiling on how long a statement waits to acquire a lock (`SET lock_timeout`). | |
| 24 | + | /// A lock-contended query fails fast instead of blocking a connection | |
| 25 | + | /// indefinitely — the most common way the pool wedges. | |
| 26 | + | pub const DB_LOCK_TIMEOUT_SECS: u64 = 30; | |
| 27 | + | ||
| 28 | + | /// Largest object a single presigned `PutObject` can carry. S3 / Ceph (Hetzner | |
| 29 | + | /// Object Storage) reject a single PUT above 5 GiB — larger objects require | |
| 30 | + | /// multipart. The browser upload paths issue exactly one presigned PUT, so this | |
| 31 | + | /// bounds them regardless of the (higher) per-tier `max_file_bytes`; files above | |
| 32 | + | /// it upload through the CLI / desktop clients, which chunk them. Keeping the | |
| 33 | + | /// browser cap here means a too-big browser upload is refused up front with a | |
| 34 | + | /// clear pointer rather than handed a presigned URL that fails at S3. | |
| 35 | + | pub const S3_SINGLE_PUT_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024; | |
| 15 | 36 | ||
| 16 | 37 | /// Lifetime of an internal-API actor assertion, minted at `ssh-key-lookup` and | |
| 17 | 38 | /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session. |
| @@ -330,8 +330,11 @@ impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> { | |||
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | 332 | fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> { | |
| 333 | - | // expression() is dead in every browser we support; record it so the | |
| 334 | - | // creator sees why it's gone, and recurse for any url() inside it. | |
| 333 | + | // expression() is dead in every browser we support, but the sanitizer's | |
| 334 | + | // contract is "recorded as blocked => actually removed", so neutralize it | |
| 335 | + | // rather than passing the token through: clear its arguments (dropping any | |
| 336 | + | // url()/payload inside) and rename it so the output can't contain a | |
| 337 | + | // working `expression(...)` (Run 21 security). | |
| 335 | 338 | if function.name.as_ref().eq_ignore_ascii_case("expression") { | |
| 336 | 339 | self.rejections.push(Rejection { | |
| 337 | 340 | kind: RejectionKind::BlockedFunction, | |
| @@ -339,6 +342,9 @@ impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> { | |||
| 339 | 342 | original_value: "expression()".into(), | |
| 340 | 343 | reason: "the expression() function is not allowed".into(), | |
| 341 | 344 | }); | |
| 345 | + | function.arguments.0.clear(); | |
| 346 | + | function.name = lightningcss::values::ident::Ident("mnw-blocked".into()); | |
| 347 | + | return Ok(()); | |
| 342 | 348 | } | |
| 343 | 349 | function.visit_children(self) | |
| 344 | 350 | } | |
| @@ -717,8 +723,13 @@ mod tests { | |||
| 717 | 723 | ||
| 718 | 724 | #[test] | |
| 719 | 725 | fn expression_function_recorded() { | |
| 720 | - | let (_out, rej) = san(".x { width: expression(alert(1)) }"); | |
| 726 | + | let (out, rej) = san(".x { width: expression(alert(1)) }"); | |
| 721 | 727 | assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction)); | |
| 728 | + | // Behavior must match the "blocked" contract: the output must not contain | |
| 729 | + | // a working expression() call or its payload. | |
| 730 | + | let lower = out.to_ascii_lowercase(); | |
| 731 | + | assert!(!lower.contains("expression("), "expression() must be neutralized in output: {out}"); | |
| 732 | + | assert!(!lower.contains("alert(1)"), "expression() payload must be stripped: {out}"); | |
| 722 | 733 | } | |
| 723 | 734 | ||
| 724 | 735 | #[test] |
| @@ -168,6 +168,10 @@ pub async fn get_landing_changelog_post( | |||
| 168 | 168 | /// (the scheduler will set it when the time comes). | |
| 169 | 169 | /// | |
| 170 | 170 | /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update. | |
| 171 | + | /// | |
| 172 | + | /// `publish` uses `Option<bool>`: `None` = leave `published_at` untouched (a | |
| 173 | + | /// background auto-save passes `None` so it can never silently unpublish a live | |
| 174 | + | /// post), `Some(true)` = publish, `Some(false)` = unpublish. | |
| 171 | 175 | #[allow(clippy::too_many_arguments)] | |
| 172 | 176 | #[tracing::instrument(skip_all)] | |
| 173 | 177 | pub async fn update_blog_post( | |
| @@ -177,19 +181,22 @@ pub async fn update_blog_post( | |||
| 177 | 181 | slug: &Slug, | |
| 178 | 182 | body_markdown: &str, | |
| 179 | 183 | body_html: &str, | |
| 180 | - | publish: bool, | |
| 184 | + | publish: Option<bool>, | |
| 181 | 185 | publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>, | |
| 182 | 186 | web_only: Option<bool>, | |
| 183 | 187 | show_on_landing: Option<bool>, | |
| 184 | 188 | ) -> Result<DbBlogPost> { | |
| 185 | 189 | let update_publish_at = publish_at.is_some(); | |
| 186 | 190 | let publish_at_value = publish_at.flatten(); | |
| 191 | + | let change_publish = publish.is_some(); | |
| 192 | + | let publish_value = publish.unwrap_or(false); | |
| 187 | 193 | ||
| 188 | - | // Four-way CASE for published_at: | |
| 194 | + | // Five-way CASE for published_at: | |
| 189 | 195 | // 1. Scheduling (publish_at is being set) → keep NULL (scheduler handles it) | |
| 190 | - | // 2. First publish (publish=true, published_at IS NULL) → set to NOW() | |
| 191 | - | // 3. Unpublish (publish=false) → clear to NULL | |
| 192 | - | // 4. Re-save while published → preserve existing timestamp | |
| 196 | + | // 2. Explicit first publish (publish=Some(true), published_at IS NULL) → set to NOW() | |
| 197 | + | // 3. Explicit unpublish (publish=Some(false)) → clear to NULL | |
| 198 | + | // 4. No publish-state change (publish=None, e.g. auto-save) → preserve | |
| 199 | + | // 5. Re-save while published (publish=Some(true), already published) → preserve | |
| 193 | 200 | let post = sqlx::query_as::<_, DbBlogPost>( | |
| 194 | 201 | r#" | |
| 195 | 202 | UPDATE blog_posts | |
| @@ -199,8 +206,8 @@ pub async fn update_blog_post( | |||
| 199 | 206 | body_html = $5, | |
| 200 | 207 | published_at = CASE | |
| 201 | 208 | WHEN $7 = true AND $8 IS NOT NULL THEN NULL | |
| 202 | - | WHEN $6 = true AND published_at IS NULL THEN NOW() | |
| 203 | - | WHEN $6 = false THEN NULL | |
| 209 | + | WHEN $11 = true AND $6 = true AND published_at IS NULL THEN NOW() | |
| 210 | + | WHEN $11 = true AND $6 = false THEN NULL | |
| 204 | 211 | ELSE published_at | |
| 205 | 212 | END, | |
| 206 | 213 | publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END, | |
| @@ -216,11 +223,12 @@ pub async fn update_blog_post( | |||
| 216 | 223 | .bind(slug) | |
| 217 | 224 | .bind(body_markdown) | |
| 218 | 225 | .bind(body_html) | |
| 219 | - | .bind(publish) | |
| 226 | + | .bind(publish_value) | |
| 220 | 227 | .bind(update_publish_at) | |
| 221 | 228 | .bind(publish_at_value) | |
| 222 | 229 | .bind(web_only) | |
| 223 | 230 | .bind(show_on_landing) | |
| 231 | + | .bind(change_publish) | |
| 224 | 232 | .fetch_one(pool) | |
| 225 | 233 | .await?; | |
| 226 | 234 |
| @@ -77,11 +77,20 @@ pub async fn update_collection( | |||
| 77 | 77 | Ok(collection) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | /// Delete a collection by ID. Returns true if a row was deleted. | |
| 80 | + | /// Delete a collection owned by `owner_id`. Returns true if a row was deleted. | |
| 81 | + | /// | |
| 82 | + | /// Ownership is scoped IN the SQL (defense in depth, mirroring | |
| 83 | + | /// `blog_posts::delete_blog_post` / `media_files::delete`) so this can't remove | |
| 84 | + | /// another user's collection even if a caller skips the upstream ownership check. | |
| 81 | 85 | #[tracing::instrument(skip_all)] | |
| 82 | - | pub async fn delete_collection(pool: &PgPool, collection_id: CollectionId) -> Result<bool> { | |
| 83 | - | let result = sqlx::query("DELETE FROM collections WHERE id = $1") | |
| 86 | + | pub async fn delete_collection( | |
| 87 | + | pool: &PgPool, | |
| 88 | + | collection_id: CollectionId, | |
| 89 | + | owner_id: UserId, | |
| 90 | + | ) -> Result<bool> { | |
| 91 | + | let result = sqlx::query("DELETE FROM collections WHERE id = $1 AND user_id = $2") | |
| 84 | 92 | .bind(collection_id) | |
| 93 | + | .bind(owner_id) | |
| 85 | 94 | .execute(pool) | |
| 86 | 95 | .await?; | |
| 87 | 96 |
| @@ -99,7 +99,26 @@ pub async fn get_active_creator_tier( | |||
| 99 | 99 | .fetch_optional(pool) | |
| 100 | 100 | .await?; | |
| 101 | 101 | ||
| 102 | - | Ok(tier.and_then(|t| t.parse().ok())) | |
| 102 | + | match tier { | |
| 103 | + | None => Ok(None), | |
| 104 | + | // A present-but-unparseable tier means the DB holds a tier string the | |
| 105 | + | // enum doesn't know (enum drift). Silently mapping that to `None` would | |
| 106 | + | // strip a paying creator's entitlements with no signal — surface it | |
| 107 | + | // loudly instead of swallowing it (Run 21). The `enum_drift` test and the | |
| 108 | + | // DB CHECK constraint make this unreachable in practice. | |
| 109 | + | Some(t) => match t.parse::<CreatorTier>() { | |
| 110 | + | Ok(parsed) => Ok(Some(parsed)), | |
| 111 | + | Err(_) => { | |
| 112 | + | tracing::error!( | |
| 113 | + | user_id = %user_id, tier = %t, | |
| 114 | + | "active creator subscription has an unrecognized tier string (enum drift)" | |
| 115 | + | ); | |
| 116 | + | Err(crate::error::AppError::Internal(anyhow::anyhow!( | |
| 117 | + | "unrecognized creator tier '{t}' for user {user_id}" | |
| 118 | + | ))) | |
| 119 | + | } | |
| 120 | + | }, | |
| 121 | + | } | |
| 103 | 122 | } | |
| 104 | 123 | ||
| 105 | 124 | // Apply a Stripe-driven status and/or period update in one guarded statement. |
| @@ -86,19 +86,22 @@ pub async fn get_followed_items( | |||
| 86 | 86 | WITH followed_item_ids AS ( | |
| 87 | 87 | SELECT i.id FROM items i | |
| 88 | 88 | JOIN projects p ON i.project_id = p.id | |
| 89 | + | JOIN users u ON u.id = p.user_id | |
| 89 | 90 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id | |
| 90 | - | WHERE i.is_public = true AND p.is_public = true | |
| 91 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 91 | 92 | UNION | |
| 92 | 93 | SELECT i.id FROM items i | |
| 93 | 94 | JOIN projects p ON i.project_id = p.id | |
| 95 | + | JOIN users u ON u.id = p.user_id | |
| 94 | 96 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id | |
| 95 | - | WHERE i.is_public = true AND p.is_public = true | |
| 97 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 96 | 98 | UNION | |
| 97 | 99 | SELECT i.id FROM items i | |
| 98 | 100 | JOIN projects p ON i.project_id = p.id | |
| 101 | + | JOIN users u ON u.id = p.user_id | |
| 99 | 102 | JOIN item_tags it ON it.item_id = i.id | |
| 100 | 103 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 101 | - | WHERE i.is_public = true AND p.is_public = true | |
| 104 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 102 | 105 | ) | |
| 103 | 106 | SELECT i.* FROM items i | |
| 104 | 107 | JOIN followed_item_ids fi ON fi.id = i.id | |
| @@ -127,19 +130,22 @@ pub async fn get_followed_feed_items( | |||
| 127 | 130 | WITH followed_item_ids AS ( | |
| 128 | 131 | SELECT i.id FROM items i | |
| 129 | 132 | JOIN projects p ON i.project_id = p.id | |
| 133 | + | JOIN users u ON u.id = p.user_id | |
| 130 | 134 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id | |
| 131 | - | WHERE i.is_public = true AND p.is_public = true | |
| 135 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 132 | 136 | UNION | |
| 133 | 137 | SELECT i.id FROM items i | |
| 134 | 138 | JOIN projects p ON i.project_id = p.id | |
| 139 | + | JOIN users u ON u.id = p.user_id | |
| 135 | 140 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id | |
| 136 | - | WHERE i.is_public = true AND p.is_public = true | |
| 141 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 137 | 142 | UNION | |
| 138 | 143 | SELECT i.id FROM items i | |
| 139 | 144 | JOIN projects p ON i.project_id = p.id | |
| 145 | + | JOIN users u ON u.id = p.user_id | |
| 140 | 146 | JOIN item_tags it ON it.item_id = i.id | |
| 141 | 147 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 142 | - | WHERE i.is_public = true AND p.is_public = true | |
| 148 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 143 | 149 | ) | |
| 144 | 150 | SELECT | |
| 145 | 151 | i.id, | |
| @@ -186,19 +192,22 @@ pub async fn count_followed_feed_items( | |||
| 186 | 192 | WITH followed_item_ids AS ( | |
| 187 | 193 | SELECT i.id FROM items i | |
| 188 | 194 | JOIN projects p ON i.project_id = p.id | |
| 195 | + | JOIN users u ON u.id = p.user_id | |
| 189 | 196 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id | |
| 190 | - | WHERE i.is_public = true AND p.is_public = true | |
| 197 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 191 | 198 | UNION | |
| 192 | 199 | SELECT i.id FROM items i | |
| 193 | 200 | JOIN projects p ON i.project_id = p.id | |
| 201 | + | JOIN users u ON u.id = p.user_id | |
| 194 | 202 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id | |
| 195 | - | WHERE i.is_public = true AND p.is_public = true | |
| 203 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 196 | 204 | UNION | |
| 197 | 205 | SELECT i.id FROM items i | |
| 198 | 206 | JOIN projects p ON i.project_id = p.id | |
| 207 | + | JOIN users u ON u.id = p.user_id | |
| 199 | 208 | JOIN item_tags it ON it.item_id = i.id | |
| 200 | 209 | JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id | |
| 201 | - | WHERE i.is_public = true AND p.is_public = true | |
| 210 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 202 | 211 | ) | |
| 203 | 212 | SELECT COUNT(*) FROM followed_item_ids | |
| 204 | 213 | "#, |
| @@ -62,6 +62,35 @@ pub async fn get_viewer_item_flags( | |||
| 62 | 62 | /// an existing item in the same project, appends a counter suffix. | |
| 63 | 63 | #[allow(clippy::too_many_arguments)] | |
| 64 | 64 | #[tracing::instrument(skip_all)] | |
| 65 | + | /// Find an existing untitled, unpublished wizard draft for a project + type. | |
| 66 | + | /// | |
| 67 | + | /// The single-type-card item wizard used to `create_item` a fresh "Untitled" | |
| 68 | + | /// row on every GET, so a prefetch or a re-visit piled up orphan drafts. The | |
| 69 | + | /// wizard now reuses the most recent such draft (newest first) instead of | |
| 70 | + | /// minting another (Run 21). A draft the creator actually names/publishes no | |
| 71 | + | /// longer matches, so real items are never recycled. | |
| 72 | + | #[tracing::instrument(skip_all)] | |
| 73 | + | pub async fn find_untitled_wizard_draft( | |
| 74 | + | pool: &PgPool, | |
| 75 | + | project_id: ProjectId, | |
| 76 | + | item_type: ItemType, | |
| 77 | + | ) -> Result<Option<DbItem>> { | |
| 78 | + | let item = sqlx::query_as::<_, DbItem>( | |
| 79 | + | r#" | |
| 80 | + | SELECT * FROM items | |
| 81 | + | WHERE project_id = $1 AND item_type = $2 | |
| 82 | + | AND title = 'Untitled' AND is_public = false AND deleted_at IS NULL | |
| 83 | + | ORDER BY created_at DESC | |
| 84 | + | LIMIT 1 | |
| 85 | + | "#, | |
| 86 | + | ) | |
| 87 | + | .bind(project_id) | |
| 88 | + | .bind(item_type) | |
| 89 | + | .fetch_optional(pool) | |
| 90 | + | .await?; | |
| 91 | + | Ok(item) | |
| 92 | + | } | |
| 93 | + | ||
| 65 | 94 | pub async fn create_item( | |
| 66 | 95 | pool: &PgPool, | |
| 67 | 96 | project_id: ProjectId, | |
| @@ -1037,6 +1066,9 @@ pub async fn get_item_by_project_and_slug( | |||
| 1037 | 1066 | /// Hide every public item for ALL given creators in one statement, for the | |
| 1038 | 1067 | /// post-grace scheduler sweep — replaces N per-user UPDATEs on the lock-held tick | |
| 1039 | 1068 | /// (Perf-S4, Run 9). Returns the total rows affected. No-op on an empty slice. | |
| 1069 | + | /// | |
| 1070 | + | /// Stamps `hidden_by_suspension_at` so the matching `unhide_all_items_for_user` | |
| 1071 | + | /// restores ONLY what this sweep hid, never a creator's genuine drafts. | |
| 1040 | 1072 | #[tracing::instrument(skip_all)] | |
| 1041 | 1073 | pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Result<u64> { | |
| 1042 | 1074 | if user_ids.is_empty() { | |
| @@ -1044,7 +1076,7 @@ pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Res | |||
| 1044 | 1076 | } | |
| 1045 | 1077 | let result = sqlx::query( | |
| 1046 | 1078 | r#" | |
| 1047 | - | UPDATE items SET is_public = false | |
| 1079 | + | UPDATE items SET is_public = false, hidden_by_suspension_at = NOW() | |
| 1048 | 1080 | WHERE project_id IN (SELECT id FROM projects WHERE user_id = ANY($1)) | |
| 1049 | 1081 | AND is_public = true | |
| 1050 | 1082 | "#, | |
| @@ -1056,15 +1088,20 @@ pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Res | |||
| 1056 | 1088 | Ok(result.rows_affected()) | |
| 1057 | 1089 | } | |
| 1058 | 1090 | ||
| 1059 | - | /// Unhide all items for a user (set is_public = true). Used when a creator re-subscribes | |
| 1060 | - | /// after post-grace hiding. Returns the number of items unhidden. | |
| 1091 | + | /// Unhide items for a user that the post-grace sweep hid. Used when a creator | |
| 1092 | + | /// re-subscribes. Returns the number of items unhidden. | |
| 1093 | + | /// | |
| 1094 | + | /// Restores ONLY items stamped by `hide_all_items_for_users` | |
| 1095 | + | /// (`hidden_by_suspension_at IS NOT NULL`) and clears the stamp — a draft the | |
| 1096 | + | /// creator never published (`is_public = false` with a NULL stamp) is left | |
| 1097 | + | /// private, so a lapsed-then-renewed subscription can't leak unpublished work. | |
| 1061 | 1098 | #[tracing::instrument(skip_all)] | |
| 1062 | 1099 | pub async fn unhide_all_items_for_user(pool: &PgPool, user_id: UserId) -> Result<u64> { | |
| 1063 | 1100 | let result = sqlx::query( | |
| 1064 | 1101 | r#" | |
| 1065 | - | UPDATE items SET is_public = true | |
| 1102 | + | UPDATE items SET is_public = true, hidden_by_suspension_at = NULL | |
| 1066 | 1103 | WHERE project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 1067 | - | AND is_public = false | |
| 1104 | + | AND hidden_by_suspension_at IS NOT NULL | |
| 1068 | 1105 | AND removed_at IS NULL | |
| 1069 | 1106 | "#, | |
| 1070 | 1107 | ) |
| @@ -3,7 +3,6 @@ | |||
| 3 | 3 | use sqlx::PgPool; | |
| 4 | 4 | ||
| 5 | 5 | use super::enums::MailingListType; | |
| 6 | - | use super::follows::FollowerEmailRow; | |
| 7 | 6 | use super::id_types::{MailingListId, ProjectId, UserId}; | |
| 8 | 7 | use super::models::DbMailingList; | |
| 9 | 8 | use crate::error::Result; | |
| @@ -115,22 +114,44 @@ pub async fn unsubscribe_from_project( | |||
| 115 | 114 | Ok(result.rows_affected()) | |
| 116 | 115 | } | |
| 117 | 116 | ||
| 118 | - | /// Get email addresses of all subscribers on a list. | |
| 119 | - | /// Only verified, non-suspended, non-suppressed users. Capped at 10,000. | |
| 117 | + | /// A mailing-list recipient: either an MNW user (has `user_id`) or an | |
| 118 | + | /// email-only subscriber imported from another platform (`user_id` is `None`). | |
| 119 | + | /// The two need different unsubscribe links — user-keyed vs email-keyed. | |
| 120 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 121 | + | pub struct MailingSubscriber { | |
| 122 | + | pub user_id: Option<UserId>, | |
| 123 | + | pub email: String, | |
| 124 | + | pub display_name: Option<String>, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Get all deliverable subscribers on a list — both MNW users AND email-only | |
| 128 | + | /// imported subscribers. Capped at 10,000. | |
| 129 | + | /// | |
| 130 | + | /// User rows require a verified, non-suspended account; email-only rows | |
| 131 | + | /// (`user_id IS NULL`) come straight from the import. Both branches exclude | |
| 132 | + | /// suppressed addresses. Previously this INNER JOINed `users`, so imported | |
| 133 | + | /// email-only subscribers were silently never emailed (Run 21 data). | |
| 120 | 134 | #[tracing::instrument(skip_all)] | |
| 121 | - | pub async fn get_subscriber_emails( | |
| 135 | + | pub async fn get_subscribers( | |
| 122 | 136 | pool: &PgPool, | |
| 123 | 137 | list_id: MailingListId, | |
| 124 | - | ) -> Result<Vec<FollowerEmailRow>> { | |
| 125 | - | let rows = sqlx::query_as::<_, FollowerEmailRow>( | |
| 138 | + | ) -> Result<Vec<MailingSubscriber>> { | |
| 139 | + | let rows = sqlx::query_as::<_, MailingSubscriber>( | |
| 126 | 140 | r#" | |
| 127 | - | SELECT u.id, u.email, u.display_name | |
| 141 | + | SELECT u.id AS user_id, u.email, u.display_name | |
| 128 | 142 | FROM mailing_list_subscribers s | |
| 129 | 143 | JOIN users u ON u.id = s.user_id | |
| 130 | 144 | WHERE s.list_id = $1 | |
| 131 | 145 | AND u.email_verified = true | |
| 132 | 146 | AND u.suspended_at IS NULL | |
| 133 | 147 | AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions) | |
| 148 | + | UNION ALL | |
| 149 | + | SELECT NULL::uuid AS user_id, s.email, NULL AS display_name | |
| 150 | + | FROM mailing_list_subscribers s | |
| 151 | + | WHERE s.list_id = $1 | |
| 152 | + | AND s.user_id IS NULL | |
| 153 | + | AND s.email IS NOT NULL | |
| 154 | + | AND LOWER(s.email) NOT IN (SELECT LOWER(email) FROM email_suppressions) | |
| 134 | 155 | LIMIT 10000 | |
| 135 | 156 | "#, | |
| 136 | 157 | ) | |
| @@ -141,6 +162,26 @@ pub async fn get_subscriber_emails( | |||
| 141 | 162 | Ok(rows) | |
| 142 | 163 | } | |
| 143 | 164 | ||
| 165 | + | /// Unsubscribe an email-only subscriber (no MNW account) from a list. Removes | |
| 166 | + | /// the `(list_id, email)` row. Idempotent. Backs the email-keyed unsubscribe | |
| 167 | + | /// link carried in emails to imported subscribers. | |
| 168 | + | #[tracing::instrument(skip_all)] | |
| 169 | + | pub async fn unsubscribe_by_email( | |
| 170 | + | pool: &PgPool, | |
| 171 | + | list_id: MailingListId, | |
| 172 | + | email: &str, | |
| 173 | + | ) -> Result<bool> { | |
| 174 | + | let result = sqlx::query( | |
| 175 | + | "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND LOWER(email) = LOWER($2) AND user_id IS NULL", | |
| 176 | + | ) | |
| 177 | + | .bind(list_id) | |
| 178 | + | .bind(email) | |
| 179 | + | .execute(pool) | |
| 180 | + | .await?; | |
| 181 | + | ||
| 182 | + | Ok(result.rows_affected() > 0) | |
| 183 | + | } | |
| 184 | + | ||
| 144 | 185 | /// Auto-create the default content + devlog lists for a new project. | |
| 145 | 186 | #[tracing::instrument(skip_all)] | |
| 146 | 187 | pub async fn create_default_lists( |
| @@ -55,7 +55,7 @@ pub mod collections; // pub so the integration test crate can exercise the layer | |||
| 55 | 55 | pub(crate) mod ota; | |
| 56 | 56 | pub(crate) mod builds; | |
| 57 | 57 | pub mod creator_tiers; | |
| 58 | - | pub(crate) mod mailing_lists; | |
| 58 | + | pub mod mailing_lists; | |
| 59 | 59 | pub mod custom_domains; | |
| 60 | 60 | pub mod patches; | |
| 61 | 61 | pub mod bundles; |
| @@ -118,8 +118,9 @@ pub struct DbSyncBlob { | |||
| 118 | 118 | /// Sync app + billing columns + live usage counters (joined view). | |
| 119 | 119 | /// | |
| 120 | 120 | /// Mirrors columns added in migration 117 (`117_synckit_v2_billing.sql`). | |
| 121 | - | /// Built by joining `sync_apps` against `sync_app_usage_current` (LEFT JOIN; /// the usage row is inserted on app create, but guards against the row going | |
| 122 | - | /// missing). | |
| 121 | + | /// Built by joining `sync_apps` against `sync_app_usage_current` (LEFT JOIN). | |
| 122 | + | /// The usage row is created with the app (`create_sync_app`) and backfilled by | |
| 123 | + | /// migration 165; the LEFT JOIN is defense-in-depth against a missing row. | |
| 123 | 124 | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 124 | 125 | pub struct DbSyncAppBilling { | |
| 125 | 126 | // sync_apps base |
| @@ -59,17 +59,71 @@ pub async fn claim_unsettled_credit(pool: &PgPool) -> Result<Option<PlatformCred | |||
| 59 | 59 | Ok(row) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | /// Record that a claimed credit's transfer succeeded. Idempotent. | |
| 63 | - | pub async fn mark_settled(pool: &PgPool, transaction_id: TransactionId) -> Result<()> { | |
| 64 | - | sqlx::query!( | |
| 65 | - | "UPDATE transactions SET platform_credit_settled_at = NOW() WHERE id = $1", | |
| 66 | - | transaction_id as TransactionId, | |
| 62 | + | /// Record that a claimed credit's transfer succeeded, storing the Stripe | |
| 63 | + | /// transfer id so the credit can be reversed if the sale is later refunded. | |
| 64 | + | /// Idempotent. Runtime-checked query (the `platform_credit_transfer_id` column | |
| 65 | + | /// is newer than the offline sqlx cache), per [`get_stale_credits`]. | |
| 66 | + | pub async fn mark_settled( | |
| 67 | + | pool: &PgPool, | |
| 68 | + | transaction_id: TransactionId, | |
| 69 | + | transfer_id: &str, | |
| 70 | + | ) -> Result<()> { | |
| 71 | + | sqlx::query( | |
| 72 | + | "UPDATE transactions SET platform_credit_settled_at = NOW(), platform_credit_transfer_id = $2 WHERE id = $1", | |
| 67 | 73 | ) | |
| 74 | + | .bind(transaction_id) | |
| 75 | + | .bind(transfer_id) | |
| 68 | 76 | .execute(pool) | |
| 69 | 77 | .await?; | |
| 70 | 78 | Ok(()) | |
| 71 | 79 | } | |
| 72 | 80 | ||
| 81 | + | /// A settled platform credit whose sale was refunded and whose MNW -> creator | |
| 82 | + | /// transfer must be reversed to claw the reimbursement back. | |
| 83 | + | #[derive(Debug, sqlx::FromRow)] | |
| 84 | + | pub struct ReversibleCredit { | |
| 85 | + | pub transaction_id: TransactionId, | |
| 86 | + | pub amount_cents: Cents, | |
| 87 | + | pub transfer_id: String, | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Up to `limit` settled platform credits sitting on refunded transactions that | |
| 91 | + | /// haven't been reversed yet, oldest first. The reversal uses a deterministic | |
| 92 | + | /// idempotency key, so the single-instance scheduler can process these | |
| 93 | + | /// sequentially and safely. Runtime-checked query (new columns). | |
| 94 | + | pub async fn get_reversible_credits(pool: &PgPool, limit: i64) -> Result<Vec<ReversibleCredit>> { | |
| 95 | + | let rows = sqlx::query_as::<_, ReversibleCredit>( | |
| 96 | + | r#" | |
| 97 | + | SELECT id AS transaction_id, | |
| 98 | + | platform_credit_cents AS amount_cents, | |
| 99 | + | platform_credit_transfer_id AS transfer_id | |
| 100 | + | FROM transactions | |
| 101 | + | WHERE status = 'refunded' | |
| 102 | + | AND platform_credit_cents > 0 | |
| 103 | + | AND platform_credit_settled_at IS NOT NULL | |
| 104 | + | AND platform_credit_transfer_id IS NOT NULL | |
| 105 | + | AND platform_credit_reversed_at IS NULL | |
| 106 | + | ORDER BY completed_at | |
| 107 | + | LIMIT $1 | |
| 108 | + | "#, | |
| 109 | + | ) | |
| 110 | + | .bind(limit) | |
| 111 | + | .fetch_all(pool) | |
| 112 | + | .await?; | |
| 113 | + | ||
| 114 | + | Ok(rows) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Mark a platform credit reversed (funds clawed back). Idempotent. | |
| 118 | + | /// Runtime-checked query (new column). | |
| 119 | + | pub async fn mark_reversed(pool: &PgPool, transaction_id: TransactionId) -> Result<()> { | |
| 120 | + | sqlx::query("UPDATE transactions SET platform_credit_reversed_at = NOW() WHERE id = $1") | |
| 121 | + | .bind(transaction_id) | |
| 122 | + | .execute(pool) | |
| 123 | + | .await?; | |
| 124 | + | Ok(()) | |
| 125 | + | } | |
| 126 | + | ||
| 73 | 127 | /// Release a claimed-but-unsettled credit back to the queue after a *graceful* | |
| 74 | 128 | /// transfer failure (transient error where nothing external happened). A | |
| 75 | 129 | /// non-graceful failure (process killed) cannot reach here; that row stays |
| @@ -99,18 +99,24 @@ pub async fn delete(pool: &PgPool, section_id: ProjectSectionId) -> Result<()> { | |||
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | 101 | /// Reorder sections by setting sort_order from an ordered list of IDs. | |
| 102 | + | /// | |
| 103 | + | /// A single `UNNEST ... WITH ORDINALITY` update: atomic (no partial ordering on | |
| 104 | + | /// failure) and one round-trip instead of one query per section. | |
| 102 | 105 | #[tracing::instrument(skip_all)] | |
| 103 | 106 | pub async fn reorder(pool: &PgPool, project_id: ProjectId, section_ids: &[ProjectSectionId]) -> Result<()> { | |
| 104 | - | for (i, id) in section_ids.iter().enumerate() { | |
| 105 | - | sqlx::query( | |
| 106 | - | "UPDATE project_sections SET sort_order = $1, updated_at = now() WHERE id = $2 AND project_id = $3", | |
| 107 | - | ) | |
| 108 | - | .bind(i as i32) | |
| 109 | - | .bind(id) | |
| 110 | - | .bind(project_id) | |
| 111 | - | .execute(pool) | |
| 112 | - | .await?; | |
| 113 | - | } | |
| 107 | + | let ids: Vec<uuid::Uuid> = section_ids.iter().map(|id| *id.as_uuid()).collect(); | |
| 108 | + | sqlx::query( | |
| 109 | + | r#" | |
| 110 | + | UPDATE project_sections AS s | |
| 111 | + | SET sort_order = ord.pos::int - 1, updated_at = now() | |
| 112 | + | FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos) | |
| 113 | + | WHERE s.id = ord.id AND s.project_id = $2 | |
| 114 | + | "#, | |
| 115 | + | ) | |
| 116 | + | .bind(&ids) | |
| 117 | + | .bind(project_id) | |
| 118 | + | .execute(pool) | |
| 119 | + | .await?; | |
| 114 | 120 | ||
| 115 | 121 | Ok(()) | |
| 116 | 122 | } |
| @@ -40,8 +40,6 @@ pub struct HeldVersionRow { | |||
| 40 | 40 | pub scan_layers: Option<serde_json::Value>, | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | /// Insert a scan result record for audit trail. | |
| 44 | - | #[tracing::instrument(skip_all)] | |
| 45 | 43 | /// Remove every CDN-served image reference to `s3_key`, across the image | |
| 46 | 44 | /// surfaces that have no per-row scan gate: the gallery tables | |
| 47 | 45 | /// (`item_images.s3_key`, `project_images.s3_key`, `content_insertions.storage_key`) | |
| @@ -158,6 +156,8 @@ pub async fn set_cdn_image_scan_status_by_key( | |||
| 158 | 156 | Ok(affected) | |
| 159 | 157 | } | |
| 160 | 158 | ||
| 159 | + | /// Insert a scan result record for audit trail. | |
| 160 | + | #[tracing::instrument(skip_all)] | |
| 161 | 161 | pub async fn insert_scan_result( | |
| 162 | 162 | db: &PgPool, | |
| 163 | 163 | s3_key: &str, |
| @@ -17,6 +17,12 @@ pub fn hash_api_key(api_key: &str) -> String { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | /// Create a new sync app. Stores the hashed API key and prefix. | |
| 20 | + | /// | |
| 21 | + | /// Creates the app row AND its `sync_app_usage_current` row in one transaction. | |
| 22 | + | /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress) | |
| 23 | + | /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so | |
| 24 | + | /// an app without one is unusable. Migration 117 seeded the row for apps that | |
| 25 | + | /// existed then; this keeps every app created since in the same state. | |
| 20 | 26 | #[tracing::instrument(skip_all)] | |
| 21 | 27 | pub async fn create_sync_app( | |
| 22 | 28 | pool: &PgPool, | |
| @@ -28,6 +34,8 @@ pub async fn create_sync_app( | |||
| 28 | 34 | ) -> Result<DbSyncApp> { | |
| 29 | 35 | let key_hash = hash_api_key(api_key); | |
| 30 | 36 | let key_prefix = &api_key[..8.min(api_key.len())]; | |
| 37 | + | ||
| 38 | + | let mut tx = pool.begin().await?; | |
| 31 | 39 | let app = sqlx::query_as::<_, DbSyncApp>( | |
| 32 | 40 | r#" | |
| 33 | 41 | INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id) | |
| @@ -41,9 +49,17 @@ pub async fn create_sync_app( | |||
| 41 | 49 | .bind(key_prefix) | |
| 42 | 50 | .bind(project_id) | |
| 43 | 51 | .bind(item_id) | |
| 44 | - | .fetch_one(pool) | |
| 52 | + | .fetch_one(&mut *tx) | |
| 53 | + | .await?; | |
| 54 | + | ||
| 55 | + | sqlx::query( | |
| 56 | + | "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING", | |
| 57 | + | ) | |
| 58 | + | .bind(app.id) | |
| 59 | + | .execute(&mut *tx) | |
| 45 | 60 | .await?; | |
| 46 | 61 | ||
| 62 | + | tx.commit().await?; | |
| 47 | 63 | Ok(app) | |
| 48 | 64 | } | |
| 49 | 65 |