@mohasinac/appkit 4.37.1 → 4.39.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 (172) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/bundles/og.d.ts +0 -1
  6. package/dist/_internal/server/features/bundles/og.js +2 -18
  7. package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
  8. package/dist/_internal/server/features/catalogue/og.js +1 -9
  9. package/dist/_internal/server/features/checkout/actions.js +18 -0
  10. package/dist/_internal/server/features/classified/og.d.ts +0 -1
  11. package/dist/_internal/server/features/classified/og.js +1 -9
  12. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  13. package/dist/_internal/server/features/digital-code/download.js +128 -0
  14. package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
  15. package/dist/_internal/server/features/digital-code/og.js +1 -9
  16. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  17. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  18. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  19. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  20. package/dist/_internal/server/features/grouped/og.d.ts +7 -4
  21. package/dist/_internal/server/features/grouped/og.js +7 -6
  22. package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
  23. package/dist/_internal/server/features/item-requests/og.js +2 -18
  24. package/dist/_internal/server/features/live/og.d.ts +0 -1
  25. package/dist/_internal/server/features/live/og.js +1 -9
  26. package/dist/_internal/server/features/prize-draws/og.js +5 -10
  27. package/dist/_internal/server/features/products/og.js +5 -2
  28. package/dist/_internal/server/features/seo/sitemap.js +10 -2
  29. package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
  30. package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
  31. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  32. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  33. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  34. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  35. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  36. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  37. package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
  38. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
  39. package/dist/_internal/shared/listing-types/art/config.js +2 -0
  40. package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
  41. package/dist/_internal/shared/listing-types/auction/config.js +3 -0
  42. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/classified/config.js +3 -0
  44. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
  45. package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
  47. package/dist/_internal/shared/listing-types/live/config.js +3 -0
  48. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
  49. package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
  50. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
  51. package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
  52. package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
  53. package/dist/_internal/shared/listing-types/standard/config.js +2 -0
  54. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
  55. package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
  56. package/dist/client/observability/reportClientError.js +11 -1
  57. package/dist/client.d.ts +6 -0
  58. package/dist/client.js +10 -0
  59. package/dist/core/hooks/useSyncManager.js +11 -1
  60. package/dist/features/account/hooks/useNotifications.js +25 -5
  61. package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
  62. package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
  63. package/dist/features/auctions/components/LiveBidPrice.js +2 -2
  64. package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
  65. package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
  66. package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
  67. package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
  68. package/dist/features/auctions/hooks/useAuctions.js +12 -1
  69. package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
  70. package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
  71. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  72. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  73. package/dist/features/blog/repository/blog.repository.js +23 -1
  74. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
  75. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
  76. package/dist/features/categories/components/BundleCollage.js +10 -5
  77. package/dist/features/categories/components/BundleDetailView.js +7 -3
  78. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  79. package/dist/features/categories/components/CategoryProductsListing.js +4 -1
  80. package/dist/features/categories/components/CategoryTree.js +1 -1
  81. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  82. package/dist/features/categories/repository/categories.repository.js +31 -9
  83. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
  84. package/dist/features/contact/email.js +36 -1
  85. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
  86. package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
  87. package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
  88. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  89. package/dist/features/orders/repository/orders.repository.js +26 -9
  90. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
  91. package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
  92. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
  93. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
  94. package/dist/features/products/actions/product-actions.d.ts +9 -0
  95. package/dist/features/products/actions/product-actions.js +11 -0
  96. package/dist/features/products/columns/productTableColumns.js +2 -2
  97. package/dist/features/products/components/AuctionsIndexListing.js +4 -1
  98. package/dist/features/products/components/BidHistory.js +2 -2
  99. package/dist/features/products/components/CompareOverlay.js +2 -2
  100. package/dist/features/products/components/GroupMemberPicker.js +5 -5
  101. package/dist/features/products/components/ListingBottomActions.js +12 -3
  102. package/dist/features/products/components/MakeOfferButton.js +7 -1
  103. package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
  104. package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
  105. package/dist/features/products/components/PrizeDrawCollage.js +16 -5
  106. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  107. package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
  108. package/dist/features/products/components/PrizeRevealModal.js +2 -2
  109. package/dist/features/products/components/ProductDetailActions.js +10 -3
  110. package/dist/features/products/components/ProductFilters.js +2 -2
  111. package/dist/features/products/components/ProductGrid.js +5 -2
  112. package/dist/features/products/components/ProductsIndexListing.js +12 -1
  113. package/dist/features/products/components/ProductsIndexPageView.js +26 -0
  114. package/dist/features/products/components/ShowGroupSection.js +2 -2
  115. package/dist/features/products/components/SublistingCarouselSection.js +2 -2
  116. package/dist/features/products/constants/listing-tabs.d.ts +36 -12
  117. package/dist/features/products/constants/listing-tabs.js +38 -14
  118. package/dist/features/products/constants/sieve.d.ts +14 -0
  119. package/dist/features/products/constants/sieve.js +39 -0
  120. package/dist/features/products/repository/products.repository.d.ts +35 -0
  121. package/dist/features/products/repository/products.repository.js +116 -3
  122. package/dist/features/products/schemas/firestore.d.ts +37 -0
  123. package/dist/features/seller/actions/seller-actions.js +4 -13
  124. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  125. package/dist/features/seller/components/SellerProductShell.js +11 -2
  126. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  127. package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
  128. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  129. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  130. package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
  131. package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
  132. package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
  133. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  134. package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
  135. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  136. package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
  137. package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
  138. package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
  139. package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
  140. package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
  141. package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
  142. package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
  143. package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
  144. package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
  145. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
  146. package/dist/features/tester/utils/phases.js +78 -12
  147. package/dist/features/wishlist/components/WishlistPage.js +2 -2
  148. package/dist/index.d.ts +2 -1
  149. package/dist/index.js +4 -1
  150. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  151. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  152. package/dist/react/contexts/SessionContext.js +40 -1
  153. package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
  154. package/dist/react/hooks/useCanSeePrices.js +40 -0
  155. package/dist/seed/addresses-seed-data.js +36 -2
  156. package/dist/seed/users-seed-data.js +10 -2
  157. package/dist/seo/json-ld.d.ts +21 -0
  158. package/dist/seo/json-ld.js +45 -0
  159. package/dist/server.d.ts +3 -0
  160. package/dist/server.js +12 -0
  161. package/dist/styles.css +41 -0
  162. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  163. package/dist/ui/components/FilePickerButton.js +23 -0
  164. package/dist/ui/components/GatedPrice.d.ts +66 -0
  165. package/dist/ui/components/GatedPrice.js +80 -0
  166. package/dist/ui/components/GatedPrice.style.css +40 -0
  167. package/dist/ui/components/PriceDisplay.d.ts +12 -0
  168. package/dist/ui/components/PriceDisplay.js +17 -17
  169. package/dist/ui/components/index.style.css +1 -0
  170. package/dist/ui/index.d.ts +4 -0
  171. package/dist/ui/index.js +8 -0
  172. package/package.json +1 -1
