@mohasinac/appkit 4.38.0 → 4.39.1

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 (53) 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/reviews/repository/reviews.repository.d.ts +1 -1
  34. package/dist/features/reviews/repository/reviews.repository.js +2 -2
  35. package/dist/features/seller/actions/seller-actions.js +4 -13
  36. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  37. package/dist/features/seller/components/SellerProductShell.js +11 -2
  38. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  39. package/dist/features/stores/api/[storeSlug]/reviews/route.js +134 -38
  40. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  41. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  42. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  43. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  44. package/dist/features/tester/utils/phases.js +78 -12
  45. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  46. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  47. package/dist/server.d.ts +3 -0
  48. package/dist/server.js +12 -0
  49. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  50. package/dist/ui/components/FilePickerButton.js +23 -0
  51. package/dist/ui/index.d.ts +2 -0
  52. package/dist/ui/index.js +7 -0
  53. package/package.json +1 -1
@@ -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>;
@@ -0,0 +1,126 @@
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
+ import { normalizeError } from "../../../errors/normalize";
44
+ import { serverLogger } from "../../../monitoring";
45
+ import { RTDB_PATHS } from "../../../providers/db-firebase/rtdb-paths";
46
+ /** Firestore collection holding the canonical record. */
47
+ export const EMAIL_EVENTS_COLLECTION = "emailEvents";
48
+ /** Normalise `to` for comparison and storage. */
49
+ function recipients(to) {
50
+ return (Array.isArray(to) ? to : [to]).map((t) => String(t).trim().toLowerCase()).filter(Boolean);
51
+ }
52
+ /**
53
+ * Should this send be recorded at all?
54
+ *
55
+ * Exported so the audit and tests can exercise the gate directly rather than
56
+ * inferring it from behaviour.
57
+ */
58
+ export function shouldRecordSend(to) {
59
+ if (process.env.EMAIL_RECORDER_ALL === "true")
60
+ return true;
61
+ const mailbox = process.env.TESTER_EMAIL_ID?.trim().toLowerCase();
62
+ if (!mailbox)
63
+ return false;
64
+ return recipients(to).includes(mailbox);
65
+ }
66
+ /**
67
+ * Write the record. Never throws, never blocks the send.
68
+ *
69
+ * Firestore is canonical and RTDB is only a ping — the same split the messaging
70
+ * feature already uses. The body does NOT go into RTDB: `bulk_events` is
71
+ * uncapped and a newsletter CSV already goes there, which is the mistake not to
72
+ * repeat on a channel that fires on every email.
73
+ */
74
+ export async function recordSendAttempt(record) {
75
+ if (!shouldRecordSend(record.to))
76
+ return;
77
+ try {
78
+ const { getAdminDb } = await import("../../../providers/db-firebase/admin");
79
+ const db = getAdminDb();
80
+ const now = new Date();
81
+ const doc = {
82
+ to: recipients(record.to),
83
+ subject: record.subject,
84
+ feature: record.feature,
85
+ audience: record.audience,
86
+ channel: record.channel,
87
+ outcome: record.outcome,
88
+ reason: record.reason ?? null,
89
+ html: record.html ?? null,
90
+ createdAt: now,
91
+ };
92
+ const ref = await db.collection(EMAIL_EVENTS_COLLECTION).add(doc);
93
+ /*
94
+ * The ping. Tiny by design: an id, the outcome and the feature are enough for
95
+ * a poller to know something happened and go read the Firestore row.
96
+ * Best-effort separately from the write above — losing the ping costs a
97
+ * poller one extra second, losing the record costs the case its evidence.
98
+ */
99
+ try {
100
+ const { getAdminRealtimeDb } = await import("../../../providers/db-firebase/admin");
101
+ await getAdminRealtimeDb()
102
+ .ref(`${RTDB_PATHS.EMAIL_EVENTS}/${ref.id}`)
103
+ .set({
104
+ id: ref.id,
105
+ feature: record.feature,
106
+ outcome: record.outcome,
107
+ at: now.toISOString(),
108
+ });
109
+ }
110
+ catch (err) {
111
+ // Deliberately unreported: the Firestore row above is the record, and it
112
+ // already succeeded by the time this runs. Losing the ping costs a poller
113
+ // one extra second of latency and loses no evidence, so logging it would
114
+ // add noise to every transient RTDB blip for no recoverable information.
115
+ void normalizeError(err);
116
+ }
117
+ }
118
+ catch (err) {
119
+ const n = normalizeError(err);
120
+ serverLogger.warn("recordSendAttempt: could not record email event", {
121
+ feature: record.feature,
122
+ outcome: record.outcome,
123
+ message: n.message,
124
+ });
125
+ }
126
+ }
package/dist/client.d.ts CHANGED
@@ -414,6 +414,8 @@ export { NavPermissionsManager } from "./features/site-settings/components/NavPe
414
414
  export type { NavPermissionsManagerProps, NavGroup as NavPermissionsGroup, NavItem as NavPermissionsItem } from "./features/site-settings/components/NavPermissionsManager";
