@mohasinac/appkit 4.39.0 → 4.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/_internal/server/jobs/core/countersReconcile.js +21 -0
  2. package/dist/_internal/server/jobs/core/onProductWrite.js +35 -1
  3. package/dist/features/categories/components/CategoryDetailPageView.js +17 -5
  4. package/dist/features/categories/repository/categories.repository.d.ts +65 -1
  5. package/dist/features/categories/repository/categories.repository.js +245 -77
  6. package/dist/features/categories/types/index.d.ts +2 -2
  7. package/dist/features/reviews/repository/reviews.repository.d.ts +1 -1
  8. package/dist/features/reviews/repository/reviews.repository.js +2 -2
  9. package/dist/features/stores/api/[storeSlug]/reviews/route.js +50 -13
  10. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.d.ts +2 -0
  11. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +145 -0
  12. package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.d.ts +2 -0
  13. package/dist/features/tester/seed-data/authored/admin__firebase-function-effects.js +234 -0
  14. package/dist/features/tester/seed-data/authored/buying__buying-checkout.js +32 -0
  15. package/dist/features/tester/seed-data/authored/buying__cart.js +60 -0
  16. package/dist/features/tester/seed-data/authored/buying__order-detail-actions.d.ts +2 -0
  17. package/dist/features/tester/seed-data/authored/buying__order-detail-actions.js +138 -0
  18. package/dist/features/tester/seed-data/authored/buying__wishlist-history.js +30 -0
  19. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.d.ts +2 -0
  20. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +145 -0
  21. package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.d.ts +2 -0
  22. package/dist/features/tester/seed-data/authored/content-discovery__event-detail-subroutes.js +154 -0
  23. package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.d.ts +2 -0
  24. package/dist/features/tester/seed-data/authored/content-discovery__store-reviews-aggregate.js +88 -0
  25. package/dist/features/tester/seed-data/authored/index.js +22 -0
  26. package/dist/features/tester/seed-data/authored/public-pages__help-how-it-works.js +134 -7
  27. package/dist/features/tester/seed-data/authored/public-pages__help-scams-guides-subpages.js +89 -0
  28. package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.d.ts +2 -0
  29. package/dist/features/tester/seed-data/authored/public-pages__newly-wired-browse-indexes.js +141 -0
  30. package/dist/features/tester/seed-data/authored/public-pages__store-tabs.d.ts +2 -0
  31. package/dist/features/tester/seed-data/authored/public-pages__store-tabs.js +115 -0
  32. package/dist/features/tester/seed-data/authored/selling__digital-content-delivery.d.ts +2 -0
  33. package/dist/features/tester/seed-data/authored/selling__digital-content-delivery.js +148 -0
  34. package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.d.ts +2 -0
  35. package/dist/features/tester/seed-data/authored/selling__listing-edit-roundtrip.js +118 -0
  36. package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.d.ts +2 -0
  37. package/dist/features/tester/seed-data/authored/selling__seller-orders-at-scale.js +69 -0
  38. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +471 -0
  39. package/dist/styles.css +1 -1
  40. package/dist/tailwind-utilities.css +1 -1
  41. package/dist/ui/components/tabs-overflow.js +30 -7
  42. package/package.json +1 -1
