max / makenotwork
8 files changed,
+347 insertions,
-153 deletions
| @@ -977,6 +977,7 @@ mod tests { | |||
| 977 | 977 | wam_url: None, | |
| 978 | 978 | internal_shared_secret: None, | |
| 979 | 979 | cli_service_token: None, | |
| 980 | + | alerts_ingest_token: None, | |
| 980 | 981 | }, | |
| 981 | 982 | }; | |
| 982 | 983 | assert!(require_admin(&user, &config).is_ok()); | |
| @@ -1057,6 +1058,7 @@ mod tests { | |||
| 1057 | 1058 | wam_url: None, | |
| 1058 | 1059 | internal_shared_secret: None, | |
| 1059 | 1060 | cli_service_token: None, | |
| 1061 | + | alerts_ingest_token: None, | |
| 1060 | 1062 | }, | |
| 1061 | 1063 | }; | |
| 1062 | 1064 | assert!(require_admin(&user, &config).is_err()); |
| @@ -51,7 +51,7 @@ const MAX_SEARCH_LEN: usize = 200; | |||
| 51 | 51 | ||
| 52 | 52 | /// Normalize a search term: trim whitespace, truncate to [`MAX_SEARCH_LEN`], | |
| 53 | 53 | /// and return `None` if the result is empty. | |
| 54 | - | fn normalize_search(raw: Option<&str>) -> Option<String> { | |
| 54 | + | pub(crate) fn normalize_search(raw: Option<&str>) -> Option<String> { | |
| 55 | 55 | let trimmed = raw?.trim(); | |
| 56 | 56 | if trimmed.is_empty() { | |
| 57 | 57 | return None; | |
| @@ -71,7 +71,7 @@ fn normalize_search(raw: Option<&str>) -> Option<String> { | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | /// Returns `true` when the search term is too short for trigram matching (1-2 chars). | |
| 74 | - | fn is_short_query(term: &str) -> bool { | |
| 74 | + | pub(crate) fn is_short_query(term: &str) -> bool { | |
| 75 | 75 | term.trim().len() <= 2 | |
| 76 | 76 | } | |
| 77 | 77 | ||
| @@ -139,6 +139,83 @@ macro_rules! bind_item_discover_filters { | |||
| 139 | 139 | }; | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | /// The visibility predicate every public item query must carry. | |
| 143 | + | /// | |
| 144 | + | /// Kept as one constant because the facet counts and the result set drifted apart | |
| 145 | + | /// once already: `get_tag_counts` was missing `scan_status` and `is_sandbox` | |
| 146 | + | /// entirely, so it counted items the results would never show. | |
| 147 | + | pub(crate) const ITEM_VISIBILITY_WHERE: &str = " WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE"; | |
| 148 | + | ||
| 149 | + | /// Which facet is being counted, and therefore which filter to omit. | |
| 150 | + | /// | |
| 151 | + | /// A facet's counts answer "what would I get if I picked this value", so the | |
| 152 | + | /// facet's own current selection must not constrain them. Selecting | |
| 153 | + | /// `item_type=audio` and then applying that filter to the item-type counts | |
| 154 | + | /// would zero every other type and hide the 12 samples also available. | |
| 155 | + | /// Every *other* active filter still applies. | |
| 156 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 157 | + | pub enum FacetAxis { | |
| 158 | + | ItemType, | |
| 159 | + | Tag, | |
| 160 | + | Price, | |
| 161 | + | AiTier, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Append facet-count filter clauses, omitting `axis`'s own dimension. | |
| 165 | + | /// | |
| 166 | + | /// Uses the same bind layout as [`append_item_discover_filters`] | |
| 167 | + | /// ($1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag) so every | |
| 168 | + | /// discover query binds identically, and `ai_tier` is inlined as an | |
| 169 | + | /// enum-derived literal exactly as the result queries do. | |
| 170 | + | pub(crate) fn append_facet_filters( | |
| 171 | + | query: &mut String, | |
| 172 | + | filters: &DiscoverFilters<'_>, | |
| 173 | + | axis: FacetAxis, | |
| 174 | + | has_search: bool, | |
| 175 | + | short_query: bool, | |
| 176 | + | ) { | |
| 177 | + | if has_search { | |
| 178 | + | if short_query { | |
| 179 | + | query.push_str(ITEM_SEARCH_CLAUSE_SHORT); | |
| 180 | + | } else { | |
| 181 | + | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if axis != FacetAxis::ItemType && filters.item_type.is_some() { | |
| 185 | + | query.push_str(" AND i.item_type = $2"); | |
| 186 | + | } | |
| 187 | + | if axis != FacetAxis::Price { | |
| 188 | + | if filters.min_price.is_some() { | |
| 189 | + | query.push_str(" AND i.price_cents >= $3"); | |
| 190 | + | } | |
| 191 | + | if filters.max_price.is_some() { | |
| 192 | + | query.push_str(" AND i.price_cents <= $4"); | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | if axis != FacetAxis::Tag && filters.tag.is_some() { | |
| 196 | + | // Descendant match via the materialized dot-path, matching the result | |
| 197 | + | // query. This previously used `parent_id`, which matched direct children | |
| 198 | + | // only, so any tag with grandchildren counted differently than it filtered. | |
| 199 | + | query.push_str( | |
| 200 | + | r#" AND EXISTS ( | |
| 201 | + | SELECT 1 FROM item_tags it2 | |
| 202 | + | JOIN tags t2 ON t2.id = it2.tag_id | |
| 203 | + | WHERE it2.item_id = i.id | |
| 204 | + | AND (t2.slug = $5 OR t2.path LIKE $5 || '.%') | |
| 205 | + | )"#, | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | if axis != FacetAxis::AiTier { | |
| 209 | + | match filters.ai_tier { | |
| 210 | + | Some(AiTierFilter::HandmadeOnly) => query.push_str(" AND i.ai_tier = 'handmade'"), | |
| 211 | + | Some(AiTierFilter::HumanLed) => { | |
| 212 | + | query.push_str(" AND i.ai_tier IN ('handmade', 'assisted')") | |
| 213 | + | } | |
| 214 | + | None => {} | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 142 | 219 | /// Search/browse public items with optional text search, item_type, tag, and price filters. | |
| 143 | 220 | /// | |
| 144 | 221 | /// Uses PostgreSQL `pg_trgm` for fuzzy text matching. The search strategy: | |
| @@ -487,15 +564,15 @@ pub async fn count_discover_projects( | |||
| 487 | 564 | } | |
| 488 | 565 | ||
| 489 | 566 | /// Get item type counts for discover page (items mode). | |
| 567 | + | /// | |
| 568 | + | /// Counts what each type *would* yield, so `filters.item_type` is omitted | |
| 569 | + | /// (see [`FacetAxis`]). Every other active filter applies. | |
| 490 | 570 | #[tracing::instrument(skip_all)] | |
| 491 | 571 | pub async fn get_item_type_counts( | |
| 492 | 572 | pool: &PgPool, | |
| 493 | - | search: Option<&str>, | |
| 494 | - | tag: Option<&str>, | |
| 495 | - | min_price: Option<i32>, | |
| 496 | - | max_price: Option<i32>, | |
| 573 | + | filters: &DiscoverFilters<'_>, | |
| 497 | 574 | ) -> Result<Vec<DbItemTypeCount>> { | |
| 498 | - | let search_term = normalize_search(search); | |
| 575 | + | let search_term = normalize_search(filters.search); | |
| 499 | 576 | let has_search = search_term.is_some(); | |
| 500 | 577 | let short_query = search_term.as_deref().is_some_and(is_short_query); | |
| 501 | 578 | ||
| @@ -505,46 +582,24 @@ pub async fn get_item_type_counts( | |||
| 505 | 582 | FROM items i | |
| 506 | 583 | JOIN projects p ON i.project_id = p.id | |
| 507 | 584 | JOIN users u ON p.user_id = u.id | |
| 508 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 509 | 585 | "#, | |
| 510 | 586 | ); | |
| 587 | + | query.push_str(ITEM_VISIBILITY_WHERE); | |
| 511 | 588 | ||
| 512 | - | if has_search { | |
| 513 | - | if short_query { | |
| 514 | - | query.push_str(ITEM_SEARCH_CLAUSE_SHORT); | |
| 515 | - | } else { | |
| 516 | - | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 517 | - | } | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | if tag.is_some() { | |
| 521 | - | query.push_str( | |
| 522 | - | r#" AND EXISTS ( | |
| 523 | - | SELECT 1 FROM item_tags it2 | |
| 524 | - | JOIN tags t2 ON t2.id = it2.tag_id | |
| 525 | - | WHERE it2.item_id = i.id | |
| 526 | - | AND (t2.slug = $2 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $2)) | |
| 527 | - | )"#, | |
| 528 | - | ); | |
| 529 | - | } | |
| 530 | - | ||
| 531 | - | if min_price.is_some() { | |
| 532 | - | query.push_str(" AND i.price_cents >= $3"); | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | if max_price.is_some() { | |
| 536 | - | query.push_str(" AND i.price_cents <= $4"); | |
| 537 | - | } | |
| 589 | + | append_facet_filters( | |
| 590 | + | &mut query, | |
| 591 | + | filters, | |
| 592 | + | FacetAxis::ItemType, | |
| 593 | + | has_search, | |
| 594 | + | short_query, | |
| 595 | + | ); | |
| 538 | 596 | ||
| 539 | 597 | query.push_str(" GROUP BY i.item_type ORDER BY count DESC"); | |
| 540 | 598 | ||
| 541 | - | let counts = sqlx::query_as::<_, DbItemTypeCount>(&query) | |
| 542 | - | .bind(search_term.as_deref().unwrap_or("")) | |
| 543 | - | .bind(tag.unwrap_or("")) | |
| 544 | - | .bind(min_price.unwrap_or(0)) | |
| 545 | - | .bind(max_price.unwrap_or(i32::MAX)) | |
| 546 | - | .fetch_all(pool) | |
| 547 | - | .await?; | |
| 599 | + | let counts = | |
| 600 | + | bind_item_discover_filters!(sqlx::query_as::<_, DbItemTypeCount>(&query), filters, search_term.as_deref()) | |
| 601 | + | .fetch_all(pool) | |
| 602 | + | .await?; | |
| 548 | 603 | ||
| 549 | 604 | Ok(counts) | |
| 550 | 605 | } | |
| @@ -557,11 +612,9 @@ pub async fn get_item_type_counts( | |||
| 557 | 612 | #[tracing::instrument(skip_all)] | |
| 558 | 613 | pub async fn get_price_range_counts( | |
| 559 | 614 | pool: &PgPool, | |
| 560 | - | search: Option<&str>, | |
| 561 | - | item_type: Option<ItemType>, | |
| 562 | - | tag: Option<&str>, | |
| 615 | + | filters: &DiscoverFilters<'_>, | |
| 563 | 616 | ) -> Result<DbPriceRangeCounts> { | |
| 564 | - | let search_term = normalize_search(search); | |
| 617 | + | let search_term = normalize_search(filters.search); | |
| 565 | 618 | let has_search = search_term.is_some(); | |
| 566 | 619 | let short_query = search_term.as_deref().is_some_and(is_short_query); | |
| 567 | 620 | ||
| @@ -576,32 +629,17 @@ pub async fn get_price_range_counts( | |||
| 576 | 629 | FROM items i | |
| 577 | 630 | JOIN projects p ON i.project_id = p.id | |
| 578 | 631 | JOIN users u ON p.user_id = u.id | |
| 579 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 580 | 632 | "#, | |
| 581 | 633 | ); | |
| 634 | + | query.push_str(ITEM_VISIBILITY_WHERE); | |
| 582 | 635 | ||
| 583 | - | if has_search { | |
| 584 | - | if short_query { | |
| 585 | - | query.push_str(ITEM_SEARCH_CLAUSE_SHORT); | |
| 586 | - | } else { | |
| 587 | - | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 588 | - | } | |
| 589 | - | } | |
| 590 | - | ||
| 591 | - | if item_type.is_some() { | |
| 592 | - | query.push_str(" AND i.item_type = $2"); | |
| 593 | - | } | |
| 594 | - | ||
| 595 | - | if tag.is_some() { | |
| 596 | - | query.push_str( | |
| 597 | - | r#" AND EXISTS ( | |
| 598 | - | SELECT 1 FROM item_tags it2 | |
| 599 | - | JOIN tags t2 ON t2.id = it2.tag_id | |
| 600 | - | WHERE it2.item_id = i.id | |
| 601 | - | AND (t2.slug = $3 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $3)) | |
| 602 | - | )"#, | |
| 603 | - | ); | |
| 604 | - | } | |
| 636 | + | append_facet_filters( | |
| 637 | + | &mut query, | |
| 638 | + | filters, | |
| 639 | + | FacetAxis::Price, | |
| 640 | + | has_search, | |
| 641 | + | short_query, | |
| 642 | + | ); | |
| 605 | 643 | ||
| 606 | 644 | #[derive(FromRow)] | |
| 607 | 645 | struct PriceRow { | |
| @@ -612,12 +650,13 @@ pub async fn get_price_range_counts( | |||
| 612 | 650 | over_100: Option<i64>, | |
| 613 | 651 | } | |
| 614 | 652 | ||
| 615 | - | let row = sqlx::query_as::<_, PriceRow>(&query) | |
| 616 | - | .bind(search_term.as_deref().unwrap_or("")) | |
| 617 | - | .bind(item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 618 | - | .bind(tag.unwrap_or("")) | |
| 619 | - | .fetch_one(pool) | |
| 620 | - | .await?; | |
| 653 | + | let row = bind_item_discover_filters!( | |
| 654 | + | sqlx::query_as::<_, PriceRow>(&query), | |
| 655 | + | filters, | |
| 656 | + | search_term.as_deref() | |
| 657 | + | ) | |
| 658 | + | .fetch_one(pool) | |
| 659 | + | .await?; | |
| 621 | 660 | ||
| 622 | 661 | Ok(DbPriceRangeCounts { | |
| 623 | 662 | free: row.free.unwrap_or(0), | |
| @@ -632,11 +671,9 @@ pub async fn get_price_range_counts( | |||
| 632 | 671 | #[tracing::instrument(skip_all)] | |
| 633 | 672 | pub async fn get_ai_tier_counts( | |
| 634 | 673 | pool: &PgPool, | |
| 635 | - | search: Option<&str>, | |
| 636 | - | item_type: Option<ItemType>, | |
| 637 | - | tag: Option<&str>, | |
| 674 | + | filters: &DiscoverFilters<'_>, | |
| 638 | 675 | ) -> Result<Vec<DbItemTypeCount>> { | |
| 639 | - | let search_term = normalize_search(search); | |
| 676 | + | let search_term = normalize_search(filters.search); | |
| 640 | 677 | let has_search = search_term.is_some(); | |
| 641 | 678 | let short_query = search_term.as_deref().is_some_and(is_short_query); | |
| 642 | 679 | ||
| @@ -646,41 +683,27 @@ pub async fn get_ai_tier_counts( | |||
| 646 | 683 | FROM items i | |
| 647 | 684 | JOIN projects p ON i.project_id = p.id | |
| 648 | 685 | JOIN users u ON p.user_id = u.id | |
| 649 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 650 | 686 | "#, | |
| 651 | 687 | ); | |
| 688 | + | query.push_str(ITEM_VISIBILITY_WHERE); | |
| 652 | 689 | ||
| 653 | - | if has_search { | |
| 654 | - | if short_query { | |
| 655 | - | query.push_str(ITEM_SEARCH_CLAUSE_SHORT); | |
| 656 | - | } else { | |
| 657 | - | query.push_str(ITEM_SEARCH_CLAUSE); | |
| 658 | - | } | |
| 659 | - | } | |
| 660 | - | ||
| 661 | - | if item_type.is_some() { | |
| 662 | - | query.push_str(" AND i.item_type = $2"); | |
| 663 | - | } | |
| 664 | - | ||
| 665 | - | if tag.is_some() { | |
| 666 | - | query.push_str( | |
| 667 | - | r#" AND EXISTS ( | |
| 668 | - | SELECT 1 FROM item_tags it2 | |
| 669 | - | JOIN tags t2 ON t2.id = it2.tag_id | |
| 670 | - | WHERE it2.item_id = i.id | |
| 671 | - | AND (t2.slug = $3 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $3)) | |
| 672 | - | )"#, | |
| 673 | - | ); | |
| 674 | - | } | |
| 690 | + | append_facet_filters( | |
| 691 | + | &mut query, | |
| 692 | + | filters, | |
| 693 | + | FacetAxis::AiTier, | |
| 694 | + | has_search, | |
| 695 | + | short_query, | |
| 696 | + | ); | |
| 675 | 697 | ||
| 676 | 698 | query.push_str(" GROUP BY i.ai_tier ORDER BY count DESC"); | |
| 677 | 699 | ||
| 678 | - | let counts = sqlx::query_as::<_, DbItemTypeCount>(&query) | |
| 679 | - | .bind(search_term.as_deref().unwrap_or("")) | |
| 680 | - | .bind(item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 681 | - | .bind(tag.unwrap_or("")) | |
| 682 | - | .fetch_all(pool) | |
| 683 | - | .await?; | |
| 700 | + | let counts = bind_item_discover_filters!( | |
| 701 | + | sqlx::query_as::<_, DbItemTypeCount>(&query), | |
| 702 | + | filters, | |
| 703 | + | search_term.as_deref() | |
| 704 | + | ) | |
| 705 | + | .fetch_all(pool) | |
| 706 | + | .await?; | |
| 684 | 707 | ||
| 685 | 708 | Ok(counts) | |
| 686 | 709 | } |
| @@ -18,7 +18,7 @@ pub(crate) mod chapters; | |||
| 18 | 18 | pub(crate) mod item_sections; | |
| 19 | 19 | pub(crate) mod project_sections; | |
| 20 | 20 | pub mod transactions; | |
| 21 | - | pub(crate) mod discover; | |
| 21 | + | pub mod discover; // pub so the integration test crate can assert facet-count correctness directly | |
| 22 | 22 | pub(crate) mod custom_links; | |
| 23 | 23 | pub(crate) mod auth; | |
| 24 | 24 | pub mod waitlist; | |
| @@ -30,7 +30,7 @@ pub(crate) mod oauth; | |||
| 30 | 30 | pub(crate) mod promo_codes; | |
| 31 | 31 | pub(crate) mod follows; | |
| 32 | 32 | pub(crate) mod subscriptions; | |
| 33 | - | pub(crate) mod tags; | |
| 33 | + | pub mod tags; // pub so the integration test crate can assert facet-count correctness directly | |
| 34 | 34 | pub(crate) mod categories; | |
| 35 | 35 | pub mod sessions; | |
| 36 | 36 | pub(crate) mod totp; |
| @@ -141,15 +141,27 @@ pub async fn set_primary_tag(pool: &PgPool, item_id: ItemId, tag_id: TagId) -> R | |||
| 141 | 141 | Ok(()) | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | - | /// Count items per tag for discover sidebar facets, filtered by current search and item_type. | |
| 144 | + | /// Count items per tag for the discover sidebar facets. | |
| 145 | + | /// | |
| 146 | + | /// Counts what each tag *would* yield, so `filters.tag` is omitted (see | |
| 147 | + | /// [`super::discover::FacetAxis`]). Every other active filter applies. | |
| 148 | + | /// | |
| 149 | + | /// This previously diverged from the result set in three ways, all of which | |
| 150 | + | /// let it count items discover would never show: it had no `users` join (so no | |
| 151 | + | /// `is_sandbox` filter), no `scan_status` predicate, and it matched | |
| 152 | + | /// `description` in search where the result query matches `title` only. It also | |
| 153 | + | /// ignored the price and AI-tier filters entirely. It now shares | |
| 154 | + | /// [`super::discover::append_facet_filters`] with the other three facets. | |
| 145 | 155 | #[tracing::instrument(skip_all)] | |
| 146 | 156 | pub async fn get_tag_counts( | |
| 147 | 157 | pool: &PgPool, | |
| 148 | - | search: Option<&str>, | |
| 149 | - | item_type: Option<super::ItemType>, | |
| 158 | + | filters: &super::discover::DiscoverFilters<'_>, | |
| 150 | 159 | ) -> Result<Vec<DbTagCount>> { | |
| 151 | - | let search_term = search.filter(|s| !s.trim().is_empty()); | |
| 160 | + | use super::discover::{FacetAxis, ITEM_VISIBILITY_WHERE, append_facet_filters, normalize_search}; | |
| 161 | + | ||
| 162 | + | let search_term = normalize_search(filters.search); | |
| 152 | 163 | let has_search = search_term.is_some(); | |
| 164 | + | let short_query = search_term.as_deref().is_some_and(super::discover::is_short_query); | |
| 153 | 165 | ||
| 154 | 166 | let mut query = String::from( | |
| 155 | 167 | r#" | |
| @@ -158,30 +170,21 @@ pub async fn get_tag_counts( | |||
| 158 | 170 | JOIN tags t ON t.id = it.tag_id | |
| 159 | 171 | JOIN items i ON i.id = it.item_id | |
| 160 | 172 | JOIN projects p ON i.project_id = p.id | |
| 161 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true | |
| 173 | + | JOIN users u ON p.user_id = u.id | |
| 162 | 174 | "#, | |
| 163 | 175 | ); | |
| 176 | + | query.push_str(ITEM_VISIBILITY_WHERE); | |
| 164 | 177 | ||
| 165 | - | if has_search { | |
| 166 | - | query.push_str( | |
| 167 | - | r#" AND ( | |
| 168 | - | i.title % $1 | |
| 169 | - | OR COALESCE(i.description, '') % $1 | |
| 170 | - | OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 171 | - | OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%' | |
| 172 | - | )"#, | |
| 173 | - | ); | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | if item_type.is_some() { | |
| 177 | - | query.push_str(" AND i.item_type = $2"); | |
| 178 | - | } | |
| 178 | + | append_facet_filters(&mut query, filters, FacetAxis::Tag, has_search, short_query); | |
| 179 | 179 | ||
| 180 | 180 | query.push_str(" GROUP BY t.id, t.name, t.slug ORDER BY count DESC"); | |
| 181 | 181 | ||
| 182 | 182 | let counts = sqlx::query_as::<_, DbTagCount>(&query) | |
| 183 | - | .bind(search_term.unwrap_or("")) | |
| 184 | - | .bind(item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 183 | + | .bind(search_term.as_deref().unwrap_or("")) | |
| 184 | + | .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default()) | |
| 185 | + | .bind(filters.min_price.unwrap_or(0)) | |
| 186 | + | .bind(filters.max_price.unwrap_or(i32::MAX)) | |
| 187 | + | .bind(filters.tag.unwrap_or("")) | |
| 185 | 188 | .fetch_all(pool) | |
| 186 | 189 | .await?; | |
| 187 | 190 |
| @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; | |||
| 15 | 15 | use crate::{ | |
| 16 | 16 | auth::MaybeUserUnverified, | |
| 17 | 17 | constants, | |
| 18 | - | db::{self, discover::DiscoverFilters, DiscoverSort, ItemType}, | |
| 18 | + | db::{self, discover::DiscoverFilters, AiTierFilter, DiscoverSort, ItemType}, | |
| 19 | 19 | error::Result, | |
| 20 | 20 | helpers::get_csrf_token, | |
| 21 | 21 | templates::*, | |
| @@ -34,7 +34,19 @@ type FacetBundle = ( | |||
| 34 | 34 | ||
| 35 | 35 | /// Cache key: the filter inputs every facet query keys off. Same filters -> | |
| 36 | 36 | /// identical viewer-independent counts, so they can be shared across viewers. | |
| 37 | - | type FacetKey = (Option<String>, Option<ItemType>, Option<String>, Option<i32>, Option<i32>); | |
| 37 | + | /// | |
| 38 | + | /// `ai_tier` is part of the key. It was omitted while no facet query applied the | |
| 39 | + | /// AI-tier filter, which made it harmless; the moment the facets started | |
| 40 | + | /// cross-applying it, leaving it out would have served one tier's counts to | |
| 41 | + | /// another for up to `FACET_CACHE_TTL`. | |
| 42 | + | type FacetKey = ( | |
| 43 | + | Option<String>, | |
| 44 | + | Option<ItemType>, | |
| 45 | + | Option<String>, | |
| 46 | + | Option<i32>, | |
| 47 | + | Option<i32>, | |
| 48 | + | Option<AiTierFilter>, | |
| 49 | + | ); | |
| 38 | 50 | ||
| 39 | 51 | /// Short TTL for the facet memo. The discover facets are the hottest, most | |
| 40 | 52 | /// cacheable aggregate on the busiest public page; recomputing five full-catalog | |
| @@ -52,21 +64,14 @@ static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> = | |||
| 52 | 64 | /// On a miss the four queries still run (concurrently); the memo makes the common | |
| 53 | 65 | /// case — repeated anonymous loads of the same filter view — a single map lookup | |
| 54 | 66 | /// instead of five full-catalog aggregate scans holding five pool connections. | |
| 55 | - | #[allow(clippy::too_many_arguments)] | |
| 56 | - | async fn cached_facets( | |
| 57 | - | db: &PgPool, | |
| 58 | - | search: Option<&str>, | |
| 59 | - | item_type: Option<ItemType>, | |
| 60 | - | tag: Option<&str>, | |
| 61 | - | min_price: Option<i32>, | |
| 62 | - | max_price: Option<i32>, | |
| 63 | - | ) -> Result<FacetBundle> { | |
| 67 | + | async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) -> Result<FacetBundle> { | |
| 64 | 68 | let key: FacetKey = ( | |
| 65 | - | search.map(str::to_string), | |
| 66 | - | item_type, | |
| 67 | - | tag.map(str::to_string), | |
| 68 | - | min_price, | |
| 69 | - | max_price, | |
| 69 | + | filters.search.map(str::to_string), | |
| 70 | + | filters.item_type, | |
| 71 | + | filters.tag.map(str::to_string), | |
| 72 | + | filters.min_price, | |
| 73 | + | filters.max_price, | |
| 74 | + | filters.ai_tier, | |
| 70 | 75 | ); | |
| 71 | 76 | ||
| 72 | 77 | let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new())); | |
| @@ -78,10 +83,10 @@ async fn cached_facets( | |||
| 78 | 83 | } | |
| 79 | 84 | ||
| 80 | 85 | let bundle: FacetBundle = tokio::try_join!( | |
| 81 | - | db::discover::get_item_type_counts(db, search, tag, min_price, max_price), | |
| 82 | - | db::tags::get_tag_counts(db, search, item_type), | |
| 83 | - | db::discover::get_ai_tier_counts(db, search, item_type, tag), | |
| 84 | - | db::discover::get_price_range_counts(db, search, item_type, tag), | |
| 86 | + | db::discover::get_item_type_counts(db, filters), | |
| 87 | + | db::tags::get_tag_counts(db, filters), | |
| 88 | + | db::discover::get_ai_tier_counts(db, filters), | |
| 89 | + | db::discover::get_price_range_counts(db, filters), | |
| 85 | 90 | )?; | |
| 86 | 91 | ||
| 87 | 92 | if let Ok(mut guard) = cache.lock() { | |
| @@ -553,9 +558,17 @@ pub(super) async fn discover( | |||
| 553 | 558 | // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is | |
| 554 | 559 | // per-viewer, so it is computed fresh (and only when logged in). | |
| 555 | 560 | let viewer_id = maybe_user.as_ref().map(|u| u.id); | |
| 556 | - | let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets( | |
| 557 | - | &db, search_filter, item_type_filter, tag_filter, query.min_price, query.max_price, | |
| 558 | - | ).await?; | |
| 561 | + | let facet_filters = DiscoverFilters { | |
| 562 | + | search: search_filter, | |
| 563 | + | item_type: item_type_filter, | |
| 564 | + | tag: tag_filter, | |
| 565 | + | min_price: query.min_price, | |
| 566 | + | max_price: query.max_price, | |
| 567 | + | sort_by: None, | |
| 568 | + | ai_tier: f.ai_tier, | |
| 569 | + | }; | |
| 570 | + | let (type_counts, tag_counts, ai_counts, price_counts) = | |
| 571 | + | cached_facets(&db, &facet_filters).await?; | |
| 559 | 572 | // Only the first ~10 tag facets are rendered (see `.take(10)` below), so | |
| 560 | 573 | // test follow-membership against just those rather than fetching the | |
| 561 | 574 | // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1). |
| @@ -342,6 +342,7 @@ impl TestHarness { | |||
| 342 | 342 | wam_url: None, | |
| 343 | 343 | internal_shared_secret: opts.internal_shared_secret.clone(), | |
| 344 | 344 | cli_service_token: opts.cli_service_token.clone(), | |
| 345 | + | alerts_ingest_token: None, | |
| 345 | 346 | }, | |
| 346 | 347 | }; | |
| 347 | 348 |
| @@ -92,6 +92,7 @@ pub async fn run(config: LoadConfig) { | |||
| 92 | 92 | wam_url: None, | |
| 93 | 93 | internal_shared_secret: None, | |
| 94 | 94 | cli_service_token: None, | |
| 95 | + | alerts_ingest_token: None, | |
| 95 | 96 | }, | |
| 96 | 97 | }; | |
| 97 | 98 |
| @@ -303,3 +303,154 @@ async fn tag_tree_endpoint_renders() { | |||
| 303 | 303 | let resp = h.client.get("/discover/tags").await; | |
| 304 | 304 | assert!(resp.status.is_success(), "GET /discover/tags: {}", resp.status); | |
| 305 | 305 | } | |
| 306 | + | ||
| 307 | + | // --------------------------------------------------------------------------- | |
| 308 | + | // Facet-count correctness | |
| 309 | + | // | |
| 310 | + | // The sidebar counts are a separate query family from the result set, and they | |
| 311 | + | // had drifted from it in three ways. These pin each one. Note that every other | |
| 312 | + | // discover test in this file passed while all three were live: nothing here | |
| 313 | + | // exercised a facet count, so the bugs were invisible to the suite. | |
| 314 | + | // --------------------------------------------------------------------------- | |
| 315 | + | ||
| 316 | + | /// Attach `slug` to `item_id`, creating the tag and its dot-path ancestry. | |
| 317 | + | async fn tag_item(h: &TestHarness, item_id: &str, slug: &str) { | |
| 318 | + | let mut parent: Option<uuid::Uuid> = None; | |
| 319 | + | let segments: Vec<&str> = slug.split('.').collect(); | |
| 320 | + | for depth in 1..=segments.len() { | |
| 321 | + | let path = segments[..depth].join("."); | |
| 322 | + | let name = segments[depth - 1]; | |
| 323 | + | let id: uuid::Uuid = sqlx::query_scalar( | |
| 324 | + | "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \ | |
| 325 | + | ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id", | |
| 326 | + | ) | |
| 327 | + | .bind(name) | |
| 328 | + | .bind(&path) | |
| 329 | + | .bind(parent) | |
| 330 | + | .fetch_one(&h.db) | |
| 331 | + | .await | |
| 332 | + | .expect("upsert tag"); | |
| 333 | + | parent = Some(id); | |
| 334 | + | } | |
| 335 | + | sqlx::query( | |
| 336 | + | "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING", | |
| 337 | + | ) | |
| 338 | + | .bind(item_id) | |
| 339 | + | .bind(parent.expect("leaf tag id")) | |
| 340 | + | .execute(&h.db) | |
| 341 | + | .await | |
| 342 | + | .expect("attach tag"); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | fn filters_for<'a>(tag: Option<&'a str>) -> makenotwork::db::discover::DiscoverFilters<'a> { | |
| 346 | + | makenotwork::db::discover::DiscoverFilters { | |
| 347 | + | search: None, | |
| 348 | + | item_type: None, | |
| 349 | + | tag, | |
| 350 | + | min_price: None, | |
| 351 | + | max_price: None, | |
| 352 | + | sort_by: None, | |
| 353 | + | ai_tier: None, | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /// A tag filter matches the whole subtree, not just direct children. | |
| 358 | + | /// | |
| 359 | + | /// The result query has always matched descendants via `tags.path LIKE | |
| 360 | + | /// 'prefix.%'`, but the facet counts used `parent_id = (SELECT id ... )`, which | |
| 361 | + | /// stops at one level. An item tagged `audio.genre.electronic` therefore | |
| 362 | + | /// appeared in the results for `?tag=audio` while contributing 0 to the | |
| 363 | + | /// sidebar counts beside them. | |
| 364 | + | #[tokio::test] | |
| 365 | + | async fn facet_counts_match_the_whole_tag_subtree() { | |
| 366 | + | let mut h = TestHarness::new().await; | |
| 367 | + | let (_, item_id) = make_discoverable_item(&mut h, "facetdepth", "Deep Tagged", "audio").await; | |
| 368 | + | tag_item(&h, &item_id, "audio.genre.electronic").await; | |
| 369 | + | ||
| 370 | + | // `audio` is two levels above the item's tag. | |
| 371 | + | let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters_for(Some("audio"))) | |
| 372 | + | .await | |
| 373 | + | .expect("item type counts"); | |
| 374 | + | let total: i64 = counts.iter().map(|c| c.count).sum(); | |
| 375 | + | ||
| 376 | + | assert_eq!( | |
| 377 | + | total, 1, | |
| 378 | + | "grandchild tag audio.genre.electronic must count under ?tag=audio \ | |
| 379 | + | (was 0 while the facets used parent_id instead of path)" | |
| 380 | + | ); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | /// The AI-tier filter constrains the facet counts. | |
| 384 | + | /// | |
| 385 | + | /// `ai_tier` was applied to the result set but to none of the four facet | |
| 386 | + | /// queries, so selecting "Handmade only" narrowed the listing while the sidebar | |
| 387 | + | /// kept reporting counts for the unfiltered catalog. | |
| 388 | + | #[tokio::test] | |
| 389 | + | async fn facet_counts_apply_the_ai_tier_filter() { | |
| 390 | + | let mut h = TestHarness::new().await; | |
| 391 | + | let (_, handmade) = make_discoverable_item(&mut h, "aihand", "Handmade One", "audio").await; | |
| 392 | + | let (_, assisted) = make_discoverable_item(&mut h, "aiasst", "Assisted One", "audio").await; | |
| 393 | + | sqlx::query("UPDATE items SET ai_tier = 'handmade' WHERE id = $1::uuid") | |
| 394 | + | .bind(&handmade) | |
| 395 | + | .execute(&h.db) | |
| 396 | + | .await | |
| 397 | + | .expect("set handmade"); | |
| 398 | + | sqlx::query("UPDATE items SET ai_tier = 'assisted' WHERE id = $1::uuid") | |
| 399 | + | .bind(&assisted) | |
| 400 | + | .execute(&h.db) | |
| 401 | + | .await | |
| 402 | + | .expect("set assisted"); | |
| 403 | + | ||
| 404 | + | let mut filters = filters_for(None); | |
| 405 | + | filters.ai_tier = Some(makenotwork::db::AiTierFilter::HandmadeOnly); | |
| 406 | + | let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters) | |
| 407 | + | .await | |
| 408 | + | .expect("item type counts"); | |
| 409 | + | let total: i64 = counts.iter().map(|c| c.count).sum(); | |
| 410 | + | ||
| 411 | + | assert_eq!( | |
| 412 | + | total, 1, | |
| 413 | + | "handmade_only must exclude the assisted item from the facet counts (was 2)" | |
| 414 | + | ); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// Tag counts obey the same visibility predicate as the results. | |
| 418 | + | /// | |
| 419 | + | /// `get_tag_counts` had no `users` join at all, so it never applied | |
| 420 | + | /// `is_sandbox = FALSE`, and it omitted `scan_status = 'clean'`. Both let it | |
| 421 | + | /// count items that discover would never display. | |
| 422 | + | #[tokio::test] | |
| 423 | + | async fn tag_counts_exclude_sandbox_and_unscanned_items() { | |
| 424 | + | let mut h = TestHarness::new().await; | |
| 425 | + | let (sandbox_user, sandboxed) = | |
| 426 | + | make_discoverable_item(&mut h, "tagsandbox", "Sandbox Item", "audio").await; | |
| 427 | + | let (_, quarantined) = | |
| 428 | + | make_discoverable_item(&mut h, "tagquar", "Quarantined Item", "audio").await; | |
| 429 | + | let (_, visible) = make_discoverable_item(&mut h, "tagok", "Visible Item", "audio").await; | |
| 430 | + | for id in [&sandboxed, &quarantined, &visible] { | |
| 431 | + | tag_item(&h, id, "audio.genre.electronic").await; | |
| 432 | + | } | |
| 433 | + | sqlx::query("UPDATE users SET is_sandbox = true WHERE id = $1::uuid") | |
| 434 | + | .bind(&sandbox_user) | |
| 435 | + | .execute(&h.db) | |
| 436 | + | .await | |
| 437 | + | .expect("sandbox the user"); | |
| 438 | + | sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid") | |
| 439 | + | .bind(&quarantined) | |
| 440 | + | .execute(&h.db) | |
| 441 | + | .await | |
| 442 | + | .expect("quarantine the item"); | |
| 443 | + | ||
| 444 | + | let counts = makenotwork::db::tags::get_tag_counts(&h.db, &filters_for(None)) | |
| 445 | + | .await | |
| 446 | + | .expect("tag counts"); | |
| 447 | + | let leaf = counts | |
| 448 | + | .iter() | |
| 449 | + | .find(|c| c.tag_slug == "audio.genre.electronic") | |
| 450 | + | .expect("leaf tag counted"); | |
| 451 | + | ||
| 452 | + | assert_eq!( | |
| 453 | + | leaf.count, 1, | |
| 454 | + | "only the visible item may count; sandbox + quarantined must be excluded (was 3)" | |
| 455 | + | ); | |
| 456 | + | } |