415
415
  export { CodeRevealPanel } from "./_internal/client/features/digital-code/CodeRevealPanel";
416
416
  export type { CodeRevealPanelProps, RevealedCode } from "./_internal/client/features/digital-code/CodeRevealPanel";
417
+ export { DigitalContentPoolManager } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
418
+ export type { DigitalContentPoolManagerProps, PoolEntry } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
417
419
  export { PhysicalLocationModal } from "./features/seller/components/PhysicalLocationModal";
418
420
  export type { PhysicalLocation } from "./features/seller/components/PhysicalLocationModal";
419
421
  export { Toggle } from "./ui/components/Toggle";
package/dist/client.js CHANGED
@@ -424,6 +424,12 @@ export { ActionPermissionsManager } from "./features/site-settings/components/Ac
424
424
  export { NavPermissionsManager } from "./features/site-settings/components/NavPermissionsManager";
425
425
  // ── Digital-code code-reveal panel (still used directly on order pages) ───────
426
426
  export { CodeRevealPanel } from "./_internal/client/features/digital-code/CodeRevealPanel";
427
+ /*
428
+ * The SELLER half. Without a surface the pool routes are unreachable, which is
429
+ * the same "finished feature nobody can get to" gap that left the pool empty in
430
+ * the first place (its only writer answered 501).
431
+ */
432
+ export { DigitalContentPoolManager } from "./_internal/client/features/digital-code/DigitalContentPoolManager";
427
433
  export { PhysicalLocationModal } from "./features/seller/components/PhysicalLocationModal";
428
434
  // [CLIENT-ONLY] — Toggle switch primitive.
429
435
  export { Toggle } from "./ui/components/Toggle";
@@ -14,7 +14,16 @@ export async function BlogIndexPageView({ searchParams = {} }) {
14
14
  const page = Number(sp(searchParams, "page")) || 1;
15
15
  const pageSize = Number(sp(searchParams, "pageSize")) || 24;
16
16
  const category = sp(searchParams, "category");
17
- const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
17
+ /*
18
+ * 🛑 `q` MUST be read and forwarded. Without it /blog?q=anything rendered the
19
+ * FULL published list — the same failure the API route's own header records
20
+ * having already fixed on its side, never back-ported here (Root Cause #59).
21
+ * It cannot self-correct on the client either: this result becomes
22
+ * `initialData`, and the listing hook sets `staleTime: Infinity` when given
23
+ * SSR data, so the unfiltered first paint is frozen for that query key.
24
+ */
25
+ const q = sp(searchParams, "q").trim();
26
+ const sieveResult = await safeRead(() => blogRepository.listPublished({ ...(category ? { category } : {}), ...(q ? { search: q } : {}) }, { sorts: sort, page, pageSize }), { route: "/blog", key: "blogPosts.listPublished", fallback: null });
18
27
  // Sandbox posts are public rows by design — not on someone else's first paint.
19
28
  const publicSieve = sieveResult
20
29
  ? { ...sieveResult, items: hidePublicTestData(sieveResult.items) }
@@ -83,9 +83,23 @@ declare class BlogRepository extends BaseRepository<BlogPostDocument> {
83
83
  parseValue: typeof parseSieveDateValue;
84
84
  };
85
85
  };
86
+ /**
87
+ * `search` uses the same `searchTxt` push-down as `listAll` — head token as an
88
+ * `array-contains` clause, remaining tokens AND-refined in memory.
89
+ *
90
+ * 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
91
+ * `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
92
+ * THIS method, so the server-rendered list ignored `q` entirely — and because
93
+ * that result is handed to the client as `initialData` under
94
+ * `staleTime: Infinity`, the unfiltered first paint was frozen and never
95
+ * refetched. Root Cause #59: the fix went into the API route and was never
96
+ * back-ported to the SSR view that duplicates its job. The route's own header
97
+ * documents the API-side half of this exact bug.
98
+ */
86
99
  listPublished(opts: {
87
100
  category?: BlogPostCategory;
88
101
  featuredOnly?: boolean;
102
+ search?: string;
89
103
  }, model: SieveModel): Promise<FirebaseSieveResult<BlogPostDocument>>;