@@ -92,6 +92,27 @@ function tallyProduct(productId, data, tallies, parentsOf) {
92
92
  * Reads are bounded by pagination rather than by `.limit(QUERY_LIMIT)`, which
93
93
  * silently recounted a subset the moment the catalogue passed 1,000 published
94
94
  * rows — a truncated recount is indistinguishable from a correct one.
95
+ *
96
+ * 🛑 THIS IS THE ONLY RECOUNT. `appkit/scripts/backfill-category-metrics.mjs`
97
+ * was a second implementation of exactly this, reachable as
98
+ * `npm run categories:backfill-metrics`, and it was wrong four ways: it wrote the
99
+ * descendant rollup into `productCount` (the own count — Root Cause 102, the bug
100
+ * this file's own/rollup split exists to fix), blanked `productIds` on every
101
+ * ancestor, enumerated only categories that HAVE products so a row that dropped
102
+ * to zero kept its stale count, and — worst — wrote any row that was both a leaf
103
+ * with products AND an ancestor twice, the ancestor pass silently overwriting its
104
+ * own count. It was deleted 2026-09-14 rather than repaired: a second recount
105
+ * that disagrees with this one is a way to *introduce* drift while believing you
106
+ * are fixing it. If a manual run is ever needed, invoke this job — do not write
107
+ * another script.
108
+ *
109
+ * As of 2026-09-14 this is a BACKSTOP, not the maintainer. `onProductWrite`
110
+ * (products → categories) and `CategoriesRepository.reparentSubtree`
111
+ * (structural moves + deletes) keep the counters correct in real time; this pass
112
+ * exists because a `batch.update()` against a dangling ancestor id throws and
113
+ * loses every increment in that batch. A non-zero `drifted` in its log is
114
+ * therefore a real signal that one of those paths missed something — it is not
115
+ * routine.
95
116
  */
96
117
  async function reconcileCategories(ctx) {
97
118
  // Every category row, so rows that dropped to zero are still reset.
@@ -104,8 +104,42 @@ async function dispatchProductWriteEvent(p) {
104
104
  if (wasPublished && isPublished) {
105
105
  const categoryMoved = beforeCategory !== afterCategory;
106
106
  const brandMoved = beforeBrand !== afterBrand;
107
- if (!categoryMoved && !brandMoved)
107
+ /*
108
+ * A listing can also change WHICH counter it belongs in without moving at
109
+ * all: flipping listingType between auction and anything else has to move
110
+ * the item from `auctionCount` to `productCount` on the same rows. That is
111
+ * tested independently for the same reason category and brand are — it
112
+ * happens on its own, and when it did, this branch returned early and left
113
+ * the item counted as an auction forever.
114
+ */
115
+ const typeChanged = beforeIsAuction !== isAuction;
116
+ if (!categoryMoved && !brandMoved && !typeChanged)
108
117
  return;
118
+ /*
119
+ * A pure type flip: nothing moved, so the item leaves one counter and joins
120
+ * the other on the SAME rows. Staged as ONE call carrying both deltas rather
121
+ * than a -1 followed by a +1, because those would be two writes to the same
122
+ * document in one batch. Note the two deltas cancel, so `totalItemCount`
123
+ * correctly does not move — the item did not come or go, it changed shape.
124
+ */
125
+ if (!categoryMoved && !brandMoved) {
126
+ const parents = await getParentIds(afterCategory ?? "");
127
+ const productDelta = isAuction ? -1 : 1;
128
+ const auctionDelta = isAuction ? 1 : -1;
129
+ const batch = ctx.db.batch();
130
+ if (afterCategory) {
131
+ categoriesRepository.updateMetricsInBatch(batch, afterCategory, parents, productDelta, auctionDelta, productId);
132
+ }
133
+ if (afterBrand) {
134
+ categoriesRepository.updateMetricsInBatch(batch, afterBrand, [], productDelta, auctionDelta, productId);
135
+ }
136
+ await commit(batch, "Listing type changed", {
137
+ productId,
138
+ from: beforeIsAuction ? "auction" : "product",
139
+ to: isAuction ? "auction" : "product",
140
+ });
141
+ return;
142
+ }
109
143
  const [beforeParents, afterParents] = await Promise.all([
110
144
  getParentIds(categoryMoved ? beforeCategory ?? "" : ""),
111
145
  getParentIds(categoryMoved ? afterCategory ?? "" : ""),
@@ -57,10 +57,18 @@ export async function CategoryDetailPageView({ slug }) {
57
57
  .listByType("bundle", { activeOnly: true, limit: 50 })
58
58
  .then(hidePublicTestData), { route: "/categories/[slug]", key: "category.bundles", fallback: [] })
59
59
  : Promise.resolve([]),
60
+ /*
61
+ * Every DESCENDANT, not just the direct children this used to fetch. A
62
+ * four-tier tree showed only tier+1 here, so the whole lower half of the
63
+ * catalogue was unreachable from the page that is supposed to be its index —
64
+ * you had to already know a sub-sub-category existed to navigate to it.
65
+ * `getDescendants` is one array-contains query over `parentIds`, which holds
66
+ * the full ancestor chain, so depth costs nothing extra.
67
+ */
60
68
  category?.id
61
- ? safeRead(() => categoriesRepository.getChildren(category.id), {
69
+ ? safeRead(() => categoriesRepository.getDescendants(category.id), {
62
70
  route: "/categories/[slug]",
63
- key: "category.children",
71
+ key: "category.descendants",
64
72
  fallback: [],
65
73
  })
66
74
  : Promise.resolve([]),
@@ -153,7 +161,11 @@ export async function CategoryDetailPageView({ slug }) {
153
161
  const preOrderCount = counts["pre-orders"] ?? 0;
154
162
  const coverImage = category?.display?.coverImage;
155
163
  const hasCover = Boolean(coverImage);
156
- return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
157
- // audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
158
- className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
164
+ return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
165
+ .sort((a, b) => a - b)
166
+ .map((tier) => (_jsxs(Div, { padding: "b-2xs", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", className: "mb-[var(--appkit-space-1-5)]", children: tier - (category?.tier ?? 0) === 1
167
+ ? "Subcategories"
168
+ : `${tier - (category?.tier ?? 0)} levels down` }), _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.filter((c) => c.tier === tier).map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
169
+ // audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
170
+ className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) })] }, tier))) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
159
171
  }
@@ -20,9 +20,73 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
20
20
  * listing/count to include products filed under any of its children.
21
21
  */
22
22
  getDescendantIds(categoryId: string): Promise<string[]>;
23
+ /**
24
+ * Every descendant DOCUMENT at any depth.
25
+ *
26
+ * 🛑 PAGINATED, DELIBERATELY. This query used to carry a bare `.limit(100)`,
27
+ * which silently truncated the subtree — acceptable-ish for a display list,
28
+ * and corrupting for `reparentSubtree`, which rewrites exactly what this
29
+ * returns. A move that saw 100 of 130 descendants would leave the other 30
30
+ * pointing at the old ancestors with no error anywhere. A partial answer to
31
+ * "what is beneath this node" is worse than a slow one.
32
+ */
33
+ getDescendants(categoryId: string): Promise<CategoryDocument[]>;
23
34
  getFeaturedCategories(): Promise<CategoryDocument[]>;
24
35
  getBrandCategories(limit?: number): Promise<CategoryDocument[]>;
25
- updateMetrics(categoryId: string, productDelta: number, auctionDelta: number, productId?: string): Promise<void>;
36
+ /**
37
+ * Move `categoryId` under `newParentId`, rewriting the WHOLE subtree.
38
+ *
39
+ * 🛑 THE DESCENDANTS ARE THE POINT. The previous `moveCategory` rewrote only
40
+ * the moved node's own hierarchy fields, so after a move every descendant
41
+ * still claimed its OLD ancestors — which breaks the `array-contains` subtree
42
+ * query, the category page's own listing, and `ProductRepository.deriveTaxonomy`
43
+ * (it reads the leaf's `parentIds` to build each product's `categorySlugs`
44
+ * chain). That is a data-corruption bug wearing the costume of a stale count.
45
+ *
46
+ * 🛑 AND THE METRIC DELTAS LIVE HERE, NOT IN A TRIGGER. `onCategoryWrite` is a
47
+ * `categories` trigger that writes `categories`, and it is safe only because
48
+ * every write it makes is gated on `parentIds` CHANGING — a structural guard.
49
+ * A `metrics.*` write is not covered by that gate, so putting rollup
50
+ * maintenance there is the Root Cause 92 shape that recursed 1,017,548 times
51
+ * in 24 hours. Doing it in the same batch as the structural change has no
52
+ * cycle to guard against in the first place.
53
+ *
54
+ * Returns the ids it rewrote, so a caller can log or verify the blast radius.
55
+ */
56
+ reparentSubtree(categoryId: string, newParentId: string | null): Promise<string[]>;
57
+ /**
58
+ * Re-derive `categorySlugs` for every product inside a moved subtree.
59
+ *
60
+ * 🛑 WITHOUT THIS THE MOVE IS ONLY HALF DONE. `categorySlugs` is a
61
+ * DENORMALISED COPY of the ancestor chain, written by
62
+ * `ProductRepository.deriveTaxonomy` at product-write time. Moving a category
63
+ * changes its descendants' chains but writes no product, so every product in
64
+ * the subtree keeps pointing at the OLD ancestors — it stays listed under the
65
+ * category it left and never appears under the one it joined. That is the same
66
+ * unreachable-listing bug the move is supposed to fix, one level down.
67
+ *
68
+ * Metric-neutral, and that is load-bearing: `onProductWrite` keys its re-file
69
+ * branch on the LEAF (`categorySlugs[0]`), which does not change when an
70
+ * ancestor moves. So these writes early-return in the trigger and apply no
71
+ * delta — the rollup was already moved by `reparentSubtree`. If this ever
72
+ * started changing the leaf, it would double-count.
73
+ *
74
+ * Reads `products` through `this.db` rather than importing ProductRepository:
75
+ * no repository in this codebase imports another feature's, because that is
76
+ * the import chain that drags firebase-admin into a client bundle.
77
+ */
78
+ private rewriteProductChains;
79
+ /**
80
+ * Commit `writes` in chunks. Firestore caps a batch at 500 operations, and a
81
+ * subtree rewrite plus its ancestor deltas can exceed that on a deep forest.
82
+ */
83
+ private commitChunked;
84
+ /**
85
+ * The public move. Everything real lives in `reparentSubtree` so the
86
+ * delete-cascade path and this share one implementation — two copies of a
87
+ * subtree rewrite would drift, and only one of them would be the one anybody
88
+ * tested.
89
+ */
26
90
  moveCategory(input: CategoryMoveInput): Promise<CategoryDocument>;
27
91
  buildTree(rootId?: string): Promise<CategoryTreeNode[]>;
28
92
  toggleFeatured(categoryId: string, featured: boolean): Promise<CategoryDocument>;
@@ -169,13 +169,42 @@ export class CategoriesRepository extends BaseRepository {
169
169
  * listing/count to include products filed under any of its children.
170
170
  */
171
171
  async getDescendantIds(categoryId) {
172
+ const docs = await this.getDescendants(categoryId);
173
+ return docs.map((d) => d.id);
174
+ }
175
+ /**
176
+ * Every descendant DOCUMENT at any depth.
177
+ *
178
+ * 🛑 PAGINATED, DELIBERATELY. This query used to carry a bare `.limit(100)`,
179
+ * which silently truncated the subtree — acceptable-ish for a display list,
180
+ * and corrupting for `reparentSubtree`, which rewrites exactly what this
181
+ * returns. A move that saw 100 of 130 descendants would leave the other 30
182
+ * pointing at the old ancestors with no error anywhere. A partial answer to
183
+ * "what is beneath this node" is worse than a slow one.
184
+ */
185
+ async getDescendants(categoryId) {
172
186
  try {
173
- const snapshot = await this.db
174
- .collection(this.collection)
175
- .where("parentIds", "array-contains", categoryId)
176
- .limit(100)
177
- .get();
178
- return snapshot.docs.map((doc) => doc.id);
187
+ const out = [];
188
+ const pageSize = 300;
189
+ let cursor = null;
190
+ for (;;) {
191
+ let q = this.db
192
+ .collection(this.collection)
193
+ .where("parentIds", "array-contains", categoryId)
194
+ .orderBy("__name__")
195
+ .limit(pageSize);
196
+ if (cursor)
197
+ q = q.startAfter(cursor);
198
+ const page = await q.get();
199
+ if (page.empty)
200
+ break;
201
+ for (const doc of page.docs)
202
+ out.push(this.mapDoc(doc));
203
+ if (page.size < pageSize)
204
+ break;
205
+ cursor = page.docs[page.docs.length - 1];
206
+ }
207
+ return out;
179
208
  }
180
209
  catch (error) {
181
210
  void normalizeError(error);
@@ -215,89 +244,228 @@ export class CategoriesRepository extends BaseRepository {
215
244
  throw new DatabaseError(`Failed to retrieve brand categories: ${error instanceof Error ? error.message : "Unknown error"}`);
216
245
  }
217
246
  }
218
- async updateMetrics(categoryId, productDelta, auctionDelta, productId) {
219
- try {
220
- const category = await this.findByIdOrFail(categoryId);
221
- const batch = this.db.batch();
222
- const now = new Date();
223
- const categoryRef = this.db.collection(this.collection).doc(categoryId);
224
- const updates = {
225
- "metrics.productCount": increment(productDelta),
226
- "metrics.auctionCount": increment(auctionDelta),
227
- "metrics.totalProductCount": increment(productDelta),
228
- "metrics.totalAuctionCount": increment(auctionDelta),
229
- "metrics.totalItemCount": increment(productDelta + auctionDelta),
230
- "metrics.lastUpdated": now,
231
- updatedAt: now,
232
- };
233
- if (productId && productDelta !== 0) {
234
- updates["metrics.productIds"] =
235
- productDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
236
- }
237
- batch.update(categoryRef, updates);
238
- for (const ancestorId of category.parentIds) {
239
- const ancestorRef = this.db.collection(this.collection).doc(ancestorId);
240
- batch.update(ancestorRef, {
241
- "metrics.totalProductCount": increment(productDelta),
242
- "metrics.totalAuctionCount": increment(auctionDelta),
243
- "metrics.totalItemCount": increment(productDelta + auctionDelta),
244
- "metrics.lastUpdated": now,
245
- updatedAt: now,
247
+ /**
248
+ * Move `categoryId` under `newParentId`, rewriting the WHOLE subtree.
249
+ *
250
+ * 🛑 THE DESCENDANTS ARE THE POINT. The previous `moveCategory` rewrote only
251
+ * the moved node's own hierarchy fields, so after a move every descendant
252
+ * still claimed its OLD ancestors — which breaks the `array-contains` subtree
253
+ * query, the category page's own listing, and `ProductRepository.deriveTaxonomy`
254
+ * (it reads the leaf's `parentIds` to build each product's `categorySlugs`
255
+ * chain). That is a data-corruption bug wearing the costume of a stale count.
256
+ *
257
+ * 🛑 AND THE METRIC DELTAS LIVE HERE, NOT IN A TRIGGER. `onCategoryWrite` is a
258
+ * `categories` trigger that writes `categories`, and it is safe only because
259
+ * every write it makes is gated on `parentIds` CHANGING — a structural guard.
260
+ * A `metrics.*` write is not covered by that gate, so putting rollup
261
+ * maintenance there is the Root Cause 92 shape that recursed 1,017,548 times
262
+ * in 24 hours. Doing it in the same batch as the structural change has no
263
+ * cycle to guard against in the first place.
264
+ *
265
+ * Returns the ids it rewrote, so a caller can log or verify the blast radius.
266
+ */
267
+ async reparentSubtree(categoryId, newParentId) {
268
+ const node = await this.findByIdOrFail(categoryId);
269
+ const newParent = newParentId
270
+ ? await this.findByIdOrFail(newParentId)
271
+ : null;
272
+ const oldParentId = node.parentIds[node.parentIds.length - 1] ?? null;
273
+ if (oldParentId === newParentId)
274
+ return [];
275
+ const descendants = await this.getDescendants(categoryId);
276
+ /*
277
+ * Cycle check. `isValidCategoryMove` only catches self-parenting and
278
+ * direct-child-as-parent, so a move onto a DEEPER descendant slips past it
279
+ * and detaches that whole branch from the forest with no error.
280
+ */
281
+ if (newParentId && descendants.some((d) => d.id === newParentId)) {
282
+ throw new DatabaseError(`Invalid category move: ${newParentId} is a descendant of ${categoryId}`);
283
+ }
284
+ if (!isValidCategoryMove(categoryId, newParentId, node)) {
285
+ throw new DatabaseError("Invalid category move: circular reference detected");
286
+ }
287
+ const now = new Date();
288
+ const writes = [];
289
+ /*
290
+ * Recompute top-down. A descendant's IMMEDIATE parent never changes when an
291
+ * ancestor moves, so each node's new fields derive from its parent's new
292
+ * fields — one pass in tier order, no recursion and no re-reads.
293
+ */
294
+ const rebuilt = new Map();
295
+ const movedFields = calculateCategoryFields(newParent, node.name, categoryId);
296
+ rebuilt.set(categoryId, { ...node, ...movedFields });
297
+ writes.push({ id: categoryId, data: { ...movedFields, updatedAt: now } });
298
+ for (const child of [...descendants].sort((a, b) => a.tier - b.tier)) {
299
+ const parentId = child.parentIds[child.parentIds.length - 1] ?? "";
300
+ const parent = rebuilt.get(parentId);
301
+ // A descendant whose parent is missing from the subtree means the chain is
302
+ // already broken; leave it alone rather than writing a guess over it.
303
+ if (!parent)
304
+ continue;
305
+ const fields = calculateCategoryFields(parent, child.name, child.id);
306
+ rebuilt.set(child.id, { ...child, ...fields });
307
+ writes.push({ id: child.id, data: { ...fields, updatedAt: now } });
308
+ }
309
+ /*
310
+ * Rollup deltas, NETTED PER ANCESTOR.
311
+ *
312
+ * The old and new chains usually share ancestors (a move within one root),
313
+ * and Firestore rejects two updates to the same document in one batch — so a
314
+ * naive `-total` on the old chain followed by `+total` on the new one either
315
+ * throws or double-counts. Net them first and drop the zeros; for a move
316
+ * between siblings that leaves only two documents to touch.
317
+ *
318
+ * The delta is the moved node's own ROLLUP, not a subtree rescan: O(depth).
319
+ * The moved node's own counts do not change — only its ancestors' rollups.
320
+ */
321
+ const m = node.metrics ?? {};
322
+ const dProducts = m?.totalProductCount ?? 0;
323
+ const dAuctions = m?.totalAuctionCount ?? 0;
324
+ if (dProducts !== 0 || dAuctions !== 0) {
325
+ const oldChain = node.parentIds;
326
+ const newChain = newParent ? [...newParent.parentIds, newParent.id] : [];
327
+ const net = new Map();
328
+ for (const id of oldChain)
329
+ net.set(id, (net.get(id) ?? 0) - 1);
330
+ for (const id of newChain)
331
+ net.set(id, (net.get(id) ?? 0) + 1);
332
+ for (const [id, sign] of net) {
333
+ if (sign === 0)
334
+ continue;
335
+ writes.push({
336
+ id,
337
+ data: {
338
+ "metrics.totalProductCount": increment(dProducts * sign),
339
+ "metrics.totalAuctionCount": increment(dAuctions * sign),
340
+ "metrics.totalItemCount": increment((dProducts + dAuctions) * sign),
341
+ "metrics.lastUpdated": now,
342
+ updatedAt: now,
343
+ },
246
344
  });
247
345
  }
248
- await batch.commit();
346
+ }
347
+ if (oldParentId) {
348
+ writes.push({
349
+ id: oldParentId,
350
+ data: { childrenIds: arrayRemove(categoryId), updatedAt: now },
351
+ });
352
+ }
353
+ if (newParentId) {
354
+ writes.push({
355
+ id: newParentId,
356
+ data: {
357
+ childrenIds: arrayUnion(categoryId),
358
+ isLeaf: false,
359
+ updatedAt: now,
360
+ },
361
+ });
362
+ }
363
+ try {
364
+ await this.commitChunked(writes);
249
365
  }
250
366
  catch (error) {
251
367
  void normalizeError(error);
252
- throw new DatabaseError(`Failed to update category metrics: ${error instanceof Error ? error.message : "Unknown error"}`);
368
+ throw new DatabaseError(`Failed to move category ${categoryId}: ${error instanceof Error ? error.message : "Unknown error"}`);
369
+ }
370
+ /*
371
+ * A parent that just lost its last child becomes a leaf again. Done after the
372
+ * commit because `childrenIds` is an arrayRemove above — reading it before
373
+ * would see the stale array.
374
+ */
375
+ if (oldParentId) {
376
+ const oldParent = await this.findById(oldParentId);
377
+ if (oldParent && (oldParent.childrenIds?.length ?? 0) === 0) {
378
+ await this.db
379
+ .collection(this.collection)
380
+ .doc(oldParentId)
381
+ .update({ isLeaf: true, updatedAt: new Date() });
382
+ }
253
383
  }
384
+ await this.rewriteProductChains(rebuilt);
385
+ return writes.map((w) => w.id);
254
386
  }
255
- async moveCategory(input) {
256
- try {
257
- const { categoryId, newParentId } = input;
258
- const category = await this.findByIdOrFail(categoryId);
259
- if (!isValidCategoryMove(categoryId, newParentId, category)) {
260
- throw new DatabaseError("Invalid category move: circular reference detected");
261
- }
262
- let newParent = null;
263
- if (newParentId) {
264
- newParent = await this.findByIdOrFail(newParentId);
387
+ /**
388
+ * Re-derive `categorySlugs` for every product inside a moved subtree.
389
+ *
390
+ * 🛑 WITHOUT THIS THE MOVE IS ONLY HALF DONE. `categorySlugs` is a
391
+ * DENORMALISED COPY of the ancestor chain, written by
392
+ * `ProductRepository.deriveTaxonomy` at product-write time. Moving a category
393
+ * changes its descendants' chains but writes no product, so every product in
394
+ * the subtree keeps pointing at the OLD ancestors — it stays listed under the
395
+ * category it left and never appears under the one it joined. That is the same
396
+ * unreachable-listing bug the move is supposed to fix, one level down.
397
+ *
398
+ * Metric-neutral, and that is load-bearing: `onProductWrite` keys its re-file
399
+ * branch on the LEAF (`categorySlugs[0]`), which does not change when an
400
+ * ancestor moves. So these writes early-return in the trigger and apply no
401
+ * delta — the rollup was already moved by `reparentSubtree`. If this ever
402
+ * started changing the leaf, it would double-count.
403
+ *
404
+ * Reads `products` through `this.db` rather than importing ProductRepository:
405
+ * no repository in this codebase imports another feature's, because that is
406
+ * the import chain that drags firebase-admin into a client bundle.
407
+ */
408
+ async rewriteProductChains(rebuilt) {
409
+ const writes = [];
410
+ for (const [categoryId, cat] of rebuilt) {
411
+ // Self first, root last — byte-identical to what deriveTaxonomy produces.
412
+ const chain = [categoryId, ...(cat.parentIds ?? []).slice().reverse()];
413
+ /*
414
+ * Byte-identical to what deriveTaxonomy produces, INCLUDING the falsy
415
+ * filter — these two must agree, or a product's names chain would differ
416
+ * depending on whether it was last touched by a product write or a
417
+ * category move, and nothing would report the difference.
418
+ */
419
+ const names = [
420
+ cat.name,
421
+ ...(cat.ancestors ?? []).map((a) => a?.name).reverse(),
422
+ ].filter((n) => !!n);
423
+ const snap = await this.db
424
+ .collection("products")
425
+ .where(PRODUCT_FIELDS.CATEGORY, "==", categoryId)
426
+ .get();
427
+ for (const doc of snap.docs) {
428
+ writes.push({
429
+ id: doc.id,
430
+ data: { categorySlugs: chain, categoryNames: names, updatedAt: new Date() },
431
+ });
265
432
  }
266
- const hierarchyFields = calculateCategoryFields(newParent, category.name, categoryId);
267
- const oldParentId = category.parentIds.length > 0
268
- ? category.parentIds[category.parentIds.length - 1]
269
- : null;
433
+ }
434
+ if (!writes.length)
435
+ return;
436
+ const products = this.db.collection("products");
437
+ for (let i = 0; i < writes.length; i += 400) {
270
438
  const batch = this.db.batch();
271
- const now = new Date();
272
- const categoryRef = this.db.collection(this.collection).doc(categoryId);
273
- batch.update(categoryRef, {
274
- ...hierarchyFields,
275
- updatedAt: now,
276
- });
277
- if (oldParentId) {
278
- const oldParentRef = this.db
279
- .collection(this.collection)
280
- .doc(oldParentId);
281
- batch.update(oldParentRef, {
282
- childrenIds: arrayRemove(categoryId),
283
- updatedAt: now,
284
- });
285
- const oldParent = await this.findById(oldParentId);
286
- if (oldParent && oldParent.childrenIds.length === 1) {
287
- batch.update(oldParentRef, { isLeaf: true });
288
- }
439
+ for (const w of writes.slice(i, i + 400)) {
440
+ batch.update(products.doc(w.id), w.data);
289
441
  }
290
- if (newParentId) {
291
- const newParentRef = this.db
292
- .collection(this.collection)
293
- .doc(newParentId);
294
- batch.update(newParentRef, {
295
- childrenIds: arrayUnion(categoryId),
296
- isLeaf: false,
297
- updatedAt: now,
298
- });
442
+ await batch.commit();
443
+ }
444
+ }
445
+ /**
446
+ * Commit `writes` in chunks. Firestore caps a batch at 500 operations, and a
447
+ * subtree rewrite plus its ancestor deltas can exceed that on a deep forest.
448
+ */
449
+ async commitChunked(writes, chunkSize = 400) {
450
+ const colRef = this.db.collection(this.collection);
451
+ for (let i = 0; i < writes.length; i += chunkSize) {
452
+ const batch = this.db.batch();
453
+ for (const w of writes.slice(i, i + chunkSize)) {
454
+ batch.update(colRef.doc(w.id), w.data);
299
455
  }
300
456
  await batch.commit();
457
+ }
458
+ }
459
+ /**
460
+ * The public move. Everything real lives in `reparentSubtree` so the
461
+ * delete-cascade path and this share one implementation — two copies of a
462
+ * subtree rewrite would drift, and only one of them would be the one anybody
463
+ * tested.
464
+ */
465
+ async moveCategory(input) {
466
+ try {
467
+ const { categoryId, newParentId } = input;
468
+ await this.reparentSubtree(categoryId, newParentId ?? null);
301
469
  return this.findByIdOrFail(categoryId);
302
470
  }
303
471
  catch (error) {
@@ -21,11 +21,11 @@ export interface CategoryMetrics {
21
21
  totalItemCount: number;
22
22
  lastUpdated?: string;
23
23
  }
24
- export interface CategoryAncestor {
24
+ export type CategoryAncestor = {
25
25
  id: string;
26
26
  name: string;
27
27
  tier: number;
28
- }
28
+ };
29
29
  export interface CategoryItem {
30
30
  id: string;
31
31
  categoryType?: CategoryType;
@@ -37,7 +37,7 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
37
37
  * it can be reasoned about).
38
38
  */
39
39
  findApprovedByProduct(productId: string, limit?: number): Promise<ReviewDocument[]>;
40
- findApprovedByStore(storeId: string): Promise<ReviewDocument[]>;
40
+ findApprovedByStore(storeId: string, limit?: number): Promise<ReviewDocument[]>;
41
41
  findByUser(userId: string): Promise<ReviewDocument[]>;
42
42
  findApprovedByUser(userId: string): Promise<ReviewDocument[]>;
43
43
  findPending(): Promise<ReviewDocument[]>;
@@ -101,13 +101,13 @@ class ReviewRepository extends BaseRepository {
101
101
  const snapshot = await query.get();
102
102
  return snapshot.docs.map((doc) => this.mapDoc(doc));
103
103
  }
104
- async findApprovedByStore(storeId) {
104
+ async findApprovedByStore(storeId, limit = 50) {
105
105
  const snapshot = await this.db
106
106
  .collection(this.collection)
107
107
  .where(REVIEW_FIELDS.STORE_ID, "==", storeId)
108
108
  .where(REVIEW_FIELDS.STATUS, "==", "approved")
109
109
  .orderBy(REVIEW_FIELDS.CREATED_AT, "desc")
110
- .limit(50)
110
+ .limit(limit)
111
111
  .get();
112
112
  return snapshot.docs.map((doc) => this.mapDoc(doc));
113
113
  }