@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.
- 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/checkout/actions.js +18 -0
- 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/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/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/client.d.ts +2 -0
- package/dist/client.js +6 -0
- 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/categories/components/CategoryDetailPageView.js +1 -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/contact/email.js +36 -1
- package/dist/features/orders/repository/orders.repository.d.ts +21 -1
- package/dist/features/orders/repository/orders.repository.js +26 -9
- 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__offers.js +1 -1
- package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
- package/dist/features/tester/utils/phases.js +78 -12
- package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
- package/dist/providers/db-firebase/rtdb-paths.js +7 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +12 -0
- package/dist/ui/components/FilePickerButton.d.ts +32 -0
- package/dist/ui/components/FilePickerButton.js +23 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +7 -0
- 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
|
-
|
|
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>;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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;
|