@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.
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
- package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
- package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
- package/dist/_internal/server/features/bundles/og.d.ts +0 -1
- package/dist/_internal/server/features/bundles/og.js +2 -18
- package/dist/_internal/server/features/catalogue/og.d.ts +0 -1
- package/dist/_internal/server/features/catalogue/og.js +1 -9
- package/dist/_internal/server/features/checkout/actions.js +18 -0
- package/dist/_internal/server/features/classified/og.d.ts +0 -1
- package/dist/_internal/server/features/classified/og.js +1 -9
- package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
- package/dist/_internal/server/features/digital-code/download.js +128 -0
- package/dist/_internal/server/features/digital-code/og.d.ts +0 -1
- package/dist/_internal/server/features/digital-code/og.js +1 -9
- package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
- package/dist/_internal/server/features/digital-code/pool.js +208 -0
- package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
- package/dist/_internal/server/features/digital-code/upload.js +144 -0
- package/dist/_internal/server/features/grouped/og.d.ts +7 -4
- package/dist/_internal/server/features/grouped/og.js +7 -6
- package/dist/_internal/server/features/item-requests/og.d.ts +0 -1
- package/dist/_internal/server/features/item-requests/og.js +2 -18
- package/dist/_internal/server/features/live/og.d.ts +0 -1
- package/dist/_internal/server/features/live/og.js +1 -9
- package/dist/_internal/server/features/prize-draws/og.js +5 -10
- package/dist/_internal/server/features/products/og.js +5 -2
- package/dist/_internal/server/features/seo/sitemap.js +10 -2
- package/dist/_internal/server/features/shared/listing-adapters.d.ts +1 -2
- package/dist/_internal/server/features/shared/listing-adapters.js +5 -3
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
- package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
- package/dist/_internal/server/notifications/send-recorder.js +126 -0
- package/dist/_internal/shared/listing-types/_registry.d.ts +31 -4
- package/dist/_internal/shared/listing-types/art/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/art/config.js +2 -0
- package/dist/_internal/shared/listing-types/auction/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/auction/config.js +3 -0
- package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/classified/config.js +3 -0
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/digital-code/config.js +3 -0
- package/dist/_internal/shared/listing-types/live/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/live/config.js +3 -0
- package/dist/_internal/shared/listing-types/pre-order/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/pre-order/config.js +2 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +3 -0
- package/dist/_internal/shared/listing-types/standard/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/standard/config.js +2 -0
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -0
- package/dist/_internal/shared/listing-types/stickers/config.js +2 -0
- package/dist/client/observability/reportClientError.js +11 -1
- package/dist/client.d.ts +6 -0
- package/dist/client.js +10 -0
- package/dist/core/hooks/useSyncManager.js +11 -1
- package/dist/features/account/hooks/useNotifications.js +25 -5
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +12 -3
- package/dist/features/auctions/components/LiveBidPrice.js +2 -2
- package/dist/features/auctions/components/LiveMinIncrement.d.ts +1 -1
- package/dist/features/auctions/components/LiveMinIncrement.js +8 -0
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
- package/dist/features/auctions/components/PlaceBidFormClient.js +16 -1
- package/dist/features/auctions/hooks/useAuctions.js +12 -1
- package/dist/features/auctions/hooks/useRealtimeBids.d.ts +3 -0
- package/dist/features/auctions/hooks/useRealtimeBids.js +138 -36
- package/dist/features/blog/components/BlogIndexPageView.js +10 -1
- package/dist/features/blog/repository/blog.repository.d.ts +14 -0
- package/dist/features/blog/repository/blog.repository.js +23 -1
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +2 -2
- package/dist/features/catalogue/components/PublicCatalogueView.js +2 -2
- package/dist/features/categories/components/BundleCollage.js +10 -5
- package/dist/features/categories/components/BundleDetailView.js +7 -3
- package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
- package/dist/features/categories/components/CategoryProductsListing.js +4 -1
- package/dist/features/categories/components/CategoryTree.js +1 -1
- package/dist/features/categories/repository/categories.repository.d.ts +34 -2
- package/dist/features/categories/repository/categories.repository.js +31 -9
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -2
- package/dist/features/contact/email.js +36 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -2
- package/dist/features/homepage/components/FeaturedBundlesSection.js +4 -4
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -2
- package/dist/features/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
- package/dist/features/pre-orders/components/PreOrderActionsClient.js +2 -2
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -2
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +4 -1
- package/dist/features/products/actions/product-actions.d.ts +9 -0
- package/dist/features/products/actions/product-actions.js +11 -0
- package/dist/features/products/columns/productTableColumns.js +2 -2
- package/dist/features/products/components/AuctionsIndexListing.js +4 -1
- package/dist/features/products/components/BidHistory.js +2 -2
- package/dist/features/products/components/CompareOverlay.js +2 -2
- package/dist/features/products/components/GroupMemberPicker.js +5 -5
- package/dist/features/products/components/ListingBottomActions.js +12 -3
- package/dist/features/products/components/MakeOfferButton.js +7 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +4 -4
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
- package/dist/features/products/components/PrizeDrawCollage.js +16 -5
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +4 -1
- package/dist/features/products/components/PrizeRevealModal.js +2 -2
- package/dist/features/products/components/ProductDetailActions.js +10 -3
- package/dist/features/products/components/ProductFilters.js +2 -2
- package/dist/features/products/components/ProductGrid.js +5 -2
- package/dist/features/products/components/ProductsIndexListing.js +12 -1
- package/dist/features/products/components/ProductsIndexPageView.js +26 -0
- package/dist/features/products/components/ShowGroupSection.js +2 -2
- package/dist/features/products/components/SublistingCarouselSection.js +2 -2
- package/dist/features/products/constants/listing-tabs.d.ts +36 -12
- package/dist/features/products/constants/listing-tabs.js +38 -14
- package/dist/features/products/constants/sieve.d.ts +14 -0
- package/dist/features/products/constants/sieve.js +39 -0
- package/dist/features/products/repository/products.repository.d.ts +35 -0
- package/dist/features/products/repository/products.repository.js +116 -3
- package/dist/features/products/schemas/firestore.d.ts +37 -0
- package/dist/features/seller/actions/seller-actions.js +4 -13
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
- package/dist/features/seller/components/SellerProductShell.js +11 -2
- package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
- package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
- package/dist/features/stores/components/StoresIndexPageView.js +17 -1
- package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__bidding.js +5 -4
- package/dist/features/tester/seed-data/authored/buying__browsing-search.js +85 -85
- package/dist/features/tester/seed-data/authored/buying__image-tile-layout.js +2 -2
- package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__product-detail.js +53 -15
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- package/dist/features/tester/seed-data/authored/content-discovery__search.js +6 -4
- package/dist/features/tester/seed-data/authored/design-ux__carousel-arrow-bounds.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__homepage-carousels.js +2 -2
- package/dist/features/tester/seed-data/authored/design-ux__status-badge-legibility.js +4 -4
- package/dist/features/tester/seed-data/authored/design-ux__sticky-cta-bar.js +8 -8
- package/dist/features/tester/seed-data/authored/public-pages__core-listing-pages.js +8 -8
- package/dist/features/tester/seed-data/authored/seo__canonical-and-host.js +16 -0
- package/dist/features/tester/seed-data/authored/seo__og-images.js +17 -0
- package/dist/features/tester/seed-data/stores-tester-seed-data.js +41 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +36 -6
- package/dist/features/tester/utils/phases.js +78 -12
- package/dist/features/wishlist/components/WishlistPage.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- package/dist/react/contexts/SessionContext.js +40 -1
- package/dist/react/hooks/useCanSeePrices.d.ts +37 -0
- package/dist/react/hooks/useCanSeePrices.js +40 -0
- package/dist/seed/addresses-seed-data.js +36 -2
- package/dist/seed/users-seed-data.js +10 -2
- package/dist/seo/json-ld.d.ts +21 -0
- package/dist/seo/json-ld.js +45 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/styles.css +41 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -0
- package/dist/ui/components/GatedPrice.d.ts +66 -0
- package/dist/ui/components/GatedPrice.js +80 -0
- package/dist/ui/components/GatedPrice.style.css +40 -0
- package/dist/ui/components/PriceDisplay.d.ts +12 -0
- package/dist/ui/components/PriceDisplay.js +17 -17
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +8 -0
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
-
|
|
42
|
-
await
|
|
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
|
-
|
|
52
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
await
|
|
67
|
-
|
|
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,
|
|
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
|
-
|
|
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>;
|