90
104
  /** Derived on every write path via `applyWriteHooks`. */
91
105
  protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
@@ -143,7 +143,25 @@ class BlogRepository extends BaseRepository {
143
143
  throw new DatabaseError(`Failed to find posts by author: ${error instanceof Error ? error.message : "Unknown error"}`);
144
144
  }
145
145
  }
146
+ /**
147
+ * `search` uses the same `searchTxt` push-down as `listAll` — head token as an
148
+ * `array-contains` clause, remaining tokens AND-refined in memory.
149
+ *
150
+ * 🛑 IT HAD NO SEARCH PARAMETER AT ALL, and that is why /blog?q= was inert.
151
+ * `blogGET` searches via `listAll(model, { search })`, but the SSR view calls
152
+ * THIS method, so the server-rendered list ignored `q` entirely — and because
153
+ * that result is handed to the client as `initialData` under
154
+ * `staleTime: Infinity`, the unfiltered first paint was frozen and never
155
+ * refetched. Root Cause #59: the fix went into the API route and was never
156
+ * back-ported to the SSR view that duplicates its job. The route's own header
157
+ * documents the API-side half of this exact bug.
158
+ */
146
159
  async listPublished(opts, model) {
160
+ // A search that yields no usable token must return NOTHING, not the whole
161
+ // published list — the same contract listAll and listForSeller keep.
162
+ const plan = planSearchTxt(opts?.search);
163
+ if (plan.empty)
164
+ return emptySearchResult();
147
165
  let baseQuery = this.getCollection().where(BLOG_POST_FIELDS.STATUS, "==", "published");
148
166
  if (opts?.category) {
149
167
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.CATEGORY, "==", opts.category);
@@ -151,11 +169,15 @@ class BlogRepository extends BaseRepository {
151
169
  if (opts?.featuredOnly) {
152
170
  baseQuery = baseQuery.where(BLOG_POST_FIELDS.IS_FEATURED, "==", true);
153
171
  }
154
- return this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
172
+ if (plan.head) {
173
+ baseQuery = baseQuery.where(BLOG_POST_FIELDS.SEARCH_TXT, "array-contains", plan.head);
174
+ }
175
+ const result = await this.sieveQuery(model, BlogRepository.SIEVE_FIELDS, {
155
176
  baseQuery,
156
177
  defaultPageSize: 12,
157
178
  maxPageSize: 50,
158
179
  });
180
+ return refineSearchTxt(result, plan.rest);
159
181
  }
160
182
  /** Derived on every write path via `applyWriteHooks`. */
161
183
  buildSearchTxtFor(data) {
@@ -155,5 +155,5 @@ export async function CategoryDetailPageView({ slug }) {
155
155
  const hasCover = Boolean(coverImage);
156
156
  return (_jsxs(Main, { children: [_jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: _jsx(Div, { layout: "flex", gap: "2", className: `.5 ${__O.xAuto} [scrollbar-width:none]`, padding: "b-2xs", children: childCategories.map((child) => (_jsxs(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(child.slug)),
157
157
  // audit-responsive-wrap-ok: deliberate horizontal-scroll chip row (parent has overflow-x-auto) — each chip staying single-line is the intended design, not hidden content
158
- className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
158
+ className: "flex-shrink-0 flex items-center gap-[var(--appkit-space-1-5)] rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary transition-colors whitespace-nowrap", children: [child.display?.icon && (_jsx(Span, { className: "leading-none", children: child.display.icon })), child.name, (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0) > 0 && (_jsx(Span, { size: "xs", color: "muted", children: (child.metrics?.totalProductCount ?? child.metrics?.productCount ?? 0).toLocaleString() }))] }, child.id))) }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
159
159
  }
@@ -20,7 +20,7 @@ function CategoryTreeNode({ item, children = [], activeId, onSelect, depth = 0,
20
20
  if (hasChildren)
21
21
  setExpanded((p) => !p);
22
22
  onSelect?.(item);
23
- }, className: `cursor-pointer py-[var(--appkit-space-1-5)] transition select-none ${depthPl} ${isActive ? "bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400" : " hover:bg-surface-hover text-[var(--appkit-color-text-muted)]"}`, align: "center", gap: "xs", padding: "x-xs", rounded: "md", children: [hasChildren && (_jsx(Span, { size: "xs", "aria-hidden": "true", className: `transition-transform duration-150 ${expanded ? "rotate-90" : ""}`, color: "faint", children: "\u25B6" })), !hasChildren && _jsx(Span, { className: "w-3" }), item.display?.icon && (_jsx(Span, { size: "base", "aria-hidden": "true", className: "flex-shrink-0", children: item.display.icon })), _jsx(Text, { className: "flex-1 truncate", children: item.name }), item.metrics && (_jsx(Span, { size: "xs", className: "ml-auto flex-shrink-0 tabular-nums", color: "faint", children: item.metrics.productCount }))] }), hasChildren && expanded && (_jsx(Div, { role: "group", children: children.map((child) => (_jsx(CategoryTreeNode, { item: child, activeId: activeId, onSelect: onSelect, depth: depth + 1 }, child.id))) }))] }));
23
+ }, className: `cursor-pointer py-[var(--appkit-space-1-5)] transition select-none ${depthPl} ${isActive ? "bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400" : " hover:bg-surface-hover text-[var(--appkit-color-text-muted)]"}`, align: "center", gap: "xs", padding: "x-xs", rounded: "md", children: [hasChildren && (_jsx(Span, { size: "xs", "aria-hidden": "true", className: `transition-transform duration-150 ${expanded ? "rotate-90" : ""}`, color: "faint", children: "\u25B6" })), !hasChildren && _jsx(Span, { className: "w-3" }), item.display?.icon && (_jsx(Span, { size: "base", "aria-hidden": "true", className: "flex-shrink-0", children: item.display.icon })), _jsx(Text, { className: "flex-1 truncate", children: item.name }), item.metrics && (_jsx(Span, { size: "xs", className: "ml-auto flex-shrink-0 tabular-nums", color: "faint", children: item.metrics.totalProductCount ?? item.metrics.productCount }))] }), hasChildren && expanded && (_jsx(Div, { role: "group", children: children.map((child) => (_jsx(CategoryTreeNode, { item: child, activeId: activeId, onSelect: onSelect, depth: depth + 1 }, child.id))) }))] }));
24
24
  }
