@mohasinac/appkit 4.38.0 → 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 (51) 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/checkout/actions.js +18 -0
  6. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  7. package/dist/_internal/server/features/digital-code/download.js +128 -0
  8. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  9. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  10. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  11. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  12. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  14. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  16. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  17. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  18. package/dist/client.d.ts +2 -0
  19. package/dist/client.js +6 -0
  20. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  21. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  22. package/dist/features/blog/repository/blog.repository.js +23 -1
  23. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  24. package/dist/features/categories/components/CategoryTree.js +1 -1
  25. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  26. package/dist/features/categories/repository/categories.repository.js +31 -9
  27. package/dist/features/contact/email.js +36 -1
  28. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  29. package/dist/features/orders/repository/orders.repository.js +26 -9
  30. package/dist/features/products/repository/products.repository.d.ts +35 -0
  31. package/dist/features/products/repository/products.repository.js +116 -3
  32. package/dist/features/products/schemas/firestore.d.ts +37 -0
  33. package/dist/features/seller/actions/seller-actions.js +4 -13
  34. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  35. package/dist/features/seller/components/SellerProductShell.js +11 -2
  36. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  37. package/dist/features/stores/api/[storeSlug]/reviews/route.js +89 -30
  38. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  39. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  40. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  41. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  42. package/dist/features/tester/utils/phases.js +78 -12
  43. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  44. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  45. package/dist/server.d.ts +3 -0
  46. package/dist/server.js +12 -0
  47. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  48. package/dist/ui/components/FilePickerButton.js +23 -0
  49. package/dist/ui/index.d.ts +2 -0
  50. package/dist/ui/index.js +7 -0
  51. package/package.json +1 -1
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The digital-content pool for a `digital-code` listing.
3
+ *
4
+ * 🛑 THIS IS THE MISSING HALF OF THE FEATURE. Everything downstream already
5
+ * existed — `claimDigitalCodeForOrder` claims one at checkout, the buyer reveals
6
+ * it through `GET /api/orders/{id}/code`, a refund revokes unclaimed ones — but
7
+ * **nothing in the codebase had ever WRITTEN a row into the pool.** The one
8
+ * seller-facing route returned `501 "Digital code management is not implemented
9
+ * yet."` So every purchase of a digital-code listing hit
10
+ * `serverLogger.warn("claimDigitalCode: code pool exhausted")` and returned
11
+ * silently, the order completed normally, and the buyer's reveal panel answered
12
+ * 404. The seller form even has a "Code Pool Size" field, which made the pool
13
+ * look populated on the listing page while it was always empty.
14
+ *
15
+ * A "digital code" delivers digital CONTENT: a redemption string, a QR image, or
16
+ * a file. `contentKind` says which, defaulting to `"code"` so every pre-existing
17
+ * document keeps its meaning with no migration.
18
+ */
19
+ import { getAdminDb } from "../../../../providers/db-firebase/admin";
20
+ import { normalizeError } from "../../../../errors/normalize";
21
+ import { serverLogger } from "../../../../monitoring";
22
+ import { ValidationError } from "../../../../errors";
23
+ import { ORDER_FIELDS } from "../../../../constants/field-names";
24
+ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
25
+ /**
26
+ * Where a delivered asset's bytes live.
27
+ *
28
+ * Deliberately OUTSIDE every prefix the public media proxy knows how to resolve,
29
+ * and no `mediaAssets` row is ever created for it — a `mediaAssets` row is what
30
+ * mints a `/media/{shortId}` slug, and that slug is served without
31
+ * authentication. No row, no slug, no public URL.
32
+ */
33
+ export const DIGITAL_CONTENT_PREFIX = "private/digital-content";
34
+ /**
35
+ * 🛑 THE PERMISSION SPLIT, and the reason it is not simply "admins only".
36
+ *
37
+ * An image is the QR/voucher case and is the whole point of widening this from
38
+ * strings, so a seller must be able to add one — they are the person who has it.
39
+ * Any OTHER file type is admin-only to upload: the upload surface accepts bytes
40
+ * that will later be streamed to a buyer, and a `.html` renamed `.pdf` is stored
41
+ * XSS the moment anything renders it inline. Narrowing who can put a non-image
42
+ * file into that path is the cheap half of the defence; the magic-byte check and
43
+ * the forced `Content-Disposition: attachment` are the other half.
44
+ *
45
+ * READ is a third thing again and is not on this axis at all: whoever may
46
+ * upload, only the buyer who paid for that order (or staff) may download. See
47
+ * `assertCanDownloadCodeAsset`.
48
+ */
49
+ export function assertCanUploadContentKind(kind, role) {
50
+ const isStaff = role === "admin" || role === "moderator" || role === "employee";
51
+ if (kind === "file" && !isStaff) {
52
+ throw new ValidationError("Only an administrator can attach a downloadable file to a listing. Sellers can attach a code or a QR image.");
53
+ }
54
+ }
55
+ function assertPoolEntryShape(input) {
56
+ if (input.contentKind === "code") {
57
+ if (!input.code || !input.code.trim()) {
58
+ throw new ValidationError("A code entry needs a code.");
59
+ }
60
+ return;
61
+ }
62
+ if (!input.assetPath) {
63
+ throw new ValidationError(`A ${input.contentKind} entry needs an uploaded asset.`);
64
+ }
65
+ /*
66
+ * Refuse anything outside the private prefix rather than "fixing it up".
67
+ * A caller that hands us `tmp/uploads/<uid>/x.png` is handing us a path the
68
+ * public proxy can already serve, and silently rewriting it would hide the
69
+ * mistake instead of surfacing it.
70
+ */
71
+ if (!input.assetPath.startsWith(`${DIGITAL_CONTENT_PREFIX}/`)) {
72
+ throw new ValidationError("Asset is not in the private digital-content store.");
73
+ }
74
+ }
75
+ /**
76
+ * Append entries to a product's pool and re-sync the two counters.
77
+ *
78
+ * `codePoolSize` and `codesAvailable` are recomputed from the subcollection, not
79
+ * incremented from the input — the seller form lets a human type `codePoolSize`
80
+ * by hand, so treating that number as authoritative would let the listing page
81
+ * advertise stock that does not exist. Counting is the only thing that cannot
82
+ * drift (Root Cause #42: a mirror is only as good as every writer of it).
83
+ */
84
+ export async function addPoolEntries(entries) {
85
+ if (entries.length === 0)
86
+ throw new ValidationError("Nothing to add.");
87
+ const productId = entries[0].productId;
88
+ for (const e of entries) {
89
+ if (e.productId !== productId) {
90
+ throw new ValidationError("All entries must belong to the same listing.");
91
+ }
92
+ assertPoolEntryShape(e);
93
+ }
94
+ const db = getAdminDb();
95
+ const codesRef = db
96
+ .collection(PRODUCT_COLLECTION)
97
+ .doc(productId)
98
+ .collection(PRODUCT_CODES_SUBCOLLECTION);
99
+ const now = new Date();
100
+ const batch = db.batch();
101
+ for (const e of entries) {
102
+ const doc = {
103
+ productId,
104
+ code: e.contentKind === "code" ? e.code.trim() : "",
105
+ contentKind: e.contentKind,
106
+ status: "available",
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ ...(e.assetPath ? { assetPath: e.assetPath } : {}),
110
+ ...(e.fileName ? { fileName: e.fileName } : {}),
111
+ ...(e.contentType ? { contentType: e.contentType } : {}),
112
+ ...(e.expiresAt ? { expiresAt: e.expiresAt } : {}),
113
+ };
114
+ batch.set(codesRef.doc(), doc);
115
+ }
116
+ await batch.commit();
117
+ const counts = await recountPool(productId);
118
+ return { added: entries.length, ...counts };
119
+ }
120
+ /**
121
+ * Recount the pool and write both counters onto the product.
122
+ *
123
+ * 🛑 `codesAvailable` HAD NO WRITER EITHER. `claimDigitalCodeForOrder` flips a
124
+ * code to `claimed` and never decrements it, so the counter only ever held
125
+ * whatever the seed or the seller typed. Since `hasCodesAvailable()` is what the
126
+ * availability predicate reads, a sold-out listing stayed "available" forever and
127
+ * a listing that never had codes still advertised stock. Both counters are
128
+ * derived here, so neither can drift from the subcollection.
129
+ */
130
+ export async function recountPool(productId) {
131
+ const db = getAdminDb();
132
+ const codesRef = db
133
+ .collection(PRODUCT_COLLECTION)
134
+ .doc(productId)
135
+ .collection(PRODUCT_CODES_SUBCOLLECTION);
136
+ // Aggregation counts, not document reads — a pool can be large and this runs
137
+ // on every claim (Rule #6).
138
+ const [availableAgg, totalAgg] = await Promise.all([
139
+ codesRef.where(ORDER_FIELDS.STATUS, "==", "available").count().get(),
140
+ codesRef.count().get(),
141
+ ]);
142
+ const available = availableAgg.data().count;
143
+ const total = totalAgg.data().count;
144
+ try {
145
+ await db.collection(PRODUCT_COLLECTION).doc(productId).update({
146
+ "digitalCode.codesAvailable": available,
147
+ "digitalCode.codePoolSize": total,
148
+ updatedAt: new Date(),
149
+ });
150
+ }
151
+ catch (err) {
152
+ void normalizeError(err);
153
+ // Non-fatal: the pool itself is the source of truth and a stale counter only
154
+ // affects a badge. Failing the claim over it would be worse.
155
+ serverLogger.error("recountPool: counter write failed", { error: normalizeError(err).message, productId });
156
+ }
157
+ return { available, total };
158
+ }
159
+ /** The seller/admin view of a pool. Never returns `code` or `assetPath`. */
160
+ export async function listPoolEntries(productId) {
161
+ const db = getAdminDb();
162
+ const snap = await db
163
+ .collection(PRODUCT_COLLECTION)
164
+ .doc(productId)
165
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
166
+ .orderBy(ORDER_FIELDS.CREATED_AT, "desc")
167
+ .limit(200)
168
+ .get();
169
+ /*
170
+ * 🛑 An ALLOW-LIST, not a spread-and-delete. The whole value of this
171
+ * subcollection is that the seller can see how many codes are left without
172
+ * seeing the codes, and a buyer's claimed code is not the seller's to read
173
+ * back either. Root Cause #70: a projection built by deleting keys publishes
174
+ * every field nobody thought to delete.
175
+ */
176
+ return snap.docs.map((d) => {
177
+ const c = d.data();
178
+ return {
179
+ id: d.id,
180
+ contentKind: c.contentKind ?? "code",
181
+ status: c.status,
182
+ ...(c.fileName ? { fileName: c.fileName } : {}),
183
+ ...(c.orderId ? { orderId: c.orderId } : {}),
184
+ ...(c.claimedAt ? { claimedAt: new Date(c.claimedAt).toISOString() } : {}),
185
+ ...(c.createdAt ? { createdAt: new Date(c.createdAt).toISOString() } : {}),
186
+ };
187
+ });
188
+ }
189
+ /**
190
+ * Remove an UNCLAIMED entry. A claimed one is somebody's purchase and deleting
191
+ * it would make their reveal 404 with no trace of what they bought.
192
+ */
193
+ export async function deletePoolEntry(productId, codeId) {
194
+ const db = getAdminDb();
195
+ const ref = db
196
+ .collection(PRODUCT_COLLECTION)
197
+ .doc(productId)
198
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
199
+ .doc(codeId);
200
+ const snap = await ref.get();
201
+ if (!snap.exists)
202
+ throw new ValidationError("Entry not found.");
203
+ if (snap.data().status === "claimed") {
204
+ throw new ValidationError("That entry has already been delivered to a buyer and cannot be removed.");
205
+ }
206
+ await ref.delete();
207
+ return recountPool(productId);
208
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Upload for delivered digital content.
3
+ *
4
+ * 🛑 A SEPARATE PIPELINE FROM `/api/media/*`, ON PURPOSE.
5
+ *
6
+ * The public media pipeline ends by creating a `mediaAssets` row, and that row
7
+ * IS the `/media/{shortId}` slug — which `GET /api/media/[...slug]` serves with
8
+ * no authentication at all. Reusing it for paid content and then "remembering
9
+ * not to publish the URL" is not a boundary; the slug exists the moment the row
10
+ * does. So this writes to `private/digital-content/...`, mints no row, and the
11
+ * bytes are only ever readable through `resolveCodeAssetForOrder`, which checks
12
+ * that the caller owns the order.
13
+ *
14
+ * Everything else about the flow is deliberately identical to the public one —
15
+ * a short-lived signed PUT so bytes never traverse the Next.js function (Rule
16
+ * #6 caps a request body at 4.5 MB), then a server-side finalize that verifies
17
+ * the file by its MAGIC BYTES rather than by what the client declared.
18
+ */
19
+ import type { ProductCodeContentKind } from "../../../../features/products/schemas/firestore";
20
+ /** The content kind a declared MIME maps to. */
21
+ export declare function contentKindForMime(mime: string): ProductCodeContentKind;
22
+ export interface SignDigitalContentInput {
23
+ productId: string;
24
+ fileName: string;
25
+ contentType: string;
26
+ size: number;
27
+ }
28
+ export interface SignedDigitalContent {
29
+ uploadUrl: string;
30
+ storagePath: string;
31
+ contentKind: ProductCodeContentKind;
32
+ expiresAt: string;
33
+ }
34
+ export declare function signDigitalContentUpload(input: SignDigitalContentInput): Promise<SignedDigitalContent>;
35
+ export interface FinalizedDigitalContent {
36
+ storagePath: string;
37
+ contentType: string;
38
+ fileName: string;
39
+ contentKind: ProductCodeContentKind;
40
+ size: number;
41
+ }
42
+ /**
43
+ * Verify an uploaded object and return the facts a pool entry needs.
44
+ *
45
+ * 🛑 THE DECLARED MIME IS NOT EVIDENCE. `finalize` on the public pipeline exists
46
+ * for exactly this reason and returns a structured 422 `MIME_MISMATCH`; the same
47
+ * rule has to hold here, and matters more, because the output is handed to a
48
+ * paying buyer. A mismatch DELETES the object rather than leaving it in the
49
+ * bucket for a later caller to reference.
50
+ */
51
+ export declare function finalizeDigitalContentUpload(storagePath: string, declaredContentType: string): Promise<FinalizedDigitalContent>;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Upload for delivered digital content.
3
+ *
4
+ * 🛑 A SEPARATE PIPELINE FROM `/api/media/*`, ON PURPOSE.
5
+ *
6
+ * The public media pipeline ends by creating a `mediaAssets` row, and that row
7
+ * IS the `/media/{shortId}` slug — which `GET /api/media/[...slug]` serves with
8
+ * no authentication at all. Reusing it for paid content and then "remembering
9
+ * not to publish the URL" is not a boundary; the slug exists the moment the row
10
+ * does. So this writes to `private/digital-content/...`, mints no row, and the
11
+ * bytes are only ever readable through `resolveCodeAssetForOrder`, which checks
12
+ * that the caller owns the order.
13
+ *
14
+ * Everything else about the flow is deliberately identical to the public one —
15
+ * a short-lived signed PUT so bytes never traverse the Next.js function (Rule
16
+ * #6 caps a request body at 4.5 MB), then a server-side finalize that verifies
17
+ * the file by its MAGIC BYTES rather than by what the client declared.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { getAdminStorage } from "../../../../providers/db-firebase";
21
+ import { normalizeError } from "../../../../errors/normalize";
22
+ import { serverLogger } from "../../../../monitoring";
23
+ import { ValidationError } from "../../../../errors";
24
+ import { classifyMime, isAllowedMime, maxBytesFor, PDF_MAGIC, } from "../../../shared/media/limits";
25
+ import { DIGITAL_CONTENT_PREFIX } from "./pool";
26
+ /** Signed-PUT lifetime. Long enough for a slow upload, short enough to matter. */
27
+ const SIGNED_URL_TTL_MS = 15 * 60 * 1000;
28
+ /** Magic bytes are read from the head of the object; 4 KB is ample for every type. */
29
+ const MAGIC_BYTE_WINDOW = 4096;
30
+ /** The content kind a declared MIME maps to. */
31
+ export function contentKindForMime(mime) {
32
+ return classifyMime(mime) === "image" ? "image" : "file";
33
+ }
34
+ export async function signDigitalContentUpload(input) {
35
+ if (!isAllowedMime(input.contentType)) {
36
+ throw new ValidationError(`Unsupported file type: ${input.contentType}`);
37
+ }
38
+ const kind = classifyMime(input.contentType);
39
+ if (kind === "video") {
40
+ // Video as a delivered good is a different product with different economics
41
+ // (egress on a 50 MB file, per download, on the Hobby tier). Refuse loudly
42
+ // rather than accept it and discover the bill.
43
+ throw new ValidationError("Video cannot be delivered as digital content.");
44
+ }
45
+ const max = maxBytesFor(input.contentType);
46
+ if (!(input.size > 0))
47
+ throw new ValidationError("Empty file.");
48
+ if (max !== null && input.size > max) {
49
+ throw new ValidationError("File is too large.");
50
+ }
51
+ /*
52
+ * A RANDOM object name, which is the opposite of the convention everywhere
53
+ * else in this codebase. Public media filenames are content-derived for SEO —
54
+ * and therefore guessable, which is precisely the property that must not hold
55
+ * for a paid good. The private prefix already means Storage's world-read rule
56
+ * cannot be reached through any app route, and an unguessable name means a
57
+ * leaked path is not a leaked catalogue.
58
+ */
59
+ const safeName = input.fileName.replace(/[^\w.\-]+/g, "_").slice(-80) || "asset";
60
+ const storagePath = `${DIGITAL_CONTENT_PREFIX}/${input.productId}/${randomUUID()}-${safeName}`;
61
+ const expires = Date.now() + SIGNED_URL_TTL_MS;
62
+ const [uploadUrl] = await getAdminStorage()
63
+ .bucket()
64
+ .file(storagePath)
65
+ .getSignedUrl({
66
+ version: "v4",
67
+ action: "write",
68
+ expires,
69
+ contentType: input.contentType,
70
+ });
71
+ return {
72
+ uploadUrl,
73
+ storagePath,
74
+ contentKind: contentKindForMime(input.contentType),
75
+ expiresAt: new Date(expires).toISOString(),
76
+ };
77
+ }
78
+ /**
79
+ * Verify an uploaded object and return the facts a pool entry needs.
80
+ *
81
+ * 🛑 THE DECLARED MIME IS NOT EVIDENCE. `finalize` on the public pipeline exists
82
+ * for exactly this reason and returns a structured 422 `MIME_MISMATCH`; the same
83
+ * rule has to hold here, and matters more, because the output is handed to a
84
+ * paying buyer. A mismatch DELETES the object rather than leaving it in the
85
+ * bucket for a later caller to reference.
86
+ */
87
+ export async function finalizeDigitalContentUpload(storagePath, declaredContentType) {
88
+ if (!storagePath.startsWith(`${DIGITAL_CONTENT_PREFIX}/`)) {
89
+ throw new ValidationError("Not a digital-content upload path.");
90
+ }
91
+ const file = getAdminStorage().bucket().file(storagePath);
92
+ const [exists] = await file.exists();
93
+ if (!exists)
94
+ throw new ValidationError("Upload not found.");
95
+ const [meta] = await file.getMetadata();
96
+ const size = Number(meta.size ?? 0);
97
+ const declaredKind = classifyMime(declaredContentType);
98
+ if (!declaredKind) {
99
+ await file.delete({ ignoreNotFound: true });
100
+ throw new ValidationError("Unsupported file type.");
101
+ }
102
+ const max = maxBytesFor(declaredContentType);
103
+ if (size <= 0 || (max !== null && size > max)) {
104
+ await file.delete({ ignoreNotFound: true });
105
+ throw new ValidationError("File is empty or too large.");
106
+ }
107
+ const [head] = await file.download({ start: 0, end: MAGIC_BYTE_WINDOW - 1 });
108
+ let detectedKind = null;
109
+ try {
110
+ const { fileTypeFromBuffer } = await import("file-type");
111
+ const detected = await fileTypeFromBuffer(head);
112
+ detectedKind = detected ? classifyMime(detected.mime) : null;
113
+ if (!detectedKind && head.subarray(0, PDF_MAGIC.length).toString("latin1") === PDF_MAGIC) {
114
+ detectedKind = "pdf";
115
+ }
116
+ }
117
+ catch (err) {
118
+ const normalized = normalizeError(err);
119
+ /*
120
+ * Detection FAILING is treated exactly like detection DISAGREEING — the
121
+ * check below sees `detectedKind === null` and deletes the object. So this
122
+ * catch cannot hide a bad upload; it only records why the prover gave up
123
+ * (a corrupt head, or file-type failing to load). Logging it matters
124
+ * because a systematic failure here would look like "every upload is
125
+ * malformed" to the person uploading.
126
+ */
127
+ serverLogger.warn("digital-content: magic-byte detection failed", {
128
+ error: normalized.message,
129
+ storagePath,
130
+ });
131
+ }
132
+ if (!detectedKind || detectedKind !== declaredKind) {
133
+ await file.delete({ ignoreNotFound: true });
134
+ throw new ValidationError("The uploaded file is not the type it claims to be, so it was discarded.");
135
+ }
136
+ const fileName = storagePath.split("/").pop().replace(/^[0-9a-f-]{36}-/, "");
137
+ return {
138
+ storagePath,
139
+ contentType: declaredContentType,
140
+ fileName,
141
+ contentKind: contentKindForMime(declaredContentType),
142
+ size,
143
+ };
144
+ }
@@ -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) {