@mohasinac/appkit 4.45.0 → 4.46.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/.build-stamp.json +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- package/dist/features/copilot/index.js +0 -3
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: a product could be filed under exactly ONE category, and the ancestor
|
|
3
|
+
* chain was derived onto it. That cannot express "this is a Dragoon G AND
|
|
4
|
+
* it is Beyblade" — two independent axes, which is what the franchise root
|
|
5
|
+
* exists for. Phase I makes assignment multiple.
|
|
6
|
+
*
|
|
7
|
+
* WHAT: the pure half of multi-category assignment — resolve the authored list,
|
|
8
|
+
* refuse the two shapes that are not allowed, pick the DEFAULT, and build
|
|
9
|
+
* the derived closure. No Firestore, no I/O, so every rule here is
|
|
10
|
+
* testable without a database.
|
|
11
|
+
*
|
|
12
|
+
* EXPORTS: MAX_CATEGORY_ASSIGNMENTS, ENFORCE_LEAF_FILING, ResolvedCategory,
|
|
13
|
+
* readAssignments, assertAssignmentShape, pickDefaultAssignment,
|
|
14
|
+
* buildTaxonomyClosure, findLeafRuleOffenders
|
|
15
|
+
*
|
|
16
|
+
* ── 🛑 THE CLOSURE IS ORDERED, AND THAT IS NOT COSMETIC ────────────────────
|
|
17
|
+
*
|
|
18
|
+
* `categorySlugs[0]` is read as "the leaf" in ~30 places across both repos —
|
|
19
|
+
* measured, not assumed: the order-item snapshot (`expand-order-items.ts`,
|
|
20
|
+
* whose own comment says *"`categorySlugs[0]` is the leaf — the chain is stored
|
|
21
|
+
* self-first"*), BOTH count writers (`category-counts.ts`,
|
|
22
|
+
* `countersReconcile.ts`), BOTH triggers (`onProductWrite.ts`), eight detail
|
|
23
|
+
* views, `ProductForm.tsx` x5, the price-index data path,
|
|
24
|
+
* `listing-adapters.ts`, `productTableColumns.tsx`, `CompareOverlay.tsx`, and
|
|
25
|
+
* `rewriteProductChains`'s own `current[0]`.
|
|
26
|
+
*
|
|
27
|
+
* A set-shaped union of two chains destroys that invariant SILENTLY. Nothing
|
|
28
|
+
* throws; the order snapshot simply starts recording whichever id happened to
|
|
29
|
+
* land first, and the realised-price band moves with it. So the closure is
|
|
30
|
+
* ordered DEFAULT CHAIN FIRST and index 0 stays the default leaf — every one of
|
|
31
|
+
* those readers remains correct by construction rather than by being found.
|
|
32
|
+
*
|
|
33
|
+
* ── 🛑 ONE ASSIGNMENT PER ROOT ─────────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* The user's rule, verbatim: *"a category can be multiple, they should not be
|
|
36
|
+
* under same tree."* It is also the only rule under which `categoryCounts`'
|
|
37
|
+
* invariant `own + sum(children.total) === total` still holds: a product
|
|
38
|
+
* assigned to both an ancestor and its descendant would be counted `own` at the
|
|
39
|
+
* ancestor AND contribute `total` up from the descendant, so the parent's two
|
|
40
|
+
* figures would overlap and no invariant could be asserted.
|
|
41
|
+
*
|
|
42
|
+
* @tag domain:products
|
|
43
|
+
* @tag layer:repository
|
|
44
|
+
* @tag pattern:none
|
|
45
|
+
* @tag access:server-only
|
|
46
|
+
* @tag consumers:ProductRepository.deriveTaxonomy
|
|
47
|
+
* @tag sideEffects:none
|
|
48
|
+
*/
|
|
49
|
+
import { ConflictError, ValidationError } from "../../../errors/index.js";
|
|
50
|
+
/**
|
|
51
|
+
* 🛑 A CAP, because the closure is `array-contains`-indexed and Firestore's
|
|
52
|
+
* `array-contains-any` takes at most 30 comparison values.
|
|
53
|
+
*
|
|
54
|
+
* 4 assignments x 5 tiers (0-4) = 20 closure entries worst case, which leaves
|
|
55
|
+
* headroom under that 30. It also bounds the index-entry cost: the seed loader
|
|
56
|
+
* commits at most 20,000 array elements per batch, and every closure entry is
|
|
57
|
+
* one index entry per product.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_CATEGORY_ASSIGNMENTS = 4;
|
|
60
|
+
/**
|
|
61
|
+
* 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
|
|
62
|
+
*
|
|
63
|
+
* It ships `false` and is flipped only once the data is clean, and the reason is
|
|
64
|
+
* a measurement: **70 of the 71 hand-authored products file at a non-leaf
|
|
65
|
+
* today** and not one of them is a lot. Enforcing first would make the whole
|
|
66
|
+
* catalogue unsaveable — and worse, it would make the very seed writes that fix
|
|
67
|
+
* the data fail, because those also go through `deriveTaxonomy`.
|
|
68
|
+
*
|
|
69
|
+
* This is the G3a lesson, which cost a symmetric rule its credibility: the plan
|
|
70
|
+
* specified "a lot takes a non-leaf while singles take leaves", measurement
|
|
71
|
+
* rejected the second half, and the half-rule has been live since. The rule is
|
|
72
|
+
* right; enforcing it before its data is not.
|
|
73
|
+
*
|
|
74
|
+
* Flip procedure, and it is a ratchet rather than a judgement call: run
|
|
75
|
+
* `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
|
|
76
|
+
* A hand count is not the measurement — the earlier one said 58 of 70 and this
|
|
77
|
+
* pass measured 70 of 71.
|
|
78
|
+
*/
|
|
79
|
+
export const ENFORCE_LEAF_FILING = false;
|
|
80
|
+
/**
|
|
81
|
+
* The authored assignment list, from whichever field the caller used.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately tolerant of all three existing shapes, because every one of them
|
|
84
|
+
* is live today and none should need editing to keep working:
|
|
85
|
+
*
|
|
86
|
+
* - `categoryIds: [a, b]` the new multi-select
|
|
87
|
+
* - `category: "a"` what `CategoryInlineSelect` writes, and what
|
|
88
|
+
* `productCreateSchema` declares
|
|
89
|
+
* - `categorySlugs: [a, ...]` the seed, and the admin `.passthrough()` route
|
|
90
|
+
*
|
|
91
|
+
* 🛑 The scalar still WINS over `categorySlugs`, unchanged from before this
|
|
92
|
+
* change and for the recorded reason: the seller edit form sends a stale
|
|
93
|
+
* derived chain alongside a fresh `category`, so preferring the chain resolved
|
|
94
|
+
* the OLD leaf and wrote it back over the user's choice.
|
|
95
|
+
*
|
|
96
|
+
* Duplicates are deduped rather than refused. A duplicate assignment is exactly
|
|
97
|
+
* equivalent to not having it, so there is no semantic question for the caller
|
|
98
|
+
* to answer — unlike a duplicate assembly component, which would change
|
|
99
|
+
* arithmetic.
|
|
100
|
+
*/
|
|
101
|
+
export function readAssignments(input) {
|
|
102
|
+
const raw = Array.isArray(input.categoryIds) && input.categoryIds.length > 0
|
|
103
|
+
? input.categoryIds
|
|
104
|
+
: input.category
|
|
105
|
+
? [input.category]
|
|
106
|
+
: Array.isArray(input.categorySlugs) && input.categorySlugs.length > 0
|
|
107
|
+
? /*
|
|
108
|
+
* Only the HEAD, never the whole array. `categorySlugs` is a derived
|
|
109
|
+
* closure: treating its ancestors as assignments would file a product
|
|
110
|
+
* at every tier of its own chain, which the one-per-root rule would
|
|
111
|
+
* then immediately refuse.
|
|
112
|
+
*/
|
|
113
|
+
[input.categorySlugs[0]]
|
|
114
|
+
: [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const id of raw) {
|
|
118
|
+
const trimmed = typeof id === "string" ? id.trim() : "";
|
|
119
|
+
if (!trimmed || seen.has(trimmed))
|
|
120
|
+
continue;
|
|
121
|
+
seen.add(trimmed);
|
|
122
|
+
out.push(trimmed);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
|
|
127
|
+
export function findLeafRuleOffenders(resolved, isLot) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const c of resolved) {
|
|
130
|
+
if (isLot) {
|
|
131
|
+
if (c.isLeaf === true)
|
|
132
|
+
out.push({ id: c.id, name: c.name, reason: "lot-at-leaf" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c.isLeaf === true)
|
|
136
|
+
continue;
|
|
137
|
+
out.push({
|
|
138
|
+
id: c.id,
|
|
139
|
+
name: c.name,
|
|
140
|
+
reason: c.isLeaf === undefined ? "not-indexed" : "single-at-branch",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Refuse the shapes that are not allowed. Throws; never corrects.
|
|
147
|
+
*
|
|
148
|
+
* 🛑 REFUSED, never corrected — silently dropping a second assignment, or
|
|
149
|
+
* re-filing one tier up, decides a taxonomy question on the seller's behalf and
|
|
150
|
+
* leaves them a listing they can no longer find (Root Cause #76 inverted).
|
|
151
|
+
*/
|
|
152
|
+
export function assertAssignmentShape(resolved, isLot, opts = {}) {
|
|
153
|
+
if (resolved.length > MAX_CATEGORY_ASSIGNMENTS) {
|
|
154
|
+
throw new ValidationError(`A listing can be filed under at most ${MAX_CATEGORY_ASSIGNMENTS} categories; this one names ${resolved.length}.`);
|
|
155
|
+
}
|
|
156
|
+
/*
|
|
157
|
+
* One per root. Grouped on `rootId` rather than on an ancestor walk, because
|
|
158
|
+
* `rootId` is already on every category document and a walk would cost a read
|
|
159
|
+
* per assignment on the write path.
|
|
160
|
+
*
|
|
161
|
+
* A row with no `rootId` is skipped rather than refused: like `isLeaf`, its
|
|
162
|
+
* absence means the tree has not been rebuilt, and refusing a write over our
|
|
163
|
+
* own stale derived field turns a maintenance gap into a seller-facing error.
|
|
164
|
+
*/
|
|
165
|
+
const byRoot = new Map();
|
|
166
|
+
for (const c of resolved) {
|
|
167
|
+
if (!c.rootId)
|
|
168
|
+
continue;
|
|
169
|
+
const group = byRoot.get(c.rootId);
|
|
170
|
+
if (group)
|
|
171
|
+
group.push(c);
|
|
172
|
+
else
|
|
173
|
+
byRoot.set(c.rootId, [c]);
|
|
174
|
+
}
|
|
175
|
+
for (const group of byRoot.values()) {
|
|
176
|
+
if (group.length < 2)
|
|
177
|
+
continue;
|
|
178
|
+
const named = group.map((c) => `"${c.name ?? c.id}"`).join(" and ");
|
|
179
|
+
throw new ValidationError(`${named} are in the same category tree, so a listing cannot be filed under both. ` +
|
|
180
|
+
`Pick one, or pick the category above them that covers both.`);
|
|
181
|
+
}
|
|
182
|
+
const offenders = findLeafRuleOffenders(resolved, isLot);
|
|
183
|
+
if (offenders.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
/*
|
|
186
|
+
* The lot half has been enforced since G3a and stays enforced regardless of
|
|
187
|
+
* the flag — it is the half measurement VALIDATED, and the argument is about
|
|
188
|
+
* the items rather than about counting: filing "12 assorted Burst beys" under
|
|
189
|
+
* `category-dragoon-g` asserts all twelve are Dragoon G.
|
|
190
|
+
*/
|
|
191
|
+
const lotAtLeaf = offenders.find((o) => o.reason === "lot-at-leaf");
|
|
192
|
+
if (lotAtLeaf) {
|
|
193
|
+
throw new ValidationError(`A lot holds several different items, so it cannot be filed under "${lotAtLeaf.name ?? lotAtLeaf.id}", ` +
|
|
194
|
+
`which is a single-item category. Pick a broader category — its parent.`);
|
|
195
|
+
}
|
|
196
|
+
if (!opts.enforceLeafFiling) {
|
|
197
|
+
opts.warn?.("Product files at a non-leaf category — allowed today, refused once ENFORCE_LEAF_FILING flips", { offenders });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const notIndexed = offenders.find((o) => o.reason === "not-indexed");
|
|
201
|
+
if (notIndexed) {
|
|
202
|
+
/*
|
|
203
|
+
* 409, not 400: the request is well formed and the seller is not confused —
|
|
204
|
+
* OUR category tree has not been rebuilt, so we cannot tell whether that
|
|
205
|
+
* node is a leaf. The correct next action is to rebuild the tree, not to
|
|
206
|
+
* fix a field. Same distinction as removing a booked lottery slot.
|
|
207
|
+
*/
|
|
208
|
+
throw new ConflictError(`"${notIndexed.name ?? notIndexed.id}" has not been indexed yet, so we cannot tell whether it is a ` +
|
|
209
|
+
`single-item category. Rebuild the category tree and try again.`);
|
|
210
|
+
}
|
|
211
|
+
const first = offenders[0];
|
|
212
|
+
throw new ValidationError(`"${first.name ?? first.id}" has subcategories, so it describes a group rather than one item. ` +
|
|
213
|
+
`Pick the specific category this listing belongs to — or mark it as a lot if it holds several different items.`);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Which assignment owns the breadcrumb, the canonical chain and index 0.
|
|
217
|
+
*
|
|
218
|
+
* Three deterministic levels:
|
|
219
|
+
* 1. the DEEPEST assignment (highest `tier`);
|
|
220
|
+
* 2. tie -> the highest listing count, which the caller supplies;
|
|
221
|
+
* 3. still tied -> the order the seller picked.
|
|
222
|
+
*
|
|
223
|
+
* 🛑 "Most listings" CANNOT be the first criterion, and this is the one place
|
|
224
|
+
* the instruction had to be corrected. A franchise node spans every product
|
|
225
|
+
* type, so `category-beyblade` outranks `category-dragoon-g` on count for
|
|
226
|
+
* EVERY Beyblade product — which would put `Home > Franchises > Beyblade` above
|
|
227
|
+
* every listing and silently widen the realised-price band from "Dragoon G" to
|
|
228
|
+
* "all Beyblade", because that band is keyed on the snapshotted
|
|
229
|
+
* `categorySlugs[0]`.
|
|
230
|
+
*
|
|
231
|
+
* Depth decides because depth IS specificity, which is also the rule singles
|
|
232
|
+
* are filed by. A model leaf is tier 4 and a franchise leaf is tier 1, so the
|
|
233
|
+
* tie-break only ever runs between two equally specific assignments — exactly
|
|
234
|
+
* where "which is busier" is the right question.
|
|
235
|
+
*
|
|
236
|
+
* `counts` is consulted ONLY on a tie, so the common single-assignment case
|
|
237
|
+
* costs no extra read at all.
|
|
238
|
+
*/
|
|
239
|
+
export function pickDefaultAssignment(resolved, counts) {
|
|
240
|
+
if (resolved.length <= 1)
|
|
241
|
+
return 0;
|
|
242
|
+
let best = 0;
|
|
243
|
+
for (let i = 1; i < resolved.length; i += 1) {
|
|
244
|
+
const a = resolved[i];
|
|
245
|
+
const b = resolved[best];
|
|
246
|
+
const tierA = a.tier ?? -1;
|
|
247
|
+
const tierB = b.tier ?? -1;
|
|
248
|
+
if (tierA > tierB) {
|
|
249
|
+
best = i;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (tierA < tierB)
|
|
253
|
+
continue;
|
|
254
|
+
/*
|
|
255
|
+
* `undefined` is not 0 here either: a category with no counts row has not
|
|
256
|
+
* been counted, and defaulting it to 0 would let an uncounted node lose a
|
|
257
|
+
* tie it might have won. Absent on BOTH sides falls through to authored
|
|
258
|
+
* order, which is level 3.
|
|
259
|
+
*/
|
|
260
|
+
const countA = counts?.get(a.id);
|
|
261
|
+
const countB = counts?.get(b.id);
|
|
262
|
+
if (countA !== undefined && (countB === undefined || countA > countB))
|
|
263
|
+
best = i;
|
|
264
|
+
}
|
|
265
|
+
return best;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The derived closure and the display names.
|
|
269
|
+
*
|
|
270
|
+
* `categorySlugs` = the default chain leaf-first, then each remaining chain
|
|
271
|
+
* leaf-first, deduped. See the header for why the order is load-bearing.
|
|
272
|
+
*
|
|
273
|
+
* `categoryNames` = the DEFAULT chain only. A union has no readable path, and
|
|
274
|
+
* the breadcrumb builder reverses this array to render root-first — so keeping
|
|
275
|
+
* it single-chain is what leaves every existing breadcrumb byte-identical.
|
|
276
|
+
*/
|
|
277
|
+
export function buildTaxonomyClosure(resolved, defaultIdx) {
|
|
278
|
+
if (resolved.length === 0) {
|
|
279
|
+
return { categorySlugs: [], categoryNames: [], categoryIdNames: [] };
|
|
280
|
+
}
|
|
281
|
+
const def = resolved[defaultIdx] ?? resolved[0];
|
|
282
|
+
const ordered = [def, ...resolved.filter((c) => c.id !== def.id)];
|
|
283
|
+
const categorySlugs = [];
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
for (const c of ordered) {
|
|
286
|
+
// Self first, root last — byte-identical to what the seed hand-writes and
|
|
287
|
+
// to what `rewriteProductChains` produces. Those three must agree.
|
|
288
|
+
for (const id of [c.id, ...c.parentIds.slice().reverse()]) {
|
|
289
|
+
if (!id || seen.has(id))
|
|
290
|
+
continue;
|
|
291
|
+
seen.add(id);
|
|
292
|
+
categorySlugs.push(id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const categoryNames = [def.name, ...def.ancestorNames.slice().reverse()].filter((n) => !!n);
|
|
296
|
+
/*
|
|
297
|
+
* I5b — one display name per ASSIGNMENT, in the same order, so the breadcrumb
|
|
298
|
+
* row can offer the other categories as links without a second read on a
|
|
299
|
+
* cached page. Deliberately NOT their full chains: the alternate crumb shows
|
|
300
|
+
* the other categories, not N stacked trails.
|
|
301
|
+
*/
|
|
302
|
+
const categoryIdNames = ordered.map((c) => c.name ?? c.id);
|
|
303
|
+
return { categorySlugs, categoryNames, categoryIdNames };
|
|
304
|
+
}
|
|
@@ -99,6 +99,23 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
99
99
|
* (Root Cause #6/#18) — a raw collection read has neither problem.
|
|
100
100
|
*/
|
|
101
101
|
private deriveTaxonomy;
|
|
102
|
+
/**
|
|
103
|
+
* Listing counts for a handful of category ids, for the default tie-break.
|
|
104
|
+
*
|
|
105
|
+
* 🛑 Called ONLY when two assignments tie at the deepest tier, so the common
|
|
106
|
+
* single-assignment write pays nothing. One `getAll` of at most
|
|
107
|
+
* `MAX_CATEGORY_ASSIGNMENTS` documents by id — no index, no query.
|
|
108
|
+
*
|
|
109
|
+
* 🛑 A missing row is OMITTED from the map rather than defaulted to 0.
|
|
110
|
+
* `categoryCounts` documents the same rule: `undefined` means "not counted
|
|
111
|
+
* yet", and defaulting it to zero would let an uncounted node lose a tie it
|
|
112
|
+
* might have won.
|
|
113
|
+
*
|
|
114
|
+
* Reads the collection through `this.db` rather than importing
|
|
115
|
+
* `categoryCountsRepository` — no repository here imports another feature's
|
|
116
|
+
* (Root Cause #6/#18), and a raw read by id has neither problem.
|
|
117
|
+
*/
|
|
118
|
+
private readAssignmentCounts;
|
|
102
119
|
/**
|
|
103
120
|
* Resolve the GST rate floor, and gross up an ex-tax price if the seller
|
|
104
121
|
* authors that way.
|
|
@@ -176,11 +193,47 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
176
193
|
/** The keys whose presence means a write could change the product's taxonomy. */
|
|
177
194
|
private static readonly TAXONOMY_KEYS;
|
|
178
195
|
update(id: string, data: Partial<ProductDocument>): Promise<ProductDocument>;
|
|
196
|
+
/**
|
|
197
|
+
* G3b — move the leaf→root listing counters for one product transition.
|
|
198
|
+
*
|
|
199
|
+
* 🛑 Called from HERE rather than from a Firestore trigger because
|
|
200
|
+
* `FieldValue.increment` is atomic but **not idempotent** and a trigger is
|
|
201
|
+
* at-least-once: a redelivered trigger double-counts permanently and
|
|
202
|
+
* invisibly. See `repository/category-counts.ts` for the full argument.
|
|
203
|
+
*
|
|
204
|
+
* 🛑 And the guard is on (chain, leaf, type, isLot, AVAILABILITY) — not on
|
|
205
|
+
* the taxonomy alone. `draft → published` changes no `categorySlugs`, so a
|
|
206
|
+
* taxonomy-only guard would never move the count on the one transition that
|
|
207
|
+
* matters most, with nothing to notice.
|
|
208
|
+
*
|
|
209
|
+
* Availability comes from `isListingRowAvailable`, the SAME predicate the
|
|
210
|
+
* listing query and `countersReconcile` use, so the incremental and nightly
|
|
211
|
+
* values can agree. Four partial copies of that predicate is what Root Cause
|
|
212
|
+
* #73 cost; this module takes the verdict as an argument rather than
|
|
213
|
+
* recomputing it.
|
|
214
|
+
*/
|
|
215
|
+
private syncCategoryCounts;
|
|
179
216
|
create(input: ProductCreateInput): Promise<ProductDocument>;
|
|
180
217
|
findByStore(storeId: string): Promise<ProductDocument[]>;
|
|
181
218
|
findByStatus(status: ProductStatus): Promise<ProductDocument[]>;
|
|
182
219
|
findFeatured(): Promise<ProductDocument[]>;
|
|
183
220
|
findByCategory(category: string): Promise<ProductDocument[]>;
|
|
221
|
+
/**
|
|
222
|
+
* I4 — products ASSIGNED to this category, not merely descended from it.
|
|
223
|
+
*
|
|
224
|
+
* 🛑 The distinction is the whole point, and the category-delete cascade is
|
|
225
|
+
* what needs it. `findByCategory` matches the derived CLOSURE, so it also
|
|
226
|
+
* returns every product in the descendant subtree — those belong to the
|
|
227
|
+
* children and travel with them when the children are reparented.
|
|
228
|
+
* `findBy("category", id)` matches only the scalar, i.e. the DEFAULT leaf, so
|
|
229
|
+
* a product that named this category as its SECOND assignment was invisible
|
|
230
|
+
* and the delete left a dangling id in its `categoryIds`.
|
|
231
|
+
*
|
|
232
|
+
* This matches the assigned list, and falls back to the scalar for every row
|
|
233
|
+
* written before `categoryIds` existed — two queries, because Firestore
|
|
234
|
+
* permits one array operator per query and there is no OR across fields.
|
|
235
|
+
*/
|
|
236
|
+
findByAssignment(categoryId: string): Promise<ProductDocument[]>;
|
|
184
237
|
/**
|
|
185
238
|
* Published products sharing at least one tag with the given list — "you
|
|
186
239
|
* might also like" style discovery. `array-contains-any` caps at 10 values,
|
|
@@ -286,7 +339,11 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
286
339
|
canFilter: boolean;
|
|
287
340
|
canSort: boolean;
|
|
288
341
|
};
|
|
289
|
-
|
|
342
|
+
views7d: {
|
|
343
|
+
canFilter: boolean;
|
|
344
|
+
canSort: boolean;
|
|
345
|
+
};
|
|
346
|
+
views30d: {
|
|
290
347
|
canFilter: boolean;
|
|
291
348
|
canSort: boolean;
|
|
292
349
|
};
|