25
25
  /**
26
26
  * CategoryTree — hierarchical expandable navigation for categories.
@@ -32,9 +32,41 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
32
32
  */
33
33
  updateMetricsInBatch(batch: import("firebase-admin/firestore").WriteBatch, categoryId: string, parentIds: string[], productDelta: number, auctionDelta: number, productId?: string): void;
34
34
  /**
35
- * Cloud Functions: full-overwrite category metrics (used by nightly reconciliation job).
35
+ * Cloud Functions: full-overwrite category metrics (nightly reconciliation).
36
+ *
37
+ * 🛑 THE OWN COUNT AND THE ROLLUP ARE SEPARATE ARGUMENTS, and they must be.
38
+ *
39
+ * This method used to take ONE pair of numbers and write it to both
40
+ * `productCount` and `totalProductCount` — so it could not express the
41
+ * distinction the live trigger (`updateMetricsInBatch`, just above) carefully
42
+ * maintains: `productCount` is the category's OWN items, `totalProductCount`
43
+ * is own + every descendant's. The nightly job therefore overwrote correct
44
+ * values with wrong ones every night:
45
+ *
46
+ * - on a leaf it was accidentally right (a leaf has no descendants);
47
+ * - on an ancestor it wrote the DESCENDANT sum into both, so a category
48
+ * with 5 of its own and 15 below reported `productCount: 15` (should be
49
+ * 5) and `totalProductCount: 15` (should be 20 — its own 5 were never
50
+ * added, because the caller's aggregate only walked other rows' leaves);
51
+ * - on a row that is both a leaf and an ancestor, `setMetrics` was called
52
+ * twice and last-write-won.
53
+ *
54
+ * Measured on production 2026-09-14 before the fix: **19 of 65 category rows
55
+ * disagreed with a recount**, every one of them low — the root read 56 against
56
+ * a true 65. Nothing errored, and no page looked broken; a wrong number is
57
+ * simply a number.
36
58
  */
37
- setMetrics(categoryId: string, productCount: number, auctionCount: number, productIds: string[], auctionIds: string[]): Promise<void>;
59
+ setMetrics(categoryId: string, m: {
60
+ /** Items filed DIRECTLY under this category. */
61
+ productCount: number;
62
+ auctionCount: number;
63
+ /** Own + every descendant's. Equals the own count on a leaf. */
64
+ totalProductCount: number;
65
+ totalAuctionCount: number;
66
+ /** Own only — the ids are used for spot-checking, never for the rollup. */
67
+ productIds: string[];
68
+ auctionIds: string[];
69
+ }): Promise<void>;
38
70
  /** SB-UNI — discriminator-keyed listing (sublistings, brands, bundles). */
39
71
  listByType(type: import("../types").CategoryType, opts?: {
40
72
  limit?: number;