@@ -1,10 +1,18 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
2
  import { getAdminAuth, getAdminRealtimeDb } from "../../../../providers/db-firebase";
3
+ import { RTDB_PATHS } from "../../../../providers/db-firebase/rtdb-paths";
3
4
  import { jobsRepository } from "../../../../repositories";
4
5
  import { batchDelete } from "../handlers/_helpers";
5
6
  const AUTH_STALE_MS = 3 * 60 * 1000;
6
7
  const PAYMENT_STALE_MS = 15 * 60 * 1000;
7
8
  const BULK_STALE_MS = 15 * 60 * 1000;
9
+ /**
10
+ * Longer than the other three on purpose: a tester batch can take ten minutes
11
+ * between triggering a send and asserting on it, and a ping deleted mid-batch
12
+ * would read as "no email was sent" — the exact false negative these records
13
+ * exist to prevent.
14
+ */
15
+ const EMAIL_STALE_MS = 60 * 60 * 1000;
8
16
  const JOBS_TTL_DAYS = 30;
9
17
  export async function runCleanupRtdbEvents(ctx) {
10
18
  ctx.logger.info("Starting RTDB events cleanup");
@@ -68,6 +76,38 @@ export async function runCleanupRtdbEvents(ctx) {
68
76
  void normalizeError(bulkErr);
69
77
  ctx.logger.error("Bulk events cleanup failed (non-fatal)", bulkErr);
70
78
  }
79
+ /*
80
+ * Tester email pings. Written only for the harness mailbox (see
81
+ * `send-recorder.ts`), so this is normally a no-op — but an unpruned RTDB
82
+ * channel is precisely the thing that quietly accumulates, and the Firebase
83
+ * budget notes `auction-bids/*` as the one family with no TTL. Do not make it
84
+ * the second.
85
+ *
86
+ * The Firestore row is the record and is NOT touched here; this only clears the
87
+ * ping.
88
+ */
89
+ try {
90
+ const emailSnap = await rtdb.ref(RTDB_PATHS.EMAIL_EVENTS).get();
91
+ if (emailSnap.exists()) {
92
+ const allEmailEvents = emailSnap.val();
93
+ const staleEmailIds = Object.entries(allEmailEvents)
94
+ .filter(([, node]) => {
95
+ const at = node.at ? Date.parse(node.at) : 0;
96
+ // An unparseable or missing timestamp is stale by definition: it can
97
+ // never age out on its own, so leaving it would pin the node forever.
98
+ return !Number.isFinite(at) || at === 0 || at < now - EMAIL_STALE_MS;
99
+ })
100
+ .map(([id]) => id);
101
+ if (staleEmailIds.length > 0) {
102
+ await Promise.all(staleEmailIds.map((id) => rtdb.ref(`${RTDB_PATHS.EMAIL_EVENTS}/${id}`).remove()));
103
+ ctx.logger.info("Email event pings removed", { count: staleEmailIds.length });
104
+ }
105
+ }
106
+ }
107
+ catch (emailErr) {
108
+ void normalizeError(emailErr);
109
+ ctx.logger.error("Email events cleanup failed (non-fatal)", emailErr);
110
+ }
71
111
  try {
72
112
  const staleJobRefs = await jobsRepository.getStaleFinishedRefs(JOBS_TTL_DAYS);
73
113
  if (staleJobRefs.length > 0) {
@@ -1,57 +1,170 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
2
  import { categoriesRepository, reviewRepository, storeRepository, } from "../../../../repositories";
3
3
  import { ProductStatusValues, PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
4
+ import { CATEGORIES_COLLECTION } from "../../../../features/categories/schemas/firestore";
4
5
  import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names";
5
6
  import { QUERY_LIMIT } from "../handlers/messages";
6
7
  const STORES_COLLECTION = "stores";
7
8
  const ORDERS_COLLECTION = "orders";
9
+ /** Page size for the products scan. Bounded reads, unbounded total coverage. */
10
+ const PAGE_SIZE = 500;
11
+ const emptyTally = () => ({
12
+ productIds: [],
13
+ auctionIds: [],
14
+ totalProducts: 0,
15
+ totalAuctions: 0,
16
+ });
17
+ /** Filed DIRECTLY under this row — `metrics.productCount` / `auctionCount`. */
18
+ function countOwn(t, productId, isAuction) {
19
+ if (isAuction)
20
+ t.auctionIds.push(productId);
21
+ else
22
+ t.productIds.push(productId);
23
+ }
24
+ /** This row or any ancestor — `metrics.totalProductCount` / `totalAuctionCount`. */
25
+ function countRollup(t, isAuction) {
26
+ if (!t)
27
+ return;
28
+ if (isAuction)
29
+ t.totalAuctions++;
30
+ else
31
+ t.totalProducts++;
32
+ }
33
+ /**
34
+ * Fold one published product into the tallies.
35
+ *
36
+ * A named function rather than an inner block: this is the only place the
37
+ * own-vs-rollup distinction is actually APPLIED, and burying it four levels deep
38
+ * inside a paginating loop is how it stopped being visible last time.
39
+ * Returns true when the product names a category nothing recognises.
40
+ */
41
+ function tallyProduct(productId, data, tallies, parentsOf) {
42
+ const isAuction = data.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
43
+ // `category` is @deprecated in favour of `categorySlugs[]`; mirror the live
44
+ // trigger's selection exactly so the two agree (onProductWrite.ts).
45
+ const leaf = (Array.isArray(data.categorySlugs) && data.categorySlugs[0]) ||
46
+ data.category ||
47
+ null;
48
+ let unknown = false;
49
+ if (leaf && !tallies.has(leaf)) {
50
+ unknown = true;
51
+ }
52
+ else if (leaf) {
53
+ countOwn(tallies.get(leaf), productId, isAuction);
54
+ // Roll up through self + every ancestor. `parentIds` already holds the
55
+ // WHOLE chain, so this is one pass rather than a walk.
56
+ for (const id of [leaf, ...(parentsOf.get(leaf) ?? [])]) {
57
+ countRollup(tallies.get(id), isAuction);
58
+ }
59
+ }
60
+ // Brands are a flat dimension: a brand row has no product children of its own
61
+ // beyond what points at it, so own === total by construction — which is why
62
+ // it is counted on both axes at once.
63
+ if (data.brandSlug && tallies.has(data.brandSlug)) {
64
+ countOwn(tallies.get(data.brandSlug), productId, isAuction);
65
+ countRollup(tallies.get(data.brandSlug), isAuction);
66
+ }
67
+ return unknown;
68
+ }
69
+ /**
70
+ * Recount every category and brand row from the products collection.
71
+ *
72
+ * 🛑 THREE THINGS THIS HAS TO GET RIGHT, each of which the previous version
73
+ * got wrong, and none of which produced an error:
74
+ *
75
+ * 1. **Own vs rollup are different numbers.** `metrics.productCount` is what is
76
+ * filed directly under a row; `metrics.totalProductCount` is that plus every
77
+ * descendant. The old code passed one number for both — see `setMetrics`'s
78
+ * header for the full account and the 19-of-65 measurement.
79
+ *
80
+ * 2. **Every row is written, including the ones that are now empty.** The old
81
+ * code built its map from products that EXIST, so a category whose last item
82
+ * was deleted was never visited and kept its stale count forever. Seeding
83
+ * `tallies` from the category list instead of from the products is the whole
84
+ * fix, and it is why the loop below walks `parentsOf` rather than `tallies`
85
+ * entries.
86
+ *
87
+ * 3. **Brands are counted too.** Brands are `categoryType:"brand"` rows in this
88
+ * same collection and `BrandDetailPageView` reads `metrics.productCount` off
89
+ * them — but nothing has ever written it. They hang off `brandSlug`, not off
90
+ * the category chain, so they get their own pass.
91
+ *
92
+ * Reads are bounded by pagination rather than by `.limit(QUERY_LIMIT)`, which
93
+ * silently recounted a subset the moment the catalogue passed 1,000 published
94
+ * rows — a truncated recount is indistinguishable from a correct one.
95
+ */
8
96
  async function reconcileCategories(ctx) {
9
- const snap = await ctx.db
10
- .collection(PRODUCT_COLLECTION)
11
- .where(ORDER_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED)
12
- .limit(QUERY_LIMIT)
13
- .get();
14
- ctx.logger.info(`[categories] ${snap.size} published products found`);
15
- const leafCounts = {};
16
- for (const doc of snap.docs) {
17
- const data = doc.data();
18
- // `category` is @deprecated in favor of `categorySlugs[]` — mirrors the
19
- // same effective-category selection as onProductWrite.ts's live trigger
20
- // so the nightly reconcile agrees with real-time updates instead of
21
- // silently no-op'ing on products written via categorySlugs-only paths.
22
- const catId = Array.isArray(data.categorySlugs) && data.categorySlugs.length > 0
23
- ? data.categorySlugs[0]
24
- : data.category;
25
- if (!catId)
26
- continue;
27
- if (!leafCounts[catId])
28
- leafCounts[catId] = { productIds: [], auctionIds: [] };
29
- if (data.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION)
30
- leafCounts[catId].auctionIds.push(doc.id);
31
- else
32
- leafCounts[catId].productIds.push(doc.id);
97
+ // Every category row, so rows that dropped to zero are still reset.
98
+ const catSnap = await ctx.db.collection(CATEGORIES_COLLECTION).get();
99
+ const parentsOf = new Map();
100
+ for (const d of catSnap.docs) {
101
+ parentsOf.set(d.id, d.data().parentIds ?? []);
33
102
  }
34
- const ancestorAggregates = {};
35
- let leafUpdated = 0;
36
- for (const [catId, { productIds, auctionIds }] of Object.entries(leafCounts)) {
37
- await categoriesRepository.setMetrics(catId, productIds.length, auctionIds.length, productIds, auctionIds);
38
- leafUpdated++;
39
- const parentIds = (await categoriesRepository.findById(catId))?.parentIds ?? [];
40
- for (const ancestorId of parentIds) {
41
- if (!ancestorAggregates[ancestorId]) {
42
- ancestorAggregates[ancestorId] = { productDelta: 0, auctionDelta: 0 };
103
+ const tallies = new Map();
104
+ for (const id of parentsOf.keys())
105
+ tallies.set(id, emptyTally());
106
+ let scanned = 0;
107
+ let unknownCategory = 0;
108
+ let cursor = null;
109
+ for (;;) {
110
+ let q = ctx.db
111
+ .collection(PRODUCT_COLLECTION)
112
+ .where(ORDER_FIELDS.STATUS, "==", ProductStatusValues.PUBLISHED)
113
+ .orderBy("__name__")
114
+ .limit(PAGE_SIZE);
115
+ if (cursor)
116
+ q = q.startAfter(cursor);
117
+ const page = await q.get();
118
+ if (page.empty)
119
+ break;
120
+ for (const doc of page.docs) {
121
+ scanned++;
122
+ if (tallyProduct(doc.id, doc.data(), tallies, parentsOf)) {
123
+ unknownCategory++;
43
124
  }
44
- ancestorAggregates[ancestorId].productDelta += productIds.length;
45
- ancestorAggregates[ancestorId].auctionDelta += auctionIds.length;
46
125
  }
126
+ cursor = page.docs[page.docs.length - 1];
127
+ if (page.size < PAGE_SIZE)
128
+ break;
47
129
  }
48
- const ancestorEntries = Object.entries(ancestorAggregates);
49
- for (const [ancestorId, { productDelta, auctionDelta }] of ancestorEntries) {
50
- await categoriesRepository.setMetrics(ancestorId, productDelta, auctionDelta, [], []);
130
+ let written = 0;
131
+ let drifted = 0;
132
+ for (const doc of catSnap.docs) {
133
+ const t = tallies.get(doc.id) ?? emptyTally();
134
+ const m = doc.data().metrics ?? {};
135
+ const same = (m.productCount ?? 0) === t.productIds.length &&
136
+ (m.auctionCount ?? 0) === t.auctionIds.length &&
137
+ (m.totalProductCount ?? 0) === t.totalProducts &&
138
+ (m.totalAuctionCount ?? 0) === t.totalAuctions;
139
+ // Skipping no-op writes keeps a 65-row nightly pass near-free against the
140
+ // 20k/day write budget — and makes `drifted` a real signal rather than a
141
+ // count of how many rows exist. A non-zero `drifted` on a quiet day means
142
+ // the live trigger missed something.
143
+ if (same)
144
+ continue;
145
+ drifted++;
146
+ try {
147
+ await categoriesRepository.setMetrics(doc.id, {
148
+ productCount: t.productIds.length,
149
+ auctionCount: t.auctionIds.length,
150
+ totalProductCount: t.totalProducts,
151
+ totalAuctionCount: t.totalAuctions,
152
+ productIds: t.productIds,
153
+ auctionIds: t.auctionIds,
154
+ });
155
+ written++;
156
+ }
157
+ catch (err) {
158
+ void normalizeError(err);
159
+ ctx.logger.error(`[categories] setMetrics failed for ${doc.id}`, err);
160
+ }
51
161
  }
52
162
  ctx.logger.info("[categories] reconciliation complete", {
53
- leafCategoriesUpdated: leafUpdated,
54
- ancestorCategoriesUpdated: ancestorEntries.length,
163
+ categoryRows: catSnap.size,
164
+ publishedProductsScanned: scanned,
165
+ drifted,
166
+ written,
167
+ unknownCategory,
55
168
  });
56
169
  }
57
170
  async function reconcileStores(ctx) {
@@ -4,6 +4,39 @@ import { normalizeError } from "../../../../errors/normalize";
4
4
  * CREATE → insert into parent's subtree + shift later siblings;
5
5
  * DELETE → shift back; MOVE → mark `positionDirty` so the nightly reconcile
6
6
  * job rebuilds.
7
+ *
8
+ * trigger-self-write-ok: terminates at depth 1 — the only branch that writes is
9
+ * gated on `parentIds` CHANGING, and none of this handler's own writes touch
10
+ * `parentIds`.
11
+ *
12
+ * 🛑 WHY THAT IS SAFE, SPELLED OUT — this is a `documentWritten` trigger on
13
+ * `categories` that writes back to `categories`, including to the triggering
14
+ * document (L110). That is the shape of Root Cause #92, which logged 1,017,548
15
+ * invocations in 24 hours, so it deserves an explicit argument rather than trust:
16
+ *
17
+ * CREATE writes → sibling `position`s, own `position`/`subtreeSize`, ancestor
18
+ * `subtreeSize`. Every one re-enters as an UPDATE.
19
+ * DELETE writes → sibling `position`s, ancestor `subtreeSize`. Same.
20
+ * UPDATE → writes ONLY when `beforeParent !== afterParent`. None of the
21
+ * writes above change `parentIds`, so the re-entry compares
22
+ * equal and does nothing. The cascade stops.
23
+ * MOVE → writes `positionDirty`, which is also not `parentIds`, so its
24
+ * own re-entry likewise does nothing. One extra hop, then stop.
25
+ *
26
+ * The guard is STRUCTURAL, not a value comparison — that is what makes it stronger
27
+ * than #92's. `onShipmentHeaderWrite` recomputed and rewrote the same derived field
28
+ * every time and relied on `JSON.stringify` equality to notice it had not changed;
29
+ * key-order sensitivity made that comparison always false, so it re-triggered
30
+ * forever. Here there is nothing to compare: the handler cannot write the field it
31
+ * branches on.
32
+ *
33
+ * 🛑 If you ever make this handler write `parentIds`, it becomes a true loop. Do
34
+ * not — a move is deliberately deferred to the nightly reconcile for this reason.
35
+ *
36
+ * Not free, though: creating one category writes to its siblings, its ancestors and
37
+ * itself, and each of those writes spawns a no-op invocation. A full reseed of the
38
+ * 58-category forest therefore costs on the order of thousands of invocations, all
39
+ * of them terminating. That is a cost worth knowing before a reseed, not a runaway.
7
40
  */
8
41
  import { FieldValue } from "firebase-admin/firestore";
9
42
  import { BATCH_LIMIT } from "../handlers/messages";
@@ -23,48 +23,102 @@ async function getParentIds(categoryId) {
23
23
  return [];
24
24
  return (await categoriesRepository.findById(categoryId))?.parentIds ?? [];
25
25
  }
26
+ /**
27
+ * 🛑 THIS IS A CROSS-COLLECTION TRIGGER AND THAT IS THE SAFETY ARGUMENT.
28
+ *
29
+ * It watches `products` and writes to `categories`, so its own writes cannot
30
+ * re-trigger it — there is no termination condition to get right, because there
31
+ * is no cycle. Contrast `onCategoryWrite`, which DOES write its own collection
32
+ * and is safe only because of a structural guard on `parentIds`.
33
+ *
34
+ * **Do not move this counting into `onCategoryWrite`.** A `categories` trigger
35
+ * that increments a `categories` document is exactly the shape of Root Cause
36
+ * #92 — `onShipmentHeaderWrite` recursed 1,017,548 times in 24 hours against a
37
+ * 2M/month quota because a key-order-sensitive `JSON.stringify` guard could
38
+ * never return true, and the only symptom was a billing page.
39
+ *
40
+ * Brands ride along here rather than in a second trigger: a brand IS a
41
+ * `categoryType:"brand"` row in the same collection, so it is the same write to
42
+ * the same place, and splitting it would double the invocations for nothing.
43
+ * A brand has no ancestors, so it is staged with an empty parent chain and its
44
+ * own count equals its rollup by construction.
45
+ */
26
46
  async function dispatchProductWriteEvent(p) {
27
- const { productId, beforeCategory, afterCategory, beforeStoreId, afterStoreId, isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx } = p;
28
- if (isDelete && wasPublished && beforeCategory) {
29
- const parentIds = await getParentIds(beforeCategory);
30
- const batch = ctx.db.batch();
31
- categoriesRepository.updateMetricsInBatch(batch, beforeCategory, parentIds, beforeIsAuction ? 0 : -1, beforeIsAuction ? -1 : 0, productId);
47
+ const { productId, beforeCategory, afterCategory, beforeBrand, afterBrand, beforeStoreId, afterStoreId, isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx } = p;
48
+ /** Stage one category (and, if given, one brand) at `sign` × 1 item. */
49
+ const stage = (batch, categoryId, brandId, parentIds, auction, sign) => {
50
+ const productDelta = auction ? 0 : sign;
51
+ const auctionDelta = auction ? sign : 0;
52
+ if (categoryId) {
53
+ categoriesRepository.updateMetricsInBatch(batch, categoryId, parentIds, productDelta, auctionDelta, productId);
54
+ }
55
+ if (brandId) {
56
+ categoriesRepository.updateMetricsInBatch(batch, brandId, [], productDelta, auctionDelta, productId);
57
+ }
58
+ };
59
+ /*
60
+ * `batch.update()` REJECTS a missing document, and a batch is atomic — so one
61
+ * dangling ancestor id loses every increment in the batch, not just its own.
62
+ * That throw is caught by the caller and logged non-fatally, which is correct:
63
+ * a counter is not worth failing a product write over. What makes it
64
+ * acceptable is that `countersReconcile` now RECOUNTS FROM SCRATCH nightly and
65
+ * rewrites every row that disagrees, so a lost increment self-heals within a
66
+ * day and shows up as a non-zero `drifted` in that job's log.
67
+ */
68
+ const commit = async (batch, what, meta) => {
32
69
  await batch.commit();
70
+ ctx.logger.info(what, { productId, ...meta });
71
+ };
72
+ if (isDelete && wasPublished && (beforeCategory || beforeBrand)) {
73
+ const batch = ctx.db.batch();
74
+ stage(batch, beforeCategory, beforeBrand, await getParentIds(beforeCategory ?? ""), beforeIsAuction, -1);
75
+ await commit(batch, "Decremented counters on hard-delete", { category: beforeCategory, brand: beforeBrand, storeId: beforeStoreId });
33
76
  if (beforeStoreId)
34
77
  await storeRepository.incrementTotalProducts(beforeStoreId, -1);
35
- ctx.logger.info("Decremented counters on hard-delete", { productId, category: beforeCategory, storeId: beforeStoreId });
36
78
  return;
37
79
  }
38
- if (!wasPublished && isPublished && afterCategory) {
39
- const parentIds = await getParentIds(afterCategory);
80
+ if (!wasPublished && isPublished && (afterCategory || afterBrand)) {
40
81
  const batch = ctx.db.batch();
41
- categoriesRepository.updateMetricsInBatch(batch, afterCategory, parentIds, isAuction ? 0 : 1, isAuction ? 1 : 0, productId);
42
- await batch.commit();
82
+ stage(batch, afterCategory, afterBrand, await getParentIds(afterCategory ?? ""), isAuction, 1);
83
+ await commit(batch, "Incremented counters on publish", { category: afterCategory, brand: afterBrand, storeId: afterStoreId });
43
84
  if (afterStoreId)
44
85
  await storeRepository.incrementTotalProducts(afterStoreId, 1);
45
- ctx.logger.info("Incremented counters on publish", { productId, category: afterCategory, storeId: afterStoreId });
46
86
  return;
47
87
  }
48
- if (wasPublished && !isPublished && beforeCategory) {
49
- const parentIds = await getParentIds(beforeCategory);
88
+ if (wasPublished && !isPublished && (beforeCategory || beforeBrand)) {
50
89
  const batch = ctx.db.batch();
51
- categoriesRepository.updateMetricsInBatch(batch, beforeCategory, parentIds, beforeIsAuction ? 0 : -1, beforeIsAuction ? -1 : 0, productId);
52
- await batch.commit();
90
+ stage(batch, beforeCategory, beforeBrand, await getParentIds(beforeCategory ?? ""), beforeIsAuction, -1);
91
+ await commit(batch, "Decremented counters on unpublish", { category: beforeCategory, brand: beforeBrand, storeId: beforeStoreId });
53
92
  if (beforeStoreId)
54
93
  await storeRepository.incrementTotalProducts(beforeStoreId, -1);
55
- ctx.logger.info("Decremented counters on unpublish", { productId, category: beforeCategory, storeId: beforeStoreId });
56
94
  return;
57
95
  }
58
- if (wasPublished && isPublished && beforeCategory && afterCategory && beforeCategory !== afterCategory) {
96
+ /*
97
+ * Still published, but re-filed. This is the branch a naive trigger gets
98
+ * wrong — incrementing the new home without decrementing the old one is how
99
+ * a count drifts permanently upward with no event to blame it on. Category
100
+ * and brand are tested INDEPENDENTLY because either can move without the
101
+ * other: re-tagging the brand while the category stands still used to fall
102
+ * through every branch and silently leave both brand rows wrong.
103
+ */
104
+ if (wasPublished && isPublished) {
105
+ const categoryMoved = beforeCategory !== afterCategory;
106
+ const brandMoved = beforeBrand !== afterBrand;
107
+ if (!categoryMoved && !brandMoved)
108
+ return;
59
109
  const [beforeParents, afterParents] = await Promise.all([
60
- getParentIds(beforeCategory),
61
- getParentIds(afterCategory),
110
+ getParentIds(categoryMoved ? beforeCategory ?? "" : ""),
111
+ getParentIds(categoryMoved ? afterCategory ?? "" : ""),
62
112
  ]);
63
113
  const batch = ctx.db.batch();
64
- categoriesRepository.updateMetricsInBatch(batch, beforeCategory, beforeParents, beforeIsAuction ? 0 : -1, beforeIsAuction ? -1 : 0, productId);
65
- categoriesRepository.updateMetricsInBatch(batch, afterCategory, afterParents, isAuction ? 0 : 1, isAuction ? 1 : 0, productId);
66
- await batch.commit();
67
- ctx.logger.info("Moved product between categories", { productId, from: beforeCategory, to: afterCategory });
114
+ stage(batch, categoryMoved ? beforeCategory : null, brandMoved ? beforeBrand : null, beforeParents, beforeIsAuction, -1);
115
+ stage(batch, categoryMoved ? afterCategory : null, brandMoved ? afterBrand : null, afterParents, isAuction, 1);
116
+ await commit(batch, "Re-filed product", {
117
+ fromCategory: categoryMoved ? beforeCategory : undefined,
118
+ toCategory: categoryMoved ? afterCategory : undefined,
119
+ fromBrand: brandMoved ? beforeBrand : undefined,
120
+ toBrand: brandMoved ? afterBrand : undefined,
121
+ });
68
122
  }
69
123
  }
70
124
  export async function handleProductWrite(input, ctx) {
@@ -77,6 +131,16 @@ export async function handleProductWrite(input, ctx) {
77
131
  // no-op category metrics here. Prefer categorySlugs[0], fall back to legacy category.
78
132
  const beforeCategory = getEffectiveCategory(before);
79
133
  const afterCategory = getEffectiveCategory(after);
134
+ /*
135
+ * `brandSlug` is the brand ROW's document id in this same `categories`
136
+ * collection. `brand` is the display NAME and is not a key — CLAUDE.md's
137
+ * § "Brand matching is by DISPLAY NAME" — so it must never be used here.
138
+ * Nothing had ever written a brand row's metrics, while
139
+ * BrandDetailPageView.tsx:124 reads `brand?.metrics?.productCount` — so every
140
+ * brand page fell through to its tab count or to a hard 0.
141
+ */
142
+ const beforeBrand = before?.brandSlug ?? null;
143
+ const afterBrand = after?.brandSlug ?? null;
80
144
  const beforeStoreId = (before?.storeId || before?.sellerId) ?? null;
81
145
  const afterStoreId = (after?.storeId || after?.sellerId) ?? null;
82
146
  const isAuction = after?.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
@@ -86,12 +150,16 @@ export async function handleProductWrite(input, ctx) {
86
150
  const isDelete = !after;
87
151
  try {
88
152
  await dispatchProductWriteEvent({
89
- productId, beforeCategory, afterCategory, beforeStoreId, afterStoreId,
153
+ productId, beforeCategory, afterCategory, beforeBrand, afterBrand,
154
+ beforeStoreId, afterStoreId,
90
155
  isAuction, beforeIsAuction, wasPublished, isPublished, isDelete, ctx,
91
156
  });
92
157
  }
93
158
  catch (err) {
94
159
  void normalizeError(err);
95
- ctx.logger.error("Counter update failed (non-fatal)", err, { productId });
160
+ // Non-fatal on purpose — a counter must never fail a product write. The
161
+ // healer is `countersReconcile`, which recounts from scratch nightly and
162
+ // reports how many rows it had to correct.
163
+ ctx.logger.error("Counter update failed (non-fatal — countersReconcile will heal it tonight)", err, { productId });
96
164
  }
97
165
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Record what the mailer decided, so the tester can assert on it without an inbox.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * 21 checklist cases are about email. Reading a real mailbox over IMAP can answer
7
+ * some of them, but it is the wrong instrument for most:
8
+ *
9
+ * - **Six of them assert an email should NOT arrive** (kill switch spares staff,
10
+ * opt-out honoured, no employee blast, losing bidders get no mail). Over IMAP,
11
+ * "nothing arrived" is unprovable — you wait N seconds and hope, and a slow
12
+ * inbox is indistinguishable from a working suppression. Here it is one read.
13
+ * - Delivery latency makes every other case slow and flaky for no added truth:
14
+ * whether Resend accepted the handoff says nothing about whether the app made
15
+ * the right decision, which is what the cases are actually about.
16
+ *
17
+ * So this records the DECISION and its inputs. A real inbox is still the only
18
+ * proof of real delivery, and the two or three cases that assert that keep using
19
+ * one (`tester/scripts/lib/inbox.mjs`).
20
+ *
21
+ * ## Why here and not in `guardSend`
22
+ *
23
+ * `SendGuardContext` carries channel/feature/audience and deliberately no
24
+ * recipient — the guard's own docstring explains it must not become the transport.
25
+ * `sendEmail()` is the one place that holds the recipient, the rendered body AND
26
+ * the guard's verdict, which is exactly the tuple a case needs.
27
+ *
28
+ * ## 🛑 Gated, because recording real people's mail is a privacy problem
29
+ *
30
+ * Recording recipients and bodies for actual users would be a new PII store with
31
+ * no retention story. So this writes ONLY when the recipient is the harness
32
+ * mailbox (`TESTER_EMAIL_ID`), or when `EMAIL_RECORDER_ALL` is explicitly set for
33
+ * local debugging. Absent that env var, this function is a no-op and costs one
34
+ * string comparison.
35
+ *
36
+ * Best-effort throughout: a recorder that can fail a send is worse than no
37
+ * recorder, so every path swallows. Same discipline as `recordAdminAction`.
38
+ *
39
+ * @tag domain:notifications,tester
40
+ * @tag layer:server
41
+ * @tag access:server-only
42
+ */
43
+ /** Firestore collection holding the canonical record. */
44
+ export declare const EMAIL_EVENTS_COLLECTION = "emailEvents";
45
+ export type EmailSendOutcome = "sent" | "suppressed" | "failed";
46
+ export interface EmailSendRecord {
47
+ /** Recipient(s), as passed to the provider. */
48
+ to: string | string[];
49
+ subject: string;
50
+ /** `feature` from the guard — the notification type or named flow. */
51
+ feature: string;
52
+ audience: string;
53
+ channel: string;
54
+ outcome: EmailSendOutcome;
55
+ /** Suppression reason (`admin_disabled`, `daily_ceiling`, …) or the error text. */
56
+ reason?: string;
57
+ /**
58
+ * The rendered body. Recorded so a case can assert the email has a heading, a
59
+ * lead line and a button rather than one bare paragraph — an assertion that is
60
+ * otherwise only checkable by eye in a real client.
61
+ */
62
+ html?: string;
63
+ }
64
+ /**
65
+ * Should this send be recorded at all?
66
+ *
67
+ * Exported so the audit and tests can exercise the gate directly rather than
68
+ * inferring it from behaviour.
69
+ */
70
+ export declare function shouldRecordSend(to: string | string[]): boolean;
71
+ /**
72
+ * Write the record. Never throws, never blocks the send.
73
+ *
74
+ * Firestore is canonical and RTDB is only a ping — the same split the messaging
75
+ * feature already uses. The body does NOT go into RTDB: `bulk_events` is
76
+ * uncapped and a newsletter CSV already goes there, which is the mistake not to
77
+ * repeat on a channel that fires on every email.
78
+ */
79
+ export declare function recordSendAttempt(record: EmailSendRecord): Promise<void>;