max / makenotwork
35 files changed,
+619 insertions,
-428 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.16" | |
| 3 | + | version = "0.3.17" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -96,6 +96,10 @@ pub const SYNCKIT_SYNC_RATE_LIMIT_BURST: u32 = 30; | |||
| 96 | 96 | pub const TWO_FACTOR_RATE_LIMIT_MS: u64 = 500; | |
| 97 | 97 | pub const TWO_FACTOR_RATE_LIMIT_BURST: u32 = 5; | |
| 98 | 98 | ||
| 99 | + | // Dashboard tab reads: generous but bounded (5/sec, burst 20) | |
| 100 | + | pub const DASHBOARD_READ_RATE_LIMIT_MS: u64 = 200; | |
| 101 | + | pub const DASHBOARD_READ_RATE_LIMIT_BURST: u32 = 20; | |
| 102 | + | ||
| 99 | 103 | // -- Pagination -- | |
| 100 | 104 | pub const DISCOVER_PAGE_SIZE: u32 = 25; | |
| 101 | 105 | pub const FEED_PAGE_SIZE: u32 = 25; |
| @@ -114,6 +114,7 @@ pub async fn get_collections_by_user( | |||
| 114 | 114 | WHERE c.user_id = $1 | |
| 115 | 115 | GROUP BY c.id | |
| 116 | 116 | ORDER BY c.updated_at DESC | |
| 117 | + | LIMIT 500 | |
| 117 | 118 | "#, | |
| 118 | 119 | ) | |
| 119 | 120 | .bind(user_id) |
| @@ -536,3 +536,65 @@ pub async fn check_upload_allowed( | |||
| 536 | 536 | ||
| 537 | 537 | Ok(max_storage) | |
| 538 | 538 | } | |
| 539 | + | ||
| 540 | + | /// Early presign-time check: reject if the user has no tier or is already at/over | |
| 541 | + | /// their storage cap. This prevents generating presigned URLs that would always | |
| 542 | + | /// fail at confirm time. Does NOT check file size (unknown at presign). | |
| 543 | + | pub async fn check_presign_allowed( | |
| 544 | + | pool: &PgPool, | |
| 545 | + | user_id: UserId, | |
| 546 | + | file_type: FileType, | |
| 547 | + | ) -> Result<()> { | |
| 548 | + | // Covers bypass tier checks | |
| 549 | + | if file_type == FileType::Cover { | |
| 550 | + | return Ok(()); | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | let active_tier = get_active_creator_tier(pool, user_id).await?; | |
| 554 | + | let grandfathered = get_grandfathered_until(pool, user_id).await?; | |
| 555 | + | ||
| 556 | + | let effective_tier = match active_tier { | |
| 557 | + | Some(tier) => Some(tier), | |
| 558 | + | None => { | |
| 559 | + | if let Some(until) = grandfathered { | |
| 560 | + | if Utc::now() < until { | |
| 561 | + | Some(CreatorTier::SmallFiles) | |
| 562 | + | } else { | |
| 563 | + | None | |
| 564 | + | } | |
| 565 | + | } else { | |
| 566 | + | None | |
| 567 | + | } | |
| 568 | + | } | |
| 569 | + | }; | |
| 570 | + | ||
| 571 | + | if effective_tier.is_none() { | |
| 572 | + | let in_grace = is_in_grace_period(pool, user_id).await?; | |
| 573 | + | if in_grace { | |
| 574 | + | return Err(AppError::Forbidden); | |
| 575 | + | } | |
| 576 | + | return Err(AppError::BadRequest( | |
| 577 | + | "A creator tier subscription is required to upload files.".to_string(), | |
| 578 | + | )); | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | let tier = effective_tier.unwrap(); | |
| 582 | + | if !tier.allows_file_uploads() { | |
| 583 | + | return Err(AppError::BadRequest( | |
| 584 | + | "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(), | |
| 585 | + | )); | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | // Reject if already at/over storage cap | |
| 589 | + | let used = get_storage_used(pool, user_id).await?; | |
| 590 | + | let max_storage = tier.max_storage_bytes(); | |
| 591 | + | if used >= max_storage { | |
| 592 | + | return Err(AppError::BadRequest(format!( | |
| 593 | + | "You've used {} of {} storage. Delete files or upgrade your tier.", | |
| 594 | + | format_bytes(used), | |
| 595 | + | format_bytes(max_storage), | |
| 596 | + | ))); | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | Ok(()) | |
| 600 | + | } |
| @@ -23,6 +23,71 @@ pub struct DiscoverFilters<'a> { | |||
| 23 | 23 | pub sort_by: Option<DiscoverSort>, | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback). | |
| 27 | + | // The ILIKE escapes \, %, _ in the search term to prevent injection into LIKE patterns. | |
| 28 | + | ||
| 29 | + | const ITEM_SEARCH_CLAUSE: &str = r#" AND ( | |
| 30 | + | i.title % $1 | |
| 31 | + | OR COALESCE(i.description, '') % $1 | |
| 32 | + | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 33 | + | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 34 | + | )"#; | |
| 35 | + | ||
| 36 | + | const PROJECT_SEARCH_CLAUSE: &str = r#" AND ( | |
| 37 | + | p.title % $1 | |
| 38 | + | OR COALESCE(p.description, '') % $1 | |
| 39 | + | OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 40 | + | OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 41 | + | )"#; | |
| 42 | + | ||
| 43 | + | /// Append discover-item filter clauses to a dynamic query. | |
| 44 | + | /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label. | |
| 45 | + | fn append_item_discover_filters(query: &mut String, filters: &DiscoverFilters<'_>, has_search: bool) { | |
| 46 | + | if has_search { | |
| 47 | + | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 48 | + | } | |
| 49 | + | if filters.item_type.is_some() { | |
| 50 | + | query.push_str(" AND i.item_type = $2"); | |
| 51 | + | } | |
| 52 | + | if filters.min_price.is_some() { | |
| 53 | + | query.push_str(" AND i.price_cents >= $3"); | |
| 54 | + | } | |
| 55 | + | if filters.max_price.is_some() { | |
| 56 | + | query.push_str(" AND i.price_cents <= $4"); | |
| 57 | + | } | |
| 58 | + | if filters.tag.is_some() { | |
| 59 | + | query.push_str( | |
| 60 | + | r#" AND EXISTS ( | |
| 61 | + | SELECT 1 FROM item_tags it2 | |
| 62 | + | JOIN tags t2 ON t2.id = it2.tag_id | |
| 63 | + | WHERE it2.item_id = i.id | |
| 64 | + | AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5)) | |
| 65 | + | )"#, | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | if filters.label.is_some() { | |
| 69 | + | query.push_str( | |
| 70 | + | r#" AND EXISTS ( | |
| 71 | + | SELECT 1 FROM project_labels pl | |
| 72 | + | JOIN labels l ON l.id = pl.label_id | |
| 73 | + | WHERE pl.project_id = i.project_id AND l.slug = $6 | |
| 74 | + | )"#, | |
| 75 | + | ); | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query. | |
| 80 | + | macro_rules! bind_item_discover_filters { | |
| 81 | + | ($q:expr, $filters:expr, $search_term:expr) => { | |
| 82 | + | $q.bind($search_term.unwrap_or("")) | |
| 83 | + | .bind($filters.item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 84 | + | .bind($filters.min_price.unwrap_or(0)) | |
| 85 | + | .bind($filters.max_price.unwrap_or(i32::MAX)) | |
| 86 | + | .bind($filters.tag.unwrap_or("")) | |
| 87 | + | .bind($filters.label.unwrap_or("")) | |
| 88 | + | }; | |
| 89 | + | } | |
| 90 | + | ||
| 26 | 91 | /// Search/browse public items with optional text search, item_type, tag, and price filters. | |
| 27 | 92 | /// | |
| 28 | 93 | /// Uses PostgreSQL `pg_trgm` for fuzzy text matching. The search strategy: | |
| @@ -101,54 +166,7 @@ pub async fn discover_items( | |||
| 101 | 166 | ) | |
| 102 | 167 | }; | |
| 103 | 168 | ||
| 104 | - | // Add search condition using trigram fuzzy matching + ILIKE fallback | |
| 105 | - | if has_search { | |
| 106 | - | query.push_str( | |
| 107 | - | r#" AND ( | |
| 108 | - | i.title % $1 | |
| 109 | - | OR COALESCE(i.description, '') % $1 | |
| 110 | - | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 111 | - | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 112 | - | )"#, | |
| 113 | - | ); | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // item_type filter (replaces old category filter) | |
| 117 | - | if filters.item_type.is_some() { | |
| 118 | - | query.push_str(" AND i.item_type = $2"); | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // Add price filters | |
| 122 | - | if filters.min_price.is_some() { | |
| 123 | - | query.push_str(" AND i.price_cents >= $3"); | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | if filters.max_price.is_some() { | |
| 127 | - | query.push_str(" AND i.price_cents <= $4"); | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | // Tag filter: match slug, including children of parent tags | |
| 131 | - | if filters.tag.is_some() { | |
| 132 | - | query.push_str( | |
| 133 | - | r#" AND EXISTS ( | |
| 134 | - | SELECT 1 FROM item_tags it2 | |
| 135 | - | JOIN tags t2 ON t2.id = it2.tag_id | |
| 136 | - | WHERE it2.item_id = i.id | |
| 137 | - | AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5)) | |
| 138 | - | )"#, | |
| 139 | - | ); | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Label filter: match by label slug on the parent project | |
| 143 | - | if filters.label.is_some() { | |
| 144 | - | query.push_str( | |
| 145 | - | r#" AND EXISTS ( | |
| 146 | - | SELECT 1 FROM project_labels pl | |
| 147 | - | JOIN labels l ON l.id = pl.label_id | |
| 148 | - | WHERE pl.project_id = i.project_id AND l.slug = $6 | |
| 149 | - | )"#, | |
| 150 | - | ); | |
| 151 | - | } | |
| 169 | + | append_item_discover_filters(&mut query, filters, has_search); | |
| 152 | 170 | ||
| 153 | 171 | // Determine ordering | |
| 154 | 172 | let order = if has_search && (filters.sort_by.is_none() || filters.sort_by == Some(DiscoverSort::Newest)) { | |
| @@ -164,17 +182,15 @@ pub async fn discover_items( | |||
| 164 | 182 | ||
| 165 | 183 | query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order)); | |
| 166 | 184 | ||
| 167 | - | let items = sqlx::query_as::<_, DbDiscoverItemRow>(&query) | |
| 168 | - | .bind(search_term.unwrap_or("")) | |
| 169 | - | .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 170 | - | .bind(filters.min_price.unwrap_or(0)) | |
| 171 | - | .bind(filters.max_price.unwrap_or(i32::MAX)) | |
| 172 | - | .bind(filters.tag.unwrap_or("")) | |
| 173 | - | .bind(filters.label.unwrap_or("")) | |
| 174 | - | .bind(limit) | |
| 175 | - | .bind(offset) | |
| 176 | - | .fetch_all(pool) | |
| 177 | - | .await?; | |
| 185 | + | let items = bind_item_discover_filters!( | |
| 186 | + | sqlx::query_as::<_, DbDiscoverItemRow>(&query), | |
| 187 | + | filters, | |
| 188 | + | search_term | |
| 189 | + | ) | |
| 190 | + | .bind(limit) | |
| 191 | + | .bind(offset) | |
| 192 | + | .fetch_all(pool) | |
| 193 | + | .await?; | |
| 178 | 194 | ||
| 179 | 195 | Ok(items) | |
| 180 | 196 | } | |
| @@ -196,59 +212,15 @@ pub async fn count_discover_items( | |||
| 196 | 212 | "#, | |
| 197 | 213 | ); | |
| 198 | 214 | ||
| 199 | - | if has_search { | |
| 200 | - | query.push_str( | |
| 201 | - | r#" AND ( | |
| 202 | - | i.title % $1 | |
| 203 | - | OR COALESCE(i.description, '') % $1 | |
| 204 | - | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 205 | - | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 206 | - | )"#, | |
| 207 | - | ); | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | if filters.item_type.is_some() { | |
| 211 | - | query.push_str(" AND i.item_type = $2"); | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | if filters.min_price.is_some() { | |
| 215 | - | query.push_str(" AND i.price_cents >= $3"); | |
| 216 | - | } | |
| 215 | + | append_item_discover_filters(&mut query, filters, has_search); | |
| 217 | 216 | ||
| 218 | - | if filters.max_price.is_some() { | |
| 219 | - | query.push_str(" AND i.price_cents <= $4"); | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | if filters.tag.is_some() { | |
| 223 | - | query.push_str( | |
| 224 | - | r#" AND EXISTS ( | |
| 225 | - | SELECT 1 FROM item_tags it2 | |
| 226 | - | JOIN tags t2 ON t2.id = it2.tag_id | |
| 227 | - | WHERE it2.item_id = i.id | |
| 228 | - | AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5)) | |
| 229 | - | )"#, | |
| 230 | - | ); | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | if filters.label.is_some() { | |
| 234 | - | query.push_str( | |
| 235 | - | r#" AND EXISTS ( | |
| 236 | - | SELECT 1 FROM project_labels pl | |
| 237 | - | JOIN labels l ON l.id = pl.label_id | |
| 238 | - | WHERE pl.project_id = i.project_id AND l.slug = $6 | |
| 239 | - | )"#, | |
| 240 | - | ); | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | let count: i64 = sqlx::query_scalar(&query) | |
| 244 | - | .bind(search_term.unwrap_or("")) | |
| 245 | - | .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 246 | - | .bind(filters.min_price.unwrap_or(0)) | |
| 247 | - | .bind(filters.max_price.unwrap_or(i32::MAX)) | |
| 248 | - | .bind(filters.tag.unwrap_or("")) | |
| 249 | - | .bind(filters.label.unwrap_or("")) | |
| 250 | - | .fetch_one(pool) | |
| 251 | - | .await?; | |
| 217 | + | let count: i64 = bind_item_discover_filters!( | |
| 218 | + | sqlx::query_scalar(&query), | |
| 219 | + | filters, | |
| 220 | + | search_term | |
| 221 | + | ) | |
| 222 | + | .fetch_one(pool) | |
| 223 | + | .await?; | |
| 252 | 224 | ||
| 253 | 225 | Ok(count) | |
| 254 | 226 | } | |
| @@ -317,14 +289,7 @@ pub async fn discover_projects( | |||
| 317 | 289 | }; | |
| 318 | 290 | ||
| 319 | 291 | if has_search { | |
| 320 | - | query.push_str( | |
| 321 | - | r#" AND ( | |
| 322 | - | p.title % $1 | |
| 323 | - | OR COALESCE(p.description, '') % $1 | |
| 324 | - | OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 325 | - | OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 326 | - | )"#, | |
| 327 | - | ); | |
| 292 | + | query.push_str(PROJECT_SEARCH_CLAUSE); | |
| 328 | 293 | } | |
| 329 | 294 | ||
| 330 | 295 | if category_slug.is_some() { | |
| @@ -382,14 +347,7 @@ pub async fn count_discover_projects( | |||
| 382 | 347 | ); | |
| 383 | 348 | ||
| 384 | 349 | if has_search { | |
| 385 | - | query.push_str( | |
| 386 | - | r#" AND ( | |
| 387 | - | p.title % $1 | |
| 388 | - | OR COALESCE(p.description, '') % $1 | |
| 389 | - | OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 390 | - | OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 391 | - | )"#, | |
| 392 | - | ); | |
| 350 | + | query.push_str(PROJECT_SEARCH_CLAUSE); | |
| 393 | 351 | } | |
| 394 | 352 | ||
| 395 | 353 | if category_slug.is_some() { | |
| @@ -439,14 +397,7 @@ pub async fn get_item_type_counts( | |||
| 439 | 397 | ); | |
| 440 | 398 | ||
| 441 | 399 | if has_search { | |
| 442 | - | query.push_str( | |
| 443 | - | r#" AND ( | |
| 444 | - | i.title % $1 | |
| 445 | - | OR COALESCE(i.description, '') % $1 | |
| 446 | - | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 447 | - | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 448 | - | )"#, | |
| 449 | - | ); | |
| 400 | + | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 450 | 401 | } | |
| 451 | 402 | ||
| 452 | 403 | if tag.is_some() { | |
| @@ -510,14 +461,7 @@ pub async fn get_price_range_counts( | |||
| 510 | 461 | ); | |
| 511 | 462 | ||
| 512 | 463 | if has_search { | |
| 513 | - | query.push_str( | |
| 514 | - | r#" AND ( | |
| 515 | - | i.title % $1 | |
| 516 | - | OR COALESCE(i.description, '') % $1 | |
| 517 | - | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 518 | - | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 519 | - | )"#, | |
| 520 | - | ); | |
| 464 | + | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 521 | 465 | } | |
| 522 | 466 | ||
| 523 | 467 | if item_type.is_some() { |
| @@ -98,6 +98,21 @@ pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>> | |||
| 98 | 98 | Ok(item) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs. | |
| 102 | + | pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> { | |
| 103 | + | if ids.is_empty() { | |
| 104 | + | return Ok(vec![]); | |
| 105 | + | } | |
| 106 | + | let rows: Vec<(ItemId, String)> = sqlx::query_as( | |
| 107 | + | "SELECT id, title FROM items WHERE id = ANY($1)", | |
| 108 | + | ) | |
| 109 | + | .bind(ids) | |
| 110 | + | .fetch_all(pool) | |
| 111 | + | .await?; | |
| 112 | + | ||
| 113 | + | Ok(rows) | |
| 114 | + | } | |
| 115 | + | ||
| 101 | 116 | /// List all items in a project, ordered by sort_order then newest. | |
| 102 | 117 | /// | |
| 103 | 118 | /// Capped at 500 as a safety limit. |
| @@ -58,6 +58,18 @@ pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Op | |||
| 58 | 58 | Ok(key) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | /// Count license keys for an item. | |
| 62 | + | pub async fn count_keys_by_item(pool: &PgPool, item_id: ItemId) -> Result<i64> { | |
| 63 | + | let count: i64 = sqlx::query_scalar( | |
| 64 | + | "SELECT COUNT(*) FROM license_keys WHERE item_id = $1", | |
| 65 | + | ) | |
| 66 | + | .bind(item_id) | |
| 67 | + | .fetch_one(pool) | |
| 68 | + | .await?; | |
| 69 | + | ||
| 70 | + | Ok(count) | |
| 71 | + | } | |
| 72 | + | ||
| 61 | 73 | /// List all license keys for an item, newest first. | |
| 62 | 74 | /// | |
| 63 | 75 | /// Hard-caps at 500 rows to bound memory and response size for the creator |
| @@ -5,6 +5,7 @@ use serde::Serialize; | |||
| 5 | 5 | use sqlx::FromRow; | |
| 6 | 6 | use uuid::Uuid; | |
| 7 | 7 | ||
| 8 | + | use super::enums::CreatorTier; | |
| 8 | 9 | use super::id_types::*; | |
| 9 | 10 | use super::validated_types::*; | |
| 10 | 11 | ||
| @@ -1561,7 +1562,7 @@ pub struct DbCreatorSubscription { | |||
| 1561 | 1562 | /// Stripe customer ID (e.g. `cus_...`). | |
| 1562 | 1563 | pub stripe_customer_id: String, | |
| 1563 | 1564 | /// Creator tier (basic, small_files, big_files, streaming). | |
| 1564 | - | pub tier: String, | |
| 1565 | + | pub tier: CreatorTier, | |
| 1565 | 1566 | /// Subscription status (active, past_due, canceled). | |
| 1566 | 1567 | pub status: String, | |
| 1567 | 1568 | /// Start of current billing period. |
| @@ -349,20 +349,27 @@ pub async fn get_sync_key( | |||
| 349 | 349 | Ok(row) | |
| 350 | 350 | } | |
| 351 | 351 | ||
| 352 | - | /// Get device count and sync log entry count for a sync app. | |
| 353 | - | pub async fn get_sync_app_stats(pool: &PgPool, app_id: SyncAppId) -> Result<(i64, i64)> { | |
| 354 | - | let row: (i64, i64) = sqlx::query_as( | |
| 352 | + | /// Get device count and sync log entry count for all apps owned by a creator. | |
| 353 | + | /// Returns Vec of (app_id, device_count, log_entry_count). Single query replaces N+1 loop. | |
| 354 | + | pub async fn get_sync_app_stats_batch( | |
| 355 | + | pool: &PgPool, | |
| 356 | + | creator_id: UserId, | |
| 357 | + | ) -> Result<Vec<(SyncAppId, i64, i64)>> { | |
| 358 | + | let rows: Vec<(SyncAppId, i64, i64)> = sqlx::query_as( | |
| 355 | 359 | r#" | |
| 356 | 360 | SELECT | |
| 357 | - | (SELECT COUNT(*) FROM sync_devices WHERE app_id = $1), | |
| 358 | - | (SELECT COUNT(*) FROM sync_log WHERE app_id = $1) | |
| 361 | + | a.id, | |
| 362 | + | (SELECT COUNT(*) FROM sync_devices d WHERE d.app_id = a.id), | |
| 363 | + | (SELECT COUNT(*) FROM sync_log l WHERE l.app_id = a.id) | |
| 364 | + | FROM sync_apps a | |
| 365 | + | WHERE a.creator_id = $1 | |
| 359 | 366 | "#, | |
| 360 | 367 | ) | |
| 361 | - | .bind(app_id) | |
| 362 | - | .fetch_one(pool) | |
| 368 | + | .bind(creator_id) | |
| 369 | + | .fetch_all(pool) | |
| 363 | 370 | .await?; | |
| 364 | 371 | ||
| 365 | - | Ok(row) | |
| 372 | + | Ok(rows) | |
| 366 | 373 | } | |
| 367 | 374 | ||
| 368 | 375 | /// Delete sync log entries older than the given number of days. |
| @@ -346,6 +346,31 @@ pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Res | |||
| 346 | 346 | Ok((row.0.unwrap_or(0), row.1.unwrap_or(0))) | |
| 347 | 347 | } | |
| 348 | 348 | ||
| 349 | + | /// Revenue per project for a given seller, returned as (project_id, title, revenue_cents). | |
| 350 | + | /// Single query replaces N+1 loop in dashboard analytics. | |
| 351 | + | pub async fn get_revenue_by_user_projects( | |
| 352 | + | pool: &PgPool, | |
| 353 | + | user_id: UserId, | |
| 354 | + | ) -> Result<Vec<(ProjectId, String, i64)>> { | |
| 355 | + | let rows: Vec<(ProjectId, String, i64)> = sqlx::query_as( | |
| 356 | + | r#" | |
| 357 | + | SELECT p.id, p.title, COALESCE(SUM(t.amount_cents), 0)::BIGINT | |
| 358 | + | FROM projects p | |
| 359 | + | LEFT JOIN items i ON i.project_id = p.id | |
| 360 | + | LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed' | |
| 361 | + | WHERE p.user_id = $1 | |
| 362 | + | GROUP BY p.id, p.title | |
| 363 | + | HAVING COALESCE(SUM(t.amount_cents), 0) > 0 | |
| 364 | + | ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC | |
| 365 | + | "#, | |
| 366 | + | ) | |
| 367 | + | .bind(user_id) | |
| 368 | + | .fetch_all(pool) | |
| 369 | + | .await?; | |
| 370 | + | ||
| 371 | + | Ok(rows) | |
| 372 | + | } | |
| 373 | + | ||
| 349 | 374 | /// Remove a free item from library (deletes the claim transaction) | |
| 350 | 375 | pub async fn remove_free_item_from_library( | |
| 351 | 376 | pool: &PgPool, | |
| @@ -552,6 +577,7 @@ pub async fn get_shared_creators( | |||
| 552 | 577 | WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id | |
| 553 | 578 | ) | |
| 554 | 579 | ORDER BY u.username | |
| 580 | + | LIMIT 500 | |
| 555 | 581 | "#, | |
| 556 | 582 | ) | |
| 557 | 583 | .bind(buyer_id) |
| @@ -215,6 +215,13 @@ pub(super) async fn update_blog_post( | |||
| 215 | 215 | // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule | |
| 216 | 216 | let publish_at = parse_schedule_datetime(req.publish_at.as_deref()); | |
| 217 | 217 | ||
| 218 | + | // Reject scheduling in the past | |
| 219 | + | if let Some(Some(dt)) = &publish_at { | |
| 220 | + | if *dt < chrono::Utc::now() { | |
| 221 | + | return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string())); | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 218 | 225 | // If scheduling, don't publish immediately | |
| 219 | 226 | let is_published = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { | |
| 220 | 227 | false |
| @@ -168,6 +168,13 @@ pub(super) async fn update_item( | |||
| 168 | 168 | // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule | |
| 169 | 169 | let publish_at = parse_schedule_datetime(req.publish_at.as_deref()); | |
| 170 | 170 | ||
| 171 | + | // Reject scheduling in the past | |
| 172 | + | if let Some(Some(dt)) = &publish_at { | |
| 173 | + | if *dt < chrono::Utc::now() { | |
| 174 | + | return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string())); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 171 | 178 | // If scheduling, override is_public to false so it doesn't go live immediately | |
| 172 | 179 | let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() { | |
| 173 | 180 | Some(false) |
| @@ -308,6 +308,14 @@ pub(super) async fn generate_key( | |||
| 308 | 308 | )); | |
| 309 | 309 | } | |
| 310 | 310 | ||
| 311 | + | // Cap at 1000 manually-generated keys per item | |
| 312 | + | let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?; | |
| 313 | + | if existing_count >= 1000 { | |
| 314 | + | return Err(AppError::BadRequest( | |
| 315 | + | "Maximum of 1000 license keys per item reached".to_string(), | |
| 316 | + | )); | |
| 317 | + | } | |
| 318 | + | ||
| 311 | 319 | let key_code = helpers::generate_key_code(); | |
| 312 | 320 | let max_activations = req.max_activations.or(item.default_max_activations); | |
| 313 | 321 |
| @@ -175,7 +175,7 @@ pub(super) async fn create_promo_code( | |||
| 175 | 175 | None | |
| 176 | 176 | }; | |
| 177 | 177 | ||
| 178 | - | // Parse optional tier_id | |
| 178 | + | // Parse optional tier_id (verify ownership via tier → project → user) | |
| 179 | 179 | let tier_id = if let Some(ref id_str) = req.tier_id { | |
| 180 | 180 | let id_str = id_str.trim(); | |
| 181 | 181 | if id_str.is_empty() { | |
| @@ -183,6 +183,17 @@ pub(super) async fn create_promo_code( | |||
| 183 | 183 | } else { | |
| 184 | 184 | let tid: SubscriptionTierId = id_str.parse() | |
| 185 | 185 | .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?; | |
| 186 | + | let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tid) | |
| 187 | + | .await? | |
| 188 | + | .ok_or(AppError::NotFound)?; | |
| 189 | + | let tier_project_id = tier.project_id | |
| 190 | + | .ok_or(AppError::BadRequest("Tier has no project".to_string()))?; | |
| 191 | + | let tier_project = db::projects::get_project_by_id(&state.db, tier_project_id) | |
| 192 | + | .await? | |
| 193 | + | .ok_or(AppError::NotFound)?; | |
| 194 | + | if tier_project.user_id != user.id { | |
| 195 | + | return Err(AppError::Forbidden); | |
| 196 | + | } | |
| 186 | 197 | Some(tid) | |
| 187 | 198 | } | |
| 188 | 199 | } else { |
| @@ -220,6 +220,8 @@ pub(super) async fn dashboard_project( | |||
| 220 | 220 | ||
| 221 | 221 | let has_blog = db_project.features.iter().any(|f| f == "blog"); | |
| 222 | 222 | ||
| 223 | + | let git_enabled = state.config.git_repos_path.is_some(); | |
| 224 | + | ||
| 223 | 225 | Ok(DashboardProjectTemplate { | |
| 224 | 226 | csrf_token, | |
| 225 | 227 | session_user: Some(session_user.clone()), | |
| @@ -229,6 +231,7 @@ pub(super) async fn dashboard_project( | |||
| 229 | 231 | items, | |
| 230 | 232 | stripe_connected: db_user.stripe_account_id.is_some(), | |
| 231 | 233 | has_blog, | |
| 234 | + | git_enabled, | |
| 232 | 235 | }) | |
| 233 | 236 | } | |
| 234 | 237 |
| @@ -8,8 +8,9 @@ pub mod wizards; | |||
| 8 | 8 | ||
| 9 | 9 | use axum::{routing::{get, post}, Router}; | |
| 10 | 10 | use serde::Deserialize; | |
| 11 | + | use tower_governor::GovernorLayer; | |
| 11 | 12 | ||
| 12 | - | use crate::{db, types::*, AppState}; | |
| 13 | + | use crate::{constants, db, types::*, AppState}; | |
| 13 | 14 | ||
| 14 | 15 | /// Query parameters for analytics time range selection. | |
| 15 | 16 | #[derive(Deserialize)] | |
| @@ -38,11 +39,13 @@ pub(super) fn build_chart_bars(buckets: &[db::analytics::TimeBucket]) -> Vec<Cha | |||
| 38 | 39 | ||
| 39 | 40 | /// Register dashboard page routes. | |
| 40 | 41 | pub fn dashboard_routes() -> Router<AppState> { | |
| 41 | - | Router::new() | |
| 42 | - | .merge(wizards::wizard_routes()) | |
| 43 | - | .route("/dashboard", get(main::dashboard)) | |
| 44 | - | .route("/dashboard/project/{slug}", get(main::dashboard_project)) | |
| 45 | - | .route("/dashboard/item/{id}", get(main::dashboard_item)) | |
| 42 | + | let read_rate_limit = crate::helpers::rate_limiter_ms( | |
| 43 | + | constants::DASHBOARD_READ_RATE_LIMIT_MS, | |
| 44 | + | constants::DASHBOARD_READ_RATE_LIMIT_BURST, | |
| 45 | + | ); | |
| 46 | + | ||
| 47 | + | // Tab endpoints — rate limited to prevent rapid polling | |
| 48 | + | let tab_routes = Router::new() | |
| 46 | 49 | .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details)) | |
| 47 | 50 | .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments)) | |
| 48 | 51 | .route("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects)) | |
| @@ -54,6 +57,7 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 54 | 57 | .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 55 | 58 | .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) | |
| 56 | 59 | .route("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics)) | |
| 60 | + | .route("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code)) | |
| 57 | 61 | .route("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings)) | |
| 58 | 62 | .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog)) | |
| 59 | 63 | .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions)) | |
| @@ -64,6 +68,14 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 64 | 68 | .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files)) | |
| 65 | 69 | .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings)) | |
| 66 | 70 | .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics)) | |
| 71 | + | .route_layer(GovernorLayer { config: read_rate_limit }); | |
| 72 | + | ||
| 73 | + | Router::new() | |
| 74 | + | .merge(wizards::wizard_routes()) | |
| 75 | + | .route("/dashboard", get(main::dashboard)) | |
| 76 | + | .route("/dashboard/project/{slug}", get(main::dashboard_project)) | |
| 77 | + | .route("/dashboard/item/{id}", get(main::dashboard_item)) | |
| 78 | + | .merge(tab_routes) | |
| 67 | 79 | .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row)) | |
| 68 | 80 | .route("/dashboard/export", get(forms::export_portal)) | |
| 69 | 81 | .route("/dashboard/delete-account", get(forms::delete_account_page)) |
| @@ -263,13 +263,6 @@ pub(super) async fn project_tab_settings( | |||
| 263 | 263 | .await? | |
| 264 | 264 | .unwrap_or_default(); | |
| 265 | 265 | ||
| 266 | - | let git_enabled = state.config.git_repos_path.is_some(); | |
| 267 | - | ||
| 268 | - | // Fetch linked repos and available (unlinked) repos for this user | |
| 269 | - | let linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id).await.unwrap_or_default(); | |
| 270 | - | let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id).await.unwrap_or_default(); | |
| 271 | - | let available_repos: Vec<_> = all_repos.into_iter().filter(|r| r.project_id.is_none()).collect(); | |
| 272 | - | ||
| 273 | 266 | // Fetch labels for this project and all available labels | |
| 274 | 267 | let project_labels = db::labels::get_labels_for_project(&state.db, db_project.id).await?; | |
| 275 | 268 | let all_labels = db::labels::get_all_labels(&state.db).await?; | |
| @@ -281,7 +274,7 @@ pub(super) async fn project_tab_settings( | |||
| 281 | 274 | let features = db_project.features.clone(); | |
| 282 | 275 | let project_features = db::ProjectFeature::all(); | |
| 283 | 276 | ||
| 284 | - | Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, git_enabled, linked_repos, available_repos, project_labels, available_labels, project_id, features, project_features })) | |
| 277 | + | Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_labels, available_labels, project_id, features, project_features })) | |
| 285 | 278 | } | |
| 286 | 279 | ||
| 287 | 280 | /// Render the HTMX partial for the project subscriptions tab (tier management). | |
| @@ -379,3 +372,33 @@ pub(super) async fn project_tab_promotions( | |||
| 379 | 372 | items, | |
| 380 | 373 | })) | |
| 381 | 374 | } | |
| 375 | + | ||
| 376 | + | /// Render the HTMX partial for the project code tab (git repos). | |
| 377 | + | #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")] | |
| 378 | + | pub(super) async fn project_tab_code( | |
| 379 | + | State(state): State<AppState>, | |
| 380 | + | AuthUser(session_user): AuthUser, | |
| 381 | + | headers: HeaderMap, | |
| 382 | + | Path(slug): Path<String>, | |
| 383 | + | ) -> Result<axum::response::Response> { | |
| 384 | + | let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { | |
| 385 | + | Ok(pair) => pair, | |
| 386 | + | Err(not_modified) => return Ok(not_modified), | |
| 387 | + | }; | |
| 388 | + | ||
| 389 | + | let git_enabled = state.config.git_repos_path.is_some(); | |
| 390 | + | let linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id).await.unwrap_or_default(); | |
| 391 | + | let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id).await.unwrap_or_default(); | |
| 392 | + | let available_repos: Vec<_> = all_repos.into_iter().filter(|r| r.project_id.is_none()).collect(); | |
| 393 | + | ||
| 394 | + | let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; | |
| 395 | + | let project = Project::from_db(&db_project, db_items.len() as u32); | |
| 396 | + | ||
| 397 | + | Ok(helpers::with_etag(generation, ProjectCodeTabTemplate { | |
| 398 | + | project, | |
| 399 | + | git_enabled, | |
| 400 | + | linked_repos, | |
| 401 | + | available_repos, | |
| 402 | + | project_id: db_project.id.to_string(), | |
| 403 | + | })) | |
| 404 | + | } |
| @@ -219,9 +219,7 @@ pub(super) async fn dashboard_tab_creator( | |||
| 219 | 219 | let creator_sub = db::creator_tiers::get_creator_sub_by_user(&state.db, session_user.id).await?; | |
| 220 | 220 | let (creator_tier_label, creator_period_end, creator_sub_status) = match &creator_sub { | |
| 221 | 221 | Some(sub) => { | |
| 222 | - | let label = sub.tier.parse::<crate::db::CreatorTier>() | |
| 223 | - | .map(|t| t.label().to_string()) | |
| 224 | - | .ok(); | |
| 222 | + | let label = Some(sub.tier.label().to_string()); | |
| 225 | 223 | let period_end = sub.current_period_end.map(|dt| dt.format("%B %d, %Y").to_string()); | |
| 226 | 224 | (label, period_end, Some(sub.status.clone())) | |
| 227 | 225 | } | |
| @@ -340,19 +338,15 @@ pub(super) async fn dashboard_tab_analytics( | |||
| 340 | 338 | }, | |
| 341 | 339 | ]; | |
| 342 | 340 | ||
| 343 | - | // Build per-project revenue breakdown | |
| 344 | - | let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; | |
| 345 | - | let mut top_projects = Vec::new(); | |
| 346 | - | for p in &db_projects { | |
| 347 | - | let (rev_cents, _sales) = db::transactions::get_revenue_by_project(&state.db, p.id).await?; | |
| 348 | - | if rev_cents > 0 { | |
| 349 | - | top_projects.push(ProjectRevenue { | |
| 350 | - | title: p.title.clone(), | |
| 351 | - | revenue: format!("${}.{:02}", rev_cents / 100, rev_cents % 100), | |
| 352 | - | }); | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | top_projects.sort_by(|a, b| b.revenue.cmp(&a.revenue)); | |
| 341 | + | // Build per-project revenue breakdown (single query, no N+1) | |
| 342 | + | let project_revenues = db::transactions::get_revenue_by_user_projects(&state.db, session_user.id).await?; | |
| 343 | + | let top_projects: Vec<ProjectRevenue> = project_revenues | |
| 344 | + | .into_iter() | |
| 345 | + | .map(|(_pid, title, rev_cents)| ProjectRevenue { | |
| 346 | + | title, | |
| 347 | + | revenue: format!("${}.{:02}", rev_cents / 100, rev_cents % 100), | |
| 348 | + | }) | |
| 349 | + | .collect(); | |
| 356 | 350 | ||
| 357 | 351 | Ok(UserAnalyticsTabTemplate { | |
| 358 | 352 | stats, | |
| @@ -469,10 +463,25 @@ pub(super) async fn dashboard_tab_synckit( | |||
| 469 | 463 | let db_apps = db::synckit::get_sync_apps_by_creator(&state.db, session_user.id).await?; | |
| 470 | 464 | let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; | |
| 471 | 465 | ||
| 466 | + | // Batch-fetch stats and item titles (no N+1) | |
| 467 | + | let stats_batch = db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?; | |
| 468 | + | let stats_map: std::collections::HashMap<_, _> = stats_batch | |
| 469 | + | .into_iter() | |
| 470 | + | .map(|(id, devices, logs)| (id, (devices, logs))) | |
| 471 | + | .collect(); | |
| 472 | + | ||
| 473 | + | let item_ids: Vec<db::ItemId> = db_apps.iter().filter_map(|a| a.item_id).collect(); | |
| 474 | + | let item_titles_batch = db::items::get_item_titles_batch(&state.db, &item_ids).await?; | |
| 475 | + | let item_title_map: std::collections::HashMap<_, _> = item_titles_batch | |
| 476 | + | .into_iter() | |
| 477 | + | .collect(); | |
| 478 | + | ||
| 472 | 479 | let mut apps = Vec::with_capacity(db_apps.len()); | |
| 473 | 480 | for app in &db_apps { | |
| 474 | - | let (device_count, log_entry_count) = | |
| 475 | - | db::synckit::get_sync_app_stats(&state.db, app.id).await?; | |
| 481 | + | let (device_count, log_entry_count) = stats_map | |
| 482 | + | .get(&app.id) | |
| 483 | + | .copied() | |
| 484 | + | .unwrap_or((0, 0)); | |
| 476 | 485 | ||
| 477 | 486 | let key = &app.api_key; | |
| 478 | 487 | let api_key_masked = if key.len() > 16 { | |
| @@ -488,14 +497,8 @@ pub(super) async fn dashboard_tab_synckit( | |||
| 488 | 497 | .map(|p| (Some(p.title.clone()), Some(p.slug.to_string()))) | |
| 489 | 498 | .unwrap_or((None, None)); | |
| 490 | 499 | ||
| 491 | - | // Resolve linked item title | |
| 492 | - | let item_title = if let Some(iid) = app.item_id { | |
| 493 | - | db::items::get_item_by_id(&state.db, iid) | |
| 494 | - | .await? | |
| 495 | - | .map(|i| i.title) | |
| 496 | - | } else { | |
| 497 | - | None | |
| 498 | - | }; | |
| 500 | + | // Resolve linked item title from batch | |
| 501 | + | let item_title = app.item_id.and_then(|iid| item_title_map.get(&iid).cloned()); | |
| 499 | 502 | ||
| 500 | 503 | apps.push(SyncAppRow { | |
| 501 | 504 | id: app.id.to_string(), |
| @@ -248,6 +248,9 @@ async fn presign_upload( | |||
| 248 | 248 | return Err(AppError::Forbidden); | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | + | // Early quota check (reject before generating presigned URL) | |
| 252 | + | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 253 | + | ||
| 251 | 254 | // Generate S3 key | |
| 252 | 255 | let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name); | |
| 253 | 256 | ||
| @@ -476,6 +479,9 @@ async fn version_presign_upload( | |||
| 476 | 479 | return Err(AppError::Forbidden); | |
| 477 | 480 | } | |
| 478 | 481 | ||
| 482 | + | // Early quota check | |
| 483 | + | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 484 | + | ||
| 479 | 485 | // Generate S3 key using the version's item_id | |
| 480 | 486 | let s3_key = S3Client::generate_key(user.id, version.item_id, file_type, &req.file_name); | |
| 481 | 487 | ||
| @@ -676,6 +682,9 @@ async fn project_image_presign( | |||
| 676 | 682 | return Err(AppError::Forbidden); | |
| 677 | 683 | } | |
| 678 | 684 | ||
| 685 | + | // Early quota check | |
| 686 | + | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 687 | + | ||
| 679 | 688 | let s3_key = S3Client::generate_project_image_key(req.project_id, &req.file_name); | |
| 680 | 689 | let expires_in = 3600; | |
| 681 | 690 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?; | |
| @@ -802,6 +811,9 @@ async fn item_image_presign( | |||
| 802 | 811 | return Err(AppError::Forbidden); | |
| 803 | 812 | } | |
| 804 | 813 | ||
| 814 | + | // Early quota check | |
| 815 | + | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 816 | + | ||
| 805 | 817 | let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name); | |
| 806 | 818 | let expires_in = 3600; | |
| 807 | 819 | let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?; |
| @@ -113,76 +113,44 @@ pub(super) async fn create_checkout( | |||
| 113 | 113 | let is_platform_wide = pc.is_platform_wide; | |
| 114 | 114 | ||
| 115 | 115 | // Only discount and free_access codes are valid at item checkout | |
| 116 | - | match pc.code_purpose { | |
| 117 | - | CodePurpose::FreeTrial => { | |
| 118 | - | return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); | |
| 116 | + | if pc.code_purpose == CodePurpose::FreeTrial { | |
| 117 | + | return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Common validation for all item checkout codes | |
| 121 | + | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 122 | + | return Err(AppError::BadRequest("This promo code has expired".to_string())); | |
| 123 | + | } | |
| 124 | + | if let Some(max) = pc.max_uses && pc.use_count >= max { | |
| 125 | + | return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Scope checks only apply to seller codes, not platform-wide credits | |
| 129 | + | if !is_platform_wide { | |
| 130 | + | if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid { | |
| 131 | + | return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); | |
| 132 | + | } | |
| 133 | + | if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project { | |
| 134 | + | return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); | |
| 119 | 135 | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Type-specific logic | |
| 139 | + | match pc.code_purpose { | |
| 140 | + | CodePurpose::FreeTrial => unreachable!(), | |
| 120 | 141 | CodePurpose::FreeAccess => { | |
| 121 | - | // free_access = 100% discount | |
| 122 | 142 | final_price_cents = 0; | |
| 123 | - | promo_code_id = Some(pc.id); | |
| 124 | 143 | } | |
| 125 | 144 | CodePurpose::Discount => { | |
| 126 | - | // Check expiry | |
| 127 | - | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 128 | - | return Err(AppError::BadRequest("This promo code has expired".to_string())); | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Check max uses | |
| 132 | - | if let Some(max) = pc.max_uses && pc.use_count >= max { | |
| 133 | - | return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); | |
| 145 | + | if !is_platform_wide && item.price_cents < pc.min_price_cents { | |
| 146 | + | return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string())); | |
| 134 | 147 | } | |
| 135 | - | ||
| 136 | - | // Scope checks only apply to seller codes, not platform-wide credits | |
| 137 | - | if !is_platform_wide { | |
| 138 | - | // Check item scope | |
| 139 | - | if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid { | |
| 140 | - | return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // Check project scope | |
| 144 | - | if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project { | |
| 145 | - | return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | // Check minimum price | |
| 149 | - | if item.price_cents < pc.min_price_cents { | |
| 150 | - | return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string())); | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | ||
| 154 | 148 | if let (Some(dt), Some(dv)) = (pc.discount_type, pc.discount_value) { | |
| 155 | 149 | final_price_cents = db::promo_codes::apply_discount(item.price_cents, dt, dv); | |
| 156 | 150 | } | |
| 157 | - | promo_code_id = Some(pc.id); | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | // Common checks for non-trial codes (free_access) | |
| 162 | - | if pc.code_purpose != CodePurpose::Discount { | |
| 163 | - | // Check expiry | |
| 164 | - | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 165 | - | return Err(AppError::BadRequest("This code has expired".to_string())); | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Check max uses | |
| 169 | - | if let Some(max) = pc.max_uses && pc.use_count >= max { | |
| 170 | - | return Err(AppError::BadRequest("This code has reached its usage limit".to_string())); | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | // Scope checks only for seller codes | |
| 174 | - | if !is_platform_wide { | |
| 175 | - | // Check item scope | |
| 176 | - | if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid { | |
| 177 | - | return Err(AppError::BadRequest("This code is not valid for this item".to_string())); | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // Check project scope | |
| 181 | - | if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project { | |
| 182 | - | return Err(AppError::BadRequest("This code is not valid for this item".to_string())); | |
| 183 | - | } | |
| 184 | 151 | } | |
| 185 | 152 | } | |
| 153 | + | promo_code_id = Some(pc.id); | |
| 186 | 154 | } | |
| 187 | 155 | } | |
| 188 | 156 |