max / makenotwork
18 files changed,
+340 insertions,
-159 deletions
| @@ -0,0 +1,61 @@ | |||
| 1 | + | -- Denormalized active-item count per project (ultra-fuzz Run 11 Perf SER-3). | |
| 2 | + | -- | |
| 3 | + | -- discover_projects computed item_count with a LEFT JOIN items + GROUP BY + | |
| 4 | + | -- COUNT(...) FILTER over the whole public catalog on every discover page load — | |
| 5 | + | -- O(items) per project. This caches the count on the project row. | |
| 6 | + | -- | |
| 7 | + | -- "Active" matches the discover filter exactly: is_public AND listed AND | |
| 8 | + | -- deleted_at IS NULL. The count is maintained by a trigger on `items`, not by | |
| 9 | + | -- application code, so no write path can forget to adjust it and the value can't | |
| 10 | + | -- drift (the maintenance lives in one place, atomic with the item write). | |
| 11 | + | ||
| 12 | + | ALTER TABLE projects ADD COLUMN item_count INTEGER NOT NULL DEFAULT 0; | |
| 13 | + | ||
| 14 | + | -- Backfill from the current truth. | |
| 15 | + | UPDATE projects p SET item_count = ( | |
| 16 | + | SELECT COUNT(*) FROM items i | |
| 17 | + | WHERE i.project_id = p.id | |
| 18 | + | AND i.is_public = true | |
| 19 | + | AND i.listed = true | |
| 20 | + | AND i.deleted_at IS NULL | |
| 21 | + | ); | |
| 22 | + | ||
| 23 | + | CREATE OR REPLACE FUNCTION sync_project_item_count() RETURNS TRIGGER AS $$ | |
| 24 | + | DECLARE | |
| 25 | + | old_active BOOLEAN; | |
| 26 | + | new_active BOOLEAN; | |
| 27 | + | BEGIN | |
| 28 | + | IF TG_OP = 'INSERT' THEN | |
| 29 | + | IF NEW.is_public AND NEW.listed AND NEW.deleted_at IS NULL THEN | |
| 30 | + | UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id; | |
| 31 | + | END IF; | |
| 32 | + | RETURN NEW; | |
| 33 | + | ELSIF TG_OP = 'DELETE' THEN | |
| 34 | + | IF OLD.is_public AND OLD.listed AND OLD.deleted_at IS NULL THEN | |
| 35 | + | UPDATE projects SET item_count = item_count - 1 WHERE id = OLD.project_id; | |
| 36 | + | END IF; | |
| 37 | + | RETURN OLD; | |
| 38 | + | ELSE -- UPDATE | |
| 39 | + | old_active := OLD.is_public AND OLD.listed AND OLD.deleted_at IS NULL; | |
| 40 | + | new_active := NEW.is_public AND NEW.listed AND NEW.deleted_at IS NULL; | |
| 41 | + | IF OLD.project_id <> NEW.project_id THEN | |
| 42 | + | -- Item moved between projects: settle the count on both sides. | |
| 43 | + | IF old_active THEN | |
| 44 | + | UPDATE projects SET item_count = item_count - 1 WHERE id = OLD.project_id; | |
| 45 | + | END IF; | |
| 46 | + | IF new_active THEN | |
| 47 | + | UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id; | |
| 48 | + | END IF; | |
| 49 | + | ELSIF old_active AND NOT new_active THEN | |
| 50 | + | UPDATE projects SET item_count = item_count - 1 WHERE id = NEW.project_id; | |
| 51 | + | ELSIF new_active AND NOT old_active THEN | |
| 52 | + | UPDATE projects SET item_count = item_count + 1 WHERE id = NEW.project_id; | |
| 53 | + | END IF; | |
| 54 | + | RETURN NEW; | |
| 55 | + | END IF; | |
| 56 | + | END; | |
| 57 | + | $$ LANGUAGE plpgsql; | |
| 58 | + | ||
| 59 | + | CREATE TRIGGER trg_sync_project_item_count | |
| 60 | + | AFTER INSERT OR UPDATE OR DELETE ON items | |
| 61 | + | FOR EACH ROW EXECUTE FUNCTION sync_project_item_count(); |
| @@ -0,0 +1,15 @@ | |||
| 1 | + | -- Ordered dashboard-transaction indexes (ultra-fuzz Run 11 Perf MOD tail). | |
| 2 | + | -- | |
| 3 | + | -- The buyer/seller dashboard tabs run | |
| 4 | + | -- SELECT ... FROM transactions WHERE {seller,buyer}_id = $1 ORDER BY created_at DESC LIMIT $2 | |
| 5 | + | -- The plain single-column indexes serve the equality but force a sort of every | |
| 6 | + | -- one of the user's transactions to satisfy the ORDER BY + LIMIT. A composite | |
| 7 | + | -- (id, created_at DESC) lets the planner walk the index in order and stop at the | |
| 8 | + | -- limit. The composite covers the single-column equality lookups as a prefix, so | |
| 9 | + | -- the originals are redundant and dropped. | |
| 10 | + | ||
| 11 | + | DROP INDEX IF EXISTS idx_transactions_seller_id; | |
| 12 | + | DROP INDEX IF EXISTS idx_transactions_buyer_id; | |
| 13 | + | ||
| 14 | + | CREATE INDEX idx_transactions_seller_created ON transactions(seller_id, created_at DESC); | |
| 15 | + | CREATE INDEX idx_transactions_buyer_created ON transactions(buyer_id, created_at DESC); |
| @@ -551,6 +551,10 @@ async fn run_ssh_command(host: &str, command: &str) -> std::result::Result<Strin | |||
| 551 | 551 | .stdin(std::process::Stdio::null()) | |
| 552 | 552 | .stdout(std::process::Stdio::piped()) | |
| 553 | 553 | .stderr(std::process::Stdio::piped()) | |
| 554 | + | // Kill the ssh process if this future is dropped (e.g. the 30-min build | |
| 555 | + | // timeout fires): otherwise the dropped future leaves ssh — and the | |
| 556 | + | // remote build it drives — running orphaned (ultra-fuzz Run 11 Perf). | |
| 557 | + | .kill_on_drop(true) | |
| 554 | 558 | .spawn() | |
| 555 | 559 | .map_err(|e| format!("failed to spawn ssh: {e}"))?; | |
| 556 | 560 | ||
| @@ -625,6 +629,9 @@ async fn run_scp_download( | |||
| 625 | 629 | args.push(local_path); | |
| 626 | 630 | let output = tokio::process::Command::new("scp") | |
| 627 | 631 | .args(&args) | |
| 632 | + | // Kill scp if this future is dropped (build timeout) rather than leaving | |
| 633 | + | // an orphaned transfer running (ultra-fuzz Run 11 Perf). | |
| 634 | + | .kill_on_drop(true) | |
| 628 | 635 | .output() | |
| 629 | 636 | .await | |
| 630 | 637 | .map_err(|e| format!("failed to spawn scp: {e}"))?; |
| @@ -341,7 +341,7 @@ pub async fn discover_projects( | |||
| 341 | 341 | p.project_type, | |
| 342 | 342 | p.created_at, | |
| 343 | 343 | u.username, | |
| 344 | - | COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count, | |
| 344 | + | p.item_count::bigint as item_count, | |
| 345 | 345 | GREATEST( | |
| 346 | 346 | similarity(p.title, $1), | |
| 347 | 347 | similarity(COALESCE(p.description, ''), $1) * 0.5 | |
| @@ -350,7 +350,6 @@ pub async fn discover_projects( | |||
| 350 | 350 | pc.slug as category_slug | |
| 351 | 351 | FROM projects p | |
| 352 | 352 | JOIN users u ON p.user_id = u.id | |
| 353 | - | LEFT JOIN items i ON i.project_id = p.id | |
| 354 | 353 | LEFT JOIN project_categories pc ON pc.id = p.category_id | |
| 355 | 354 | WHERE p.is_public = true AND u.is_sandbox = FALSE | |
| 356 | 355 | "#, | |
| @@ -366,13 +365,12 @@ pub async fn discover_projects( | |||
| 366 | 365 | p.project_type, | |
| 367 | 366 | p.created_at, | |
| 368 | 367 | u.username, | |
| 369 | - | COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count, | |
| 368 | + | p.item_count::bigint as item_count, | |
| 370 | 369 | 1.0::real as match_score, | |
| 371 | 370 | pc.name as category_name, | |
| 372 | 371 | pc.slug as category_slug | |
| 373 | 372 | FROM projects p | |
| 374 | 373 | JOIN users u ON p.user_id = u.id | |
| 375 | - | LEFT JOIN items i ON i.project_id = p.id | |
| 376 | 374 | LEFT JOIN project_categories pc ON pc.id = p.category_id | |
| 377 | 375 | WHERE p.is_public = true AND u.is_sandbox = FALSE | |
| 378 | 376 | "#, | |
| @@ -387,13 +385,12 @@ pub async fn discover_projects( | |||
| 387 | 385 | p.project_type, | |
| 388 | 386 | p.created_at, | |
| 389 | 387 | u.username, | |
| 390 | - | COUNT(i.id) FILTER (WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL) as item_count, | |
| 388 | + | p.item_count::bigint as item_count, | |
| 391 | 389 | NULL::real as match_score, | |
| 392 | 390 | pc.name as category_name, | |
| 393 | 391 | pc.slug as category_slug | |
| 394 | 392 | FROM projects p | |
| 395 | 393 | JOIN users u ON p.user_id = u.id | |
| 396 | - | LEFT JOIN items i ON i.project_id = p.id | |
| 397 | 394 | LEFT JOIN project_categories pc ON pc.id = p.category_id | |
| 398 | 395 | WHERE p.is_public = true AND u.is_sandbox = FALSE | |
| 399 | 396 | "#, | |
| @@ -416,7 +413,8 @@ pub async fn discover_projects( | |||
| 416 | 413 | query.push_str(" AND EXISTS (SELECT 1 FROM git_repos gr WHERE gr.project_id = p.id)"); | |
| 417 | 414 | } | |
| 418 | 415 | ||
| 419 | - | query.push_str(" GROUP BY p.id, u.username, pc.name, pc.slug"); | |
| 416 | + | // No GROUP BY: item_count is now a denormalized column on projects (maintained | |
| 417 | + | // by a trigger), so the query has no aggregate to group (Run 11 Perf SER-3). | |
| 420 | 418 | ||
| 421 | 419 | let order = if has_search && (sort_by.is_none() || sort_by == Some(DiscoverSort::Newest)) { | |
| 422 | 420 | "match_score DESC NULLS LAST, p.created_at DESC" |
| @@ -83,30 +83,25 @@ pub async fn get_followed_items( | |||
| 83 | 83 | ) -> Result<Vec<super::models::DbItem>> { | |
| 84 | 84 | let items = sqlx::query_as::<_, super::models::DbItem>( | |
| 85 | 85 | r#" | |
| 86 | - | SELECT DISTINCT i.* FROM items i | |
| 87 | - | JOIN projects p ON i.project_id = p.id | |
| 88 | - | WHERE i.is_public = true AND p.is_public = true | |
| 89 | - | AND ( | |
| 90 | - | -- Items from followed users | |
| 91 | - | p.user_id IN ( | |
| 92 | - | SELECT target_id FROM follows | |
| 93 | - | WHERE follower_id = $1 AND target_type = 'user' | |
| 94 | - | ) | |
| 95 | - | OR | |
| 96 | - | -- Items from followed projects | |
| 97 | - | p.id IN ( | |
| 98 | - | SELECT target_id FROM follows | |
| 99 | - | WHERE follower_id = $1 AND target_type = 'project' | |
| 100 | - | ) | |
| 101 | - | OR | |
| 102 | - | -- Items with followed tags | |
| 103 | - | i.id IN ( | |
| 104 | - | SELECT it.item_id FROM item_tags it | |
| 105 | - | WHERE it.tag_id IN ( | |
| 106 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' | |
| 107 | - | ) | |
| 108 | - | ) | |
| 86 | + | WITH followed_item_ids AS ( | |
| 87 | + | SELECT i.id FROM items i | |
| 88 | + | JOIN projects p ON i.project_id = p.id | |
| 89 | + | 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 | + | UNION | |
| 92 | + | SELECT i.id FROM items i | |
| 93 | + | JOIN projects p ON i.project_id = p.id | |
| 94 | + | 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 | |
| 96 | + | UNION | |
| 97 | + | SELECT i.id FROM items i | |
| 98 | + | JOIN projects p ON i.project_id = p.id | |
| 99 | + | JOIN item_tags it ON it.item_id = i.id | |
| 100 | + | 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 | |
| 109 | 102 | ) | |
| 103 | + | SELECT i.* FROM items i | |
| 104 | + | JOIN followed_item_ids fi ON fi.id = i.id | |
| 110 | 105 | ORDER BY i.created_at DESC | |
| 111 | 106 | LIMIT 50 | |
| 112 | 107 | "#, | |
| @@ -129,7 +124,24 @@ pub async fn get_followed_feed_items( | |||
| 129 | 124 | ) -> Result<Vec<super::models::DbDiscoverItemRow>> { | |
| 130 | 125 | let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>( | |
| 131 | 126 | r#" | |
| 132 | - | SELECT DISTINCT | |
| 127 | + | WITH followed_item_ids AS ( | |
| 128 | + | SELECT i.id FROM items i | |
| 129 | + | JOIN projects p ON i.project_id = p.id | |
| 130 | + | 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 | |
| 132 | + | UNION | |
| 133 | + | SELECT i.id FROM items i | |
| 134 | + | JOIN projects p ON i.project_id = p.id | |
| 135 | + | 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 | |
| 137 | + | UNION | |
| 138 | + | SELECT i.id FROM items i | |
| 139 | + | JOIN projects p ON i.project_id = p.id | |
| 140 | + | JOIN item_tags it ON it.item_id = i.id | |
| 141 | + | 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 | |
| 143 | + | ) | |
| 144 | + | SELECT | |
| 133 | 145 | i.id, | |
| 134 | 146 | i.title, | |
| 135 | 147 | i.description, | |
| @@ -145,25 +157,11 @@ pub async fn get_followed_feed_items( | |||
| 145 | 157 | NULL::real as match_score, | |
| 146 | 158 | i.ai_tier | |
| 147 | 159 | FROM items i | |
| 160 | + | JOIN followed_item_ids fi ON fi.id = i.id | |
| 148 | 161 | JOIN projects p ON i.project_id = p.id | |
| 149 | 162 | JOIN users u ON p.user_id = u.id | |
| 150 | 163 | LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true | |
| 151 | 164 | LEFT JOIN tags pt ON pt.id = pit.tag_id | |
| 152 | - | WHERE i.is_public = true AND p.is_public = true | |
| 153 | - | AND ( | |
| 154 | - | p.user_id IN ( | |
| 155 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user' | |
| 156 | - | ) | |
| 157 | - | OR p.id IN ( | |
| 158 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project' | |
| 159 | - | ) | |
| 160 | - | OR i.id IN ( | |
| 161 | - | SELECT it.item_id FROM item_tags it | |
| 162 | - | WHERE it.tag_id IN ( | |
| 163 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' | |
| 164 | - | ) | |
| 165 | - | ) | |
| 166 | - | ) | |
| 167 | 165 | ORDER BY i.created_at DESC | |
| 168 | 166 | LIMIT $2 OFFSET $3 | |
| 169 | 167 | "#, | |
| @@ -185,24 +183,24 @@ pub async fn count_followed_feed_items( | |||
| 185 | 183 | ) -> Result<i64> { | |
| 186 | 184 | let count: i64 = sqlx::query_scalar( | |
| 187 | 185 | r#" | |
| 188 | - | SELECT COUNT(DISTINCT i.id) | |
| 189 | - | FROM items i | |
| 190 | - | JOIN projects p ON i.project_id = p.id | |
| 191 | - | WHERE i.is_public = true AND p.is_public = true | |
| 192 | - | AND ( | |
| 193 | - | p.user_id IN ( | |
| 194 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user' | |
| 195 | - | ) | |
| 196 | - | OR p.id IN ( | |
| 197 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project' | |
| 198 | - | ) | |
| 199 | - | OR i.id IN ( | |
| 200 | - | SELECT it.item_id FROM item_tags it | |
| 201 | - | WHERE it.tag_id IN ( | |
| 202 | - | SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' | |
| 203 | - | ) | |
| 204 | - | ) | |
| 186 | + | WITH followed_item_ids AS ( | |
| 187 | + | SELECT i.id FROM items i | |
| 188 | + | JOIN projects p ON i.project_id = p.id | |
| 189 | + | 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 | |
| 191 | + | UNION | |
| 192 | + | SELECT i.id FROM items i | |
| 193 | + | JOIN projects p ON i.project_id = p.id | |
| 194 | + | 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 | |
| 196 | + | UNION | |
| 197 | + | SELECT i.id FROM items i | |
| 198 | + | JOIN projects p ON i.project_id = p.id | |
| 199 | + | JOIN item_tags it ON it.item_id = i.id | |
| 200 | + | 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 | |
| 205 | 202 | ) | |
| 203 | + | SELECT COUNT(*) FROM followed_item_ids | |
| 206 | 204 | "#, | |
| 207 | 205 | ) | |
| 208 | 206 | .bind(follower_id) |
| @@ -110,6 +110,27 @@ pub async fn get_latest_release( | |||
| 110 | 110 | Ok(release) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | /// Fetch a single release scoped to its app — a direct indexed `(id, app_id)` | |
| 114 | + | /// lookup instead of listing the app's whole release set and scanning it in Rust | |
| 115 | + | /// (ultra-fuzz Run 11 Perf SER-1). Returns None if the release doesn't exist or | |
| 116 | + | /// doesn't belong to the app. | |
| 117 | + | #[tracing::instrument(skip_all)] | |
| 118 | + | pub async fn get_release( | |
| 119 | + | pool: &PgPool, | |
| 120 | + | app_id: SyncAppId, | |
| 121 | + | release_id: OtaReleaseId, | |
| 122 | + | ) -> Result<Option<DbOtaRelease>> { | |
| 123 | + | let release = sqlx::query_as::<_, DbOtaRelease>( | |
| 124 | + | "SELECT * FROM ota_releases WHERE id = $1 AND app_id = $2", | |
| 125 | + | ) | |
| 126 | + | .bind(release_id) | |
| 127 | + | .bind(app_id) | |
| 128 | + | .fetch_optional(pool) | |
| 129 | + | .await?; | |
| 130 | + | ||
| 131 | + | Ok(release) | |
| 132 | + | } | |
| 133 | + | ||
| 113 | 134 | /// Delete a release (cascades to artifacts). | |
| 114 | 135 | #[tracing::instrument(skip_all)] | |
| 115 | 136 | pub async fn delete_release(pool: &PgPool, release_id: OtaReleaseId) -> Result<bool> { |
| @@ -280,11 +280,9 @@ async fn upload_artifact( | |||
| 280 | 280 | return Err(AppError::BadRequest("file_size must be positive".to_string())); | |
| 281 | 281 | } | |
| 282 | 282 | ||
| 283 | - | // Verify the release belongs to this app | |
| 284 | - | let releases = db::ota::list_releases(&state.db, app_id).await?; | |
| 285 | - | let release = releases | |
| 286 | - | .iter() | |
| 287 | - | .find(|r| r.id == release_id) | |
| 283 | + | // Verify the release belongs to this app (scoped lookup, not a full list scan) | |
| 284 | + | let release = db::ota::get_release(&state.db, app_id, release_id) | |
| 285 | + | .await? | |
| 288 | 286 | .ok_or(AppError::NotFound)?; | |
| 289 | 287 | ||
| 290 | 288 | let s3_key = crate::storage::S3Client::generate_ota_artifact_key( | |
| @@ -390,9 +388,11 @@ async fn artifact_download( | |||
| 390 | 388 | .await? | |
| 391 | 389 | .ok_or(AppError::NotFound)?; | |
| 392 | 390 | ||
| 393 | - | // Verify release belongs to this app | |
| 394 | - | let releases = db::ota::list_releases(&state.db, app.id).await?; | |
| 395 | - | if !releases.iter().any(|r| r.id == release_id) { | |
| 391 | + | // Verify release belongs to this app (scoped lookup, not a full list scan) | |
| 392 | + | if db::ota::get_release(&state.db, app.id, release_id) | |
| 393 | + | .await? | |
| 394 | + | .is_none() | |
| 395 | + | { | |
| 396 | 396 | return Err(AppError::NotFound); | |
| 397 | 397 | } | |
| 398 | 398 |
| @@ -72,15 +72,16 @@ pub(super) async fn dashboard( | |||
| 72 | 72 | ) -> Result<impl IntoResponse> { | |
| 73 | 73 | let csrf_token = get_csrf_token(&session).await; | |
| 74 | 74 | ||
| 75 | - | let db_user = db::users::get_user_by_id(&state.db, session_user.id) | |
| 76 | - | .await? | |
| 77 | - | .ok_or(AppError::NotFound)?; | |
| 78 | - | ||
| 79 | - | let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; | |
| 80 | - | ||
| 81 | - | // Get transactions where user is buyer or seller | |
| 82 | - | let incoming_txs = db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?; | |
| 83 | - | let outgoing_txs = db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)).await?; | |
| 75 | + | // These four reads are independent — run them concurrently rather than in | |
| 76 | + | // series so the dashboard pays one round-trip's latency, not four (Run 11 | |
| 77 | + | // Perf MOD tail; revisits Run 10 M6 now that the heavy per-render work is gone). | |
| 78 | + | let (db_user, db_projects, incoming_txs, outgoing_txs) = tokio::try_join!( | |
| 79 | + | db::users::get_user_by_id(&state.db, session_user.id), | |
| 80 | + | db::projects::get_projects_by_user(&state.db, session_user.id), | |
| 81 | + | db::transactions::get_transactions_by_seller(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)), | |
| 82 | + | db::transactions::get_transactions_by_buyer(&state.db, session_user.id, Some(DASHBOARD_TRANSACTION_LIMIT)), | |
| 83 | + | )?; | |
| 84 | + | let db_user = db_user.ok_or(AppError::NotFound)?; | |
| 84 | 85 | ||
| 85 | 86 | let user = User::from(&db_user); | |
| 86 | 87 | let transactions = super::collect_transactions(incoming_txs, outgoing_txs); |
| @@ -61,6 +61,7 @@ pub fn dashboard_routes() -> CsrfRouter<AppState> { | |||
| 61 | 61 | .route_get("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys)) | |
| 62 | 62 | .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support)) | |
| 63 | 63 | .route_get("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts)) | |
| 64 | + | .route_get("/dashboard/tabs/payout-summary", get(tabs::dashboard_tab_payout_summary)) | |
| 64 | 65 | .route_get("/dashboard/transactions", get(tabs::dashboard_transactions)) | |
| 65 | 66 | .route_get("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 66 | 67 | .route_get("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) |
| @@ -10,7 +10,8 @@ pub(super) use item::{ | |||
| 10 | 10 | pub(super) use user::{ | |
| 11 | 11 | dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_contacts, | |
| 12 | 12 | dashboard_tab_creator, dashboard_tab_details, dashboard_tab_forums, | |
| 13 | - | dashboard_tab_media, dashboard_tab_payments, dashboard_tab_profile, | |
| 13 | + | dashboard_tab_media, dashboard_tab_payments, dashboard_tab_payout_summary, | |
| 14 | + | dashboard_tab_profile, | |
| 14 | 15 | dashboard_tab_projects, dashboard_tab_settings, dashboard_tab_ssh_keys, | |
| 15 | 16 | dashboard_tab_support, dashboard_tab_synckit, dashboard_transactions, | |
| 16 | 17 | regenerate_feed_url, |
| @@ -11,7 +11,8 @@ pub(in crate::routes::pages::dashboard) use integrations::{ | |||
| 11 | 11 | dashboard_tab_forums, dashboard_tab_media, dashboard_tab_synckit, | |
| 12 | 12 | }; | |
| 13 | 13 | pub(in crate::routes::pages::dashboard) use payments::{ | |
| 14 | - | dashboard_tab_contacts, dashboard_tab_payments, dashboard_transactions, | |
| 14 | + | dashboard_tab_contacts, dashboard_tab_payments, dashboard_tab_payout_summary, | |
| 15 | + | dashboard_transactions, | |
| 15 | 16 | }; | |
| 16 | 17 | ||
| 17 | 18 | use axum::extract::State; |
| @@ -26,25 +26,6 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( | |||
| 26 | 26 | ||
| 27 | 27 | let user = User::from(&db_user); | |
| 28 | 28 | ||
| 29 | - | // Fetch Stripe balance for payout summary | |
| 30 | - | let payout_summary = match (&state.stripe, &user.stripe_account_id) { | |
| 31 | - | (Some(stripe), Some(account_id)) => { | |
| 32 | - | match stripe.get_balance(account_id).await { | |
| 33 | - | Ok(balance) => { | |
| 34 | - | Some(PayoutSummary { | |
| 35 | - | available: helpers::format_revenue(balance.available_cents), | |
| 36 | - | pending: helpers::format_revenue(balance.pending_cents), | |
| 37 | - | }) | |
| 38 | - | } | |
| 39 | - | Err(e) => { | |
| 40 | - | tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary"); | |
| 41 | - | None | |
| 42 | - | } | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | _ => None, | |
| 46 | - | }; | |
| 47 | - | ||
| 48 | 29 | let incoming_txs = db::transactions::get_transactions_by_seller( | |
| 49 | 30 | &state.db, | |
| 50 | 31 | session_user.id, | |
| @@ -81,7 +62,6 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( | |||
| 81 | 62 | ||
| 82 | 63 | Ok(UserPaymentsTabTemplate { | |
| 83 | 64 | user, | |
| 84 | - | payout_summary, | |
| 85 | 65 | transactions, | |
| 86 | 66 | tips_received, | |
| 87 | 67 | tips_total: helpers::format_revenue(tips_total_cents), | |
| @@ -93,6 +73,39 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments( | |||
| 93 | 73 | }) | |
| 94 | 74 | } | |
| 95 | 75 | ||
| 76 | + | /// Render the HTMX partial for the Stripe payout summary (lazy-loaded in the | |
| 77 | + | /// Payments tab). Split out of `dashboard_tab_payments` so the tab renders | |
| 78 | + | /// instantly instead of blocking on the Stripe balance round-trip (Run 11 Perf SER-2). | |
| 79 | + | #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payout_summary")] | |
| 80 | + | pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payout_summary( | |
| 81 | + | State(state): State<AppState>, | |
| 82 | + | AuthUser(session_user): AuthUser, | |
| 83 | + | ) -> Result<impl IntoResponse> { | |
| 84 | + | let db_user = db::users::get_user_by_id(&state.db, session_user.id) | |
| 85 | + | .await? | |
| 86 | + | .ok_or(crate::error::AppError::NotFound)?; | |
| 87 | + | let user = User::from(&db_user); | |
| 88 | + | ||
| 89 | + | let payout_summary = match (&state.stripe, &user.stripe_account_id) { | |
| 90 | + | (Some(stripe), Some(account_id)) => match stripe.get_balance(account_id).await { | |
| 91 | + | Ok(balance) => Some(PayoutSummary { | |
| 92 | + | available: helpers::format_revenue(balance.available_cents), | |
| 93 | + | pending: helpers::format_revenue(balance.pending_cents), | |
| 94 | + | }), | |
| 95 | + | Err(e) => { | |
| 96 | + | tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary"); | |
| 97 | + | None | |
| 98 | + | } | |
| 99 | + | }, | |
| 100 | + | _ => None, | |
| 101 | + | }; | |
| 102 | + | ||
| 103 | + | Ok(PayoutSummaryPartialTemplate { | |
| 104 | + | payout_summary, | |
| 105 | + | stripe_payouts_enabled: user.stripe_payouts_enabled, | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 96 | 109 | /// Render the HTMX partial for the buyer contacts section (lazy-loaded in Payments tab). | |
| 97 | 110 | #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_contacts")] | |
| 98 | 111 | pub(in crate::routes::pages::dashboard) async fn dashboard_tab_contacts( |
| @@ -147,7 +147,14 @@ async fn claim_free_cart_items( | |||
| 147 | 147 | if items.is_empty() { | |
| 148 | 148 | return Ok(()); | |
| 149 | 149 | } | |
| 150 | + | // Claim every free item in ONE transaction instead of a begin/commit per item | |
| 151 | + | // (Run 11 Perf MOD tail) — collapses N round-trips to one and makes the cart | |
| 152 | + | // claim atomic (all free items or none). Post-commit side effects (bundle | |
| 153 | + | // grants, license-key minting) run after, only for the newly-claimed items. | |
| 150 | 154 | let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(items.len()); | |
| 155 | + | let mut claimed_items: Vec<&db::cart::CartItem> = Vec::with_capacity(items.len()); | |
| 156 | + | ||
| 157 | + | let mut tx = state.db.begin().await.context("begin free-claim transaction")?; | |
| 151 | 158 | for item in items { | |
| 152 | 159 | let claim = db::transactions::ClaimParams { | |
| 153 | 160 | buyer_id: user_id, | |
| @@ -159,7 +166,6 @@ async fn claim_free_cart_items( | |||
| 159 | 166 | parent_transaction_id: None, | |
| 160 | 167 | }; | |
| 161 | 168 | ||
| 162 | - | let mut tx = state.db.begin().await.context("begin free-claim transaction")?; | |
| 163 | 169 | let claimed = db::transactions::claim_free_item(&mut *tx, &claim) | |
| 164 | 170 | .await | |
| 165 | 171 | .context("claim free item")?; | |
| @@ -167,23 +173,23 @@ async fn claim_free_cart_items( | |||
| 167 | 173 | db::items::increment_sales_count(&mut *tx, item.item_id) | |
| 168 | 174 | .await | |
| 169 | 175 | .context("increment sales count")?; | |
| 176 | + | claimed_items.push(*item); | |
| 170 | 177 | } | |
| 171 | - | tx.commit().await.context("commit free-claim transaction")?; | |
| 178 | + | to_remove.push(item.item_id); | |
| 179 | + | } | |
| 180 | + | tx.commit().await.context("commit free-claim transaction")?; | |
| 172 | 181 | ||
| 173 | - | if claimed { | |
| 174 | - | if item.item_type == "bundle" { | |
| 175 | - | grant_bundle_items(state, item.item_id, user_id, seller_id, None).await; | |
| 176 | - | } | |
| 177 | - | if item.enable_license_keys { | |
| 178 | - | let key_code = helpers::generate_key_code(); | |
| 179 | - | db::license_keys::create_license_key( | |
| 180 | - | &state.db, item.item_id, user_id, None, &key_code, | |
| 181 | - | item.default_max_activations, | |
| 182 | - | ).await.ok(); | |
| 183 | - | } | |
| 182 | + | for item in claimed_items { | |
| 183 | + | if item.item_type == "bundle" { | |
| 184 | + | grant_bundle_items(state, item.item_id, user_id, seller_id, None).await; | |
| 185 | + | } | |
| 186 | + | if item.enable_license_keys { | |
| 187 | + | let key_code = helpers::generate_key_code(); | |
| 188 | + | db::license_keys::create_license_key( | |
| 189 | + | &state.db, item.item_id, user_id, None, &key_code, | |
| 190 | + | item.default_max_activations, | |
| 191 | + | ).await.ok(); | |
| 184 | 192 | } | |
| 185 | - | ||
| 186 | - | to_remove.push(item.item_id); | |
| 187 | 193 | } | |
| 188 | 194 | db::cart::remove_from_cart_bulk(&state.db, user_id, &to_remove).await.ok(); | |
| 189 | 195 | Ok(()) |
| @@ -196,6 +196,7 @@ impl_into_response!( | |||
| 196 | 196 | ProjectAnalyticsTabTemplate, | |
| 197 | 197 | UserAnalyticsTabTemplate, | |
| 198 | 198 | BuyerContactsPartialTemplate, | |
| 199 | + | PayoutSummaryPartialTemplate, | |
| 199 | 200 | ProjectSettingsTabTemplate, | |
| 200 | 201 | ProjectCodeTabTemplate, | |
| 201 | 202 | ProjectBlogTabTemplate, |
| @@ -262,7 +262,6 @@ pub struct CustomLinkWithId { | |||
| 262 | 262 | #[template(path = "partials/tabs/user_payments.html")] | |
| 263 | 263 | pub struct UserPaymentsTabTemplate { | |
| 264 | 264 | pub user: User, | |
| 265 | - | pub payout_summary: Option<PayoutSummary>, | |
| 266 | 265 | pub transactions: Vec<Transaction>, | |
| 267 | 266 | pub tips_received: Vec<TipReceived>, | |
| 268 | 267 | pub tips_total: String, | |
| @@ -620,6 +619,15 @@ pub struct BuyerContactsPartialTemplate { | |||
| 620 | 619 | pub contacts: Vec<BuyerContact>, | |
| 621 | 620 | } | |
| 622 | 621 | ||
| 622 | + | /// Stripe payout-summary card, HTMX-loaded into the Payments tab so the tab | |
| 623 | + | /// render never blocks on the Stripe balance round-trip (ultra-fuzz Run 11 Perf SER-2). | |
| 624 | + | #[derive(Template)] | |
| 625 | + | #[template(path = "partials/tabs/payout_summary.html")] | |
| 626 | + | pub struct PayoutSummaryPartialTemplate { | |
| 627 | + | pub payout_summary: Option<PayoutSummary>, | |
| 628 | + | pub stripe_payouts_enabled: bool, | |
| 629 | + | } | |
| 630 | + | ||
| 623 | 631 | /// SSH keys list partial for HTMX updates. | |
| 624 | 632 | #[derive(Template)] | |
| 625 | 633 | #[template(path = "partials/ssh_keys_list.html")] |
| @@ -0,0 +1,44 @@ | |||
| 1 | + | {% if let Some(summary) = payout_summary %} | |
| 2 | + | <div class="stripe-payout-summary card-muted mb-5"> | |
| 3 | + | <div class="stack-row stack-row--top"> | |
| 4 | + | <div> | |
| 5 | + | <h3 class="card-h">Payout Summary</h3> | |
| 6 | + | <p class="m-0 text-sm muted"> | |
| 7 | + | Payouts are processed automatically via Stripe Connect. | |
| 8 | + | </p> | |
| 9 | + | </div> | |
| 10 | + | <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener"> | |
| 11 | + | <button class="btn-secondary text-sm">View in Stripe</button> | |
| 12 | + | </a> | |
| 13 | + | </div> | |
| 14 | + | ||
| 15 | + | <div class="payout-stats-grid"> | |
| 16 | + | <div> | |
| 17 | + | <div class="text-xs dimmed">Available Balance</div> | |
| 18 | + | <div class="amount-big">{{ summary.available }}</div> | |
| 19 | + | </div> | |
| 20 | + | <div> | |
| 21 | + | <div class="text-xs dimmed">Pending</div> | |
| 22 | + | <div class="amount-big">{{ summary.pending }}</div> | |
| 23 | + | </div> | |
| 24 | + | </div> | |
| 25 | + | ||
| 26 | + | {% if !stripe_payouts_enabled %} | |
| 27 | + | <div class="payouts-disabled-notice"> | |
| 28 | + | Payouts are not yet enabled. Complete your Stripe account setup above. | |
| 29 | + | </div> | |
| 30 | + | {% endif %} | |
| 31 | + | </div> | |
| 32 | + | {% else %} | |
| 33 | + | <div class="card-muted mb-5"> | |
| 34 | + | <div class="stack-row"> | |
| 35 | + | <div> | |
| 36 | + | <h3 class="card-h">Payout Summary</h3> | |
| 37 | + | <p class="m-0 text-sm dimmed">Unable to load balance. Check your Stripe dashboard for details.</p> | |
| 38 | + | </div> | |
| 39 | + | <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener"> | |
| 40 | + | <button class="btn-secondary text-sm">View in Stripe</button> | |
| 41 | + | </a> | |
| 42 | + | </div> | |
| 43 | + | </div> | |
| 44 | + | {% endif %} |
| @@ -68,47 +68,13 @@ | |||
| 68 | 68 | ||
| 69 | 69 | <div class="section-group-label">Payouts</div> | |
| 70 | 70 | ||
| 71 | - | {% if let Some(summary) = payout_summary %} | |
| 72 | - | <div class="stripe-payout-summary card-muted mb-5"> | |
| 73 | - | <div class="stack-row stack-row--top"> | |
| 74 | - | <div> | |
| 75 | - | <h3 class="card-h">Payout Summary</h3> | |
| 76 | - | <p class="m-0 text-sm muted"> | |
| 77 | - | Payouts are processed automatically via Stripe Connect. | |
| 78 | - | </p> | |
| 79 | - | </div> | |
| 80 | - | <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener"> | |
| 81 | - | <button class="btn-secondary text-sm">View in Stripe</button> | |
| 82 | - | </a> | |
| 83 | - | </div> | |
| 84 | - | ||
| 85 | - | <div class="payout-stats-grid"> | |
| 86 | - | <div> | |
| 87 | - | <div class="text-xs dimmed">Available Balance</div> | |
| 88 | - | <div class="amount-big">{{ summary.available }}</div> | |
| 89 | - | </div> | |
| 90 | - | <div> | |
| 91 | - | <div class="text-xs dimmed">Pending</div> | |
| 92 | - | <div class="amount-big">{{ summary.pending }}</div> | |
| 93 | - | </div> | |
| 94 | - | </div> | |
| 95 | - | ||
| 96 | - | {% if !user.stripe_payouts_enabled %} | |
| 97 | - | <div class="payouts-disabled-notice"> | |
| 98 | - | Payouts are not yet enabled. Complete your Stripe account setup above. | |
| 99 | - | </div> | |
| 100 | - | {% endif %} | |
| 101 | - | </div> | |
| 102 | - | {% else if user.stripe_account_id.is_some() %} | |
| 103 | - | <div class="card-muted mb-5"> | |
| 104 | - | <div class="stack-row"> | |
| 105 | - | <div> | |
| 106 | - | <h3 class="card-h">Payout Summary</h3> | |
| 107 | - | <p class="m-0 text-sm dimmed">Unable to load balance. Check your Stripe dashboard for details.</p> | |
| 108 | - | </div> | |
| 109 | - | <a href="https://dashboard.stripe.com/payouts" target="_blank" rel="noopener"> | |
| 110 | - | <button class="btn-secondary text-sm">View in Stripe</button> | |
| 111 | - | </a> | |
| 71 | + | {% if user.stripe_account_id.is_some() %} | |
| 72 | + | <!-- Stripe balance is lazy-loaded so the tab render never blocks on the | |
| 73 | + | Stripe round-trip (ultra-fuzz Run 11 Perf SER-2). --> | |
| 74 | + | <div hx-get="/dashboard/tabs/payout-summary" hx-trigger="revealed" hx-swap="innerHTML" id="payout-summary-section"> | |
| 75 | + | <div class="card-muted mb-5"> | |
| 76 | + | <h3 class="card-h">Payout Summary</h3> | |
| 77 | + | <p class="m-0 text-sm muted">Loading balance.</p> | |
| 112 | 78 | </div> | |
| 113 | 79 | </div> | |
| 114 | 80 | {% endif %} |
| @@ -44,6 +44,45 @@ async fn make_discoverable_item( | |||
| 44 | 44 | (setup.user_id.to_string(), setup.item_id) | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | /// The denormalized `projects.item_count` (read by discover instead of a | |
| 48 | + | /// COUNT-over-the-catalog) is maintained by a trigger on `items`. Pin that it | |
| 49 | + | /// tracks the active-item lifecycle exactly: list/unlist/soft-delete each move | |
| 50 | + | /// the cached count (ultra-fuzz Run 11 Perf SER-3). | |
| 51 | + | async fn project_item_count(h: &TestHarness, project_id: &str) -> i32 { | |
| 52 | + | sqlx::query_scalar::<_, i32>("SELECT item_count FROM projects WHERE id = $1::uuid") | |
| 53 | + | .bind(project_id) | |
| 54 | + | .fetch_one(&h.db) | |
| 55 | + | .await | |
| 56 | + | .expect("read item_count") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[tokio::test] | |
| 60 | + | async fn project_item_count_tracks_active_item_lifecycle() { | |
| 61 | + | let mut h = TestHarness::new().await; | |
| 62 | + | let setup = h.create_creator_with_item("counttrack", "audio", 1000).await; | |
| 63 | + | let pid = setup.project_id.to_string(); | |
| 64 | + | ||
| 65 | + | // Active: public + listed + not deleted. | |
| 66 | + | sqlx::query("UPDATE items SET is_public = true, listed = true, deleted_at = NULL WHERE id = $1::uuid") | |
| 67 | + | .bind(&setup.item_id).execute(&h.db).await.expect("activate item"); | |
| 68 | + | assert_eq!(project_item_count(&h, &pid).await, 1, "active item counts"); | |
| 69 | + | ||
| 70 | + | // Unlist -> drops out of the active set. | |
| 71 | + | sqlx::query("UPDATE items SET listed = false WHERE id = $1::uuid") | |
| 72 | + | .bind(&setup.item_id).execute(&h.db).await.expect("unlist"); | |
| 73 | + | assert_eq!(project_item_count(&h, &pid).await, 0, "unlisted item not counted"); | |
| 74 | + | ||
| 75 | + | // Relist -> back in. | |
| 76 | + | sqlx::query("UPDATE items SET listed = true WHERE id = $1::uuid") | |
| 77 | + | .bind(&setup.item_id).execute(&h.db).await.expect("relist"); | |
| 78 | + | assert_eq!(project_item_count(&h, &pid).await, 1, "relisted item counts again"); | |
| 79 | + | ||
| 80 | + | // Soft-delete -> drops out. | |
| 81 | + | sqlx::query("UPDATE items SET deleted_at = NOW() WHERE id = $1::uuid") | |
| 82 | + | .bind(&setup.item_id).execute(&h.db).await.expect("soft delete"); | |
| 83 | + | assert_eq!(project_item_count(&h, &pid).await, 0, "soft-deleted item not counted"); | |
| 84 | + | } | |
| 85 | + | ||
| 47 | 86 | #[tokio::test] | |
| 48 | 87 | async fn discover_page_renders_for_anonymous_visitor() { | |
| 49 | 88 | let mut h = TestHarness::new().await; |