@mohasinac/appkit 4.45.0 → 4.47.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/BreadcrumbTrail.d.ts +24 -1
- package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
- 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/CategoryInlineSelect.d.ts +17 -1
- package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
- package/dist/features/seller/components/SellerProductShell.js +30 -1
- 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-generated.d.ts +24 -0
- package/dist/seed/_helpers/category-content-generated.js +1837 -0
- 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
|
@@ -115,7 +115,15 @@ export function CategoryTemplatePicker({ categoryId, condition, context, isDescr
|
|
|
115
115
|
* accurate BEFORE the seller commits. A note that appears only after the
|
|
116
116
|
* text lands is a report on something they can no longer choose about.
|
|
117
117
|
*/
|
|
118
|
-
|
|
118
|
+
/*
|
|
119
|
+
* 🛑 Server slots merge UNDERNEATH the form's context, so a draft-derived
|
|
120
|
+
* value always wins. The two key spaces are disjoint today — the form knows
|
|
121
|
+
* the listing, the server knows the category — and this ordering is what
|
|
122
|
+
* keeps it safe if they ever overlap: the more specific source is the one
|
|
123
|
+
* holding the item in front of the seller.
|
|
124
|
+
*/
|
|
125
|
+
const resolved = React.useMemo(() => ({ ...(data?.slots ?? {}), ...context }), [data?.slots, context]);
|
|
126
|
+
const filled = React.useMemo(() => (template ? interpolate(template.body, resolved) : null), [template, resolved]);
|
|
119
127
|
/*
|
|
120
128
|
* Auto-fill when the seller owns nothing. Deliberately an effect rather than
|
|
121
129
|
* a render-time write: the body has to reach the draft, which is the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CategoryCountsDocument } from "../schemas/category-counts";
|
|
1
2
|
import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
2
3
|
import { type CategoryCreateInput, type CategoryDocument, type CategoryMoveInput, type CategoryTreeNode } from "../schemas";
|
|
3
4
|
import type { FirestoreDocument, JsonValue } from "@mohasinac/appkit";
|
|
@@ -157,6 +158,36 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
157
158
|
* The caller must have already fetched `parentIds` for the category.
|
|
158
159
|
*/
|
|
159
160
|
updateMetricsInBatch(batch: import("firebase-admin/firestore").WriteBatch, categoryId: string, parentIds: string[], productDelta: number, auctionDelta: number, productId?: string): void;
|
|
161
|
+
/**
|
|
162
|
+
* I3 — the same live metric update, for a product filed under SEVERAL
|
|
163
|
+
* categories at once.
|
|
164
|
+
*
|
|
165
|
+
* 🛑 WHY THIS EXISTS RATHER THAN CALLING THE SINGLE VERSION IN A LOOP.
|
|
166
|
+
* **Firestore rejects a batch containing two writes to the same document.**
|
|
167
|
+
* Two assignments whose chains share an ancestor would each stage a
|
|
168
|
+
* `batch.update` on that ancestor, and the whole batch throws — losing every
|
|
169
|
+
* increment in it, not just the duplicate.
|
|
170
|
+
*
|
|
171
|
+
* Under the one-per-root rule the chains are disjoint and it cannot happen.
|
|
172
|
+
* That is not sufficient grounds to skip the aggregation: the rule is
|
|
173
|
+
* enforced at ONE write path (`deriveTaxonomy`), while this trigger fires for
|
|
174
|
+
* whatever reached Firestore — including a seeded row and the admin
|
|
175
|
+
* `.passthrough()` route, neither of which passes through it. Aggregating
|
|
176
|
+
* costs a Map; being wrong costs a silently dropped counter.
|
|
177
|
+
*
|
|
178
|
+
* So: one `batch.update` per DISTINCT document, with the own-deltas and the
|
|
179
|
+
* rollup-deltas summed per id first.
|
|
180
|
+
*
|
|
181
|
+
* 🛑 `own` lands on every assignment and `total` lands once per distinct id
|
|
182
|
+
* across all their chains — which is the arithmetic `CategoryCountsDocument`
|
|
183
|
+
* states: `total` already includes `own`, and the two are never summed.
|
|
184
|
+
*/
|
|
185
|
+
updateMetricsForAssignmentsInBatch(batch: import("firebase-admin/firestore").WriteBatch, assignments: Array<{
|
|
186
|
+
id: string;
|
|
187
|
+
parentIds: string[];
|
|
188
|
+
}>, productDelta: number, auctionDelta: number, productId?: string,
|
|
189
|
+
/** A brand row: flat, so own and rollup move together. */
|
|
190
|
+
brandId?: string | null): void;
|
|
160
191
|
/**
|
|
161
192
|
* Cloud Functions: full-overwrite category metrics (nightly reconciliation).
|
|
162
193
|
*
|
|
@@ -182,6 +213,19 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
182
213
|
* a true 65. Nothing errored, and no page looked broken; a wrong number is
|
|
183
214
|
* simply a number.
|
|
184
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* G3b — this category's OWN and ROLLUP counts, as one document read.
|
|
218
|
+
*
|
|
219
|
+
* 🛑 Returns `null` when no counts document exists, NEVER a zeroed object.
|
|
220
|
+
* An unknown count must stay distinguishable from a count of nothing (Root
|
|
221
|
+
* Cause #72), so the caller falls back to `metrics` and renders exactly as
|
|
222
|
+
* it did before — which is what makes `categoryCounts` additive rather than
|
|
223
|
+
* a regression on every category until its first product mutation.
|
|
224
|
+
*
|
|
225
|
+
* One doc by id: no index, no query, and safe inside a page already at Rule
|
|
226
|
+
* #6's round-trip budget provided the caller runs it in parallel.
|
|
227
|
+
*/
|
|
228
|
+
getLiveCounts(categoryId: string): Promise<CategoryCountsDocument | null>;
|
|
185
229
|
setMetrics(categoryId: string, m: {
|
|
186
230
|
/** Items filed DIRECTLY under this category. */
|
|
187
231
|
productCount: number;
|
|
@@ -206,6 +250,27 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
206
250
|
* Used by /api/sublisting-categories/[slug]/listings.
|
|
207
251
|
*/
|
|
208
252
|
getSublistingListings(sublistingId: string, limit?: number): Promise<FirestoreDocument[]>;
|
|
253
|
+
/**
|
|
254
|
+
* I4 — re-cache a renamed node's name into every descendant's `ancestors[]`,
|
|
255
|
+
* and rebuild their `searchTxt`.
|
|
256
|
+
*
|
|
257
|
+
* 🛑 WHY A RENAME NEEDS THIS AT ALL. `ancestors[]` is `{id, name, tier}` —
|
|
258
|
+
* a denormalised copy of a name that lives on another document. Nothing
|
|
259
|
+
* re-synced it, so renaming "Beyblade Burst" left every node beneath it
|
|
260
|
+
* holding the old string, and `buildCategorySearchTxt` indexes those names
|
|
261
|
+
* deliberately: typing "burst" reaches `category-lost-longinus` BECAUSE
|
|
262
|
+
* Beyblade Burst is one of its ancestors. So a rename silently broke lineage
|
|
263
|
+
* search for the whole subtree while the node's own page looked perfect.
|
|
264
|
+
*
|
|
265
|
+
* Mirror-drift (Root Cause #42) on the taxonomy's own axis, and the same
|
|
266
|
+
* class as `storeName` on a product — which already has a backfill job for
|
|
267
|
+
* exactly this reason.
|
|
268
|
+
*
|
|
269
|
+
* Returns the ids actually written. A node already carrying the right names
|
|
270
|
+
* is skipped, so a re-run costs nothing — which is what lets an
|
|
271
|
+
* at-least-once trigger start the job that calls this.
|
|
272
|
+
*/
|
|
273
|
+
refreshSubtreeAncestors(categoryId: string): Promise<string[]>;
|
|
209
274
|
/**
|
|
210
275
|
* SB-UNI-B — delete a sublisting category and unlink all products
|
|
211
276
|
* that referenced it. Mirrors the cascade behavior of the old
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
2
|
+
import { CATEGORY_COUNTS_COLLECTION, } from "../schemas/category-counts.js";
|
|
2
3
|
import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops.js";
|
|
3
4
|
import { DatabaseError } from "../../../errors/index.js";
|
|
4
5
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase/index.js";
|
|
@@ -701,35 +702,81 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
701
702
|
* The caller must have already fetched `parentIds` for the category.
|
|
702
703
|
*/
|
|
703
704
|
updateMetricsInBatch(batch, categoryId, parentIds, productDelta, auctionDelta, productId) {
|
|
705
|
+
this.updateMetricsForAssignmentsInBatch(batch, [{ id: categoryId, parentIds }], productDelta, auctionDelta, productId);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* I3 — the same live metric update, for a product filed under SEVERAL
|
|
709
|
+
* categories at once.
|
|
710
|
+
*
|
|
711
|
+
* 🛑 WHY THIS EXISTS RATHER THAN CALLING THE SINGLE VERSION IN A LOOP.
|
|
712
|
+
* **Firestore rejects a batch containing two writes to the same document.**
|
|
713
|
+
* Two assignments whose chains share an ancestor would each stage a
|
|
714
|
+
* `batch.update` on that ancestor, and the whole batch throws — losing every
|
|
715
|
+
* increment in it, not just the duplicate.
|
|
716
|
+
*
|
|
717
|
+
* Under the one-per-root rule the chains are disjoint and it cannot happen.
|
|
718
|
+
* That is not sufficient grounds to skip the aggregation: the rule is
|
|
719
|
+
* enforced at ONE write path (`deriveTaxonomy`), while this trigger fires for
|
|
720
|
+
* whatever reached Firestore — including a seeded row and the admin
|
|
721
|
+
* `.passthrough()` route, neither of which passes through it. Aggregating
|
|
722
|
+
* costs a Map; being wrong costs a silently dropped counter.
|
|
723
|
+
*
|
|
724
|
+
* So: one `batch.update` per DISTINCT document, with the own-deltas and the
|
|
725
|
+
* rollup-deltas summed per id first.
|
|
726
|
+
*
|
|
727
|
+
* 🛑 `own` lands on every assignment and `total` lands once per distinct id
|
|
728
|
+
* across all their chains — which is the arithmetic `CategoryCountsDocument`
|
|
729
|
+
* states: `total` already includes `own`, and the two are never summed.
|
|
730
|
+
*/
|
|
731
|
+
updateMetricsForAssignmentsInBatch(batch, assignments, productDelta, auctionDelta, productId,
|
|
732
|
+
/** A brand row: flat, so own and rollup move together. */
|
|
733
|
+
brandId) {
|
|
704
734
|
const now = new Date();
|
|
705
735
|
const colRef = this.db.collection(this.collection);
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
736
|
+
/** id → how many times `own` moves, and whether the rollup moves. */
|
|
737
|
+
const own = new Map();
|
|
738
|
+
const rollup = new Set();
|
|
739
|
+
for (const a of assignments) {
|
|
740
|
+
if (!a.id)
|
|
741
|
+
continue;
|
|
742
|
+
own.set(a.id, (own.get(a.id) ?? 0) + 1);
|
|
743
|
+
rollup.add(a.id);
|
|
744
|
+
for (const ancestorId of a.parentIds)
|
|
745
|
+
if (ancestorId)
|
|
746
|
+
rollup.add(ancestorId);
|
|
747
|
+
}
|
|
748
|
+
if (brandId) {
|
|
749
|
+
own.set(brandId, (own.get(brandId) ?? 0) + 1);
|
|
750
|
+
rollup.add(brandId);
|
|
751
|
+
}
|
|
752
|
+
for (const id of rollup) {
|
|
753
|
+
/*
|
|
754
|
+
* `ownTimes` is 0 for a pure ancestor and 1 for an assignment. It is only
|
|
755
|
+
* ever >1 if the SAME id was assigned twice, which `readAssignments`
|
|
756
|
+
* dedupes — kept as a multiplier rather than a boolean so that case
|
|
757
|
+
* stays arithmetically honest instead of silently collapsing.
|
|
758
|
+
*/
|
|
759
|
+
const ownTimes = own.get(id) ?? 0;
|
|
760
|
+
const update = {
|
|
727
761
|
"metrics.totalProductCount": increment(productDelta),
|
|
728
762
|
"metrics.totalAuctionCount": increment(auctionDelta),
|
|
729
763
|
"metrics.totalItemCount": increment(productDelta + auctionDelta),
|
|
730
764
|
"metrics.lastUpdated": now,
|
|
731
765
|
updatedAt: now,
|
|
732
|
-
}
|
|
766
|
+
};
|
|
767
|
+
if (ownTimes > 0) {
|
|
768
|
+
update["metrics.productCount"] = increment(productDelta * ownTimes);
|
|
769
|
+
update["metrics.auctionCount"] = increment(auctionDelta * ownTimes);
|
|
770
|
+
if (productId && productDelta !== 0) {
|
|
771
|
+
update["metrics.productIds"] =
|
|
772
|
+
productDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
|
|
773
|
+
}
|
|
774
|
+
if (productId && auctionDelta !== 0) {
|
|
775
|
+
update["metrics.auctionIds"] =
|
|
776
|
+
auctionDelta > 0 ? arrayUnion(productId) : arrayRemove(productId);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
batch.update(colRef.doc(id), update);
|
|
733
780
|
}
|
|
734
781
|
}
|
|
735
782
|
/**
|
|
@@ -757,6 +804,25 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
757
804
|
* a true 65. Nothing errored, and no page looked broken; a wrong number is
|
|
758
805
|
* simply a number.
|
|
759
806
|
*/
|
|
807
|
+
/**
|
|
808
|
+
* G3b — this category's OWN and ROLLUP counts, as one document read.
|
|
809
|
+
*
|
|
810
|
+
* 🛑 Returns `null` when no counts document exists, NEVER a zeroed object.
|
|
811
|
+
* An unknown count must stay distinguishable from a count of nothing (Root
|
|
812
|
+
* Cause #72), so the caller falls back to `metrics` and renders exactly as
|
|
813
|
+
* it did before — which is what makes `categoryCounts` additive rather than
|
|
814
|
+
* a regression on every category until its first product mutation.
|
|
815
|
+
*
|
|
816
|
+
* One doc by id: no index, no query, and safe inside a page already at Rule
|
|
817
|
+
* #6's round-trip budget provided the caller runs it in parallel.
|
|
818
|
+
*/
|
|
819
|
+
async getLiveCounts(categoryId) {
|
|
820
|
+
const snap = await this.db
|
|
821
|
+
.collection(CATEGORY_COUNTS_COLLECTION)
|
|
822
|
+
.doc(categoryId)
|
|
823
|
+
.get();
|
|
824
|
+
return snap.exists ? snap.data() : null;
|
|
825
|
+
}
|
|
760
826
|
async setMetrics(categoryId, m) {
|
|
761
827
|
await this.db
|
|
762
828
|
.collection(this.collection)
|
|
@@ -813,6 +879,86 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
813
879
|
.get();
|
|
814
880
|
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
815
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* I4 — re-cache a renamed node's name into every descendant's `ancestors[]`,
|
|
884
|
+
* and rebuild their `searchTxt`.
|
|
885
|
+
*
|
|
886
|
+
* 🛑 WHY A RENAME NEEDS THIS AT ALL. `ancestors[]` is `{id, name, tier}` —
|
|
887
|
+
* a denormalised copy of a name that lives on another document. Nothing
|
|
888
|
+
* re-synced it, so renaming "Beyblade Burst" left every node beneath it
|
|
889
|
+
* holding the old string, and `buildCategorySearchTxt` indexes those names
|
|
890
|
+
* deliberately: typing "burst" reaches `category-lost-longinus` BECAUSE
|
|
891
|
+
* Beyblade Burst is one of its ancestors. So a rename silently broke lineage
|
|
892
|
+
* search for the whole subtree while the node's own page looked perfect.
|
|
893
|
+
*
|
|
894
|
+
* Mirror-drift (Root Cause #42) on the taxonomy's own axis, and the same
|
|
895
|
+
* class as `storeName` on a product — which already has a backfill job for
|
|
896
|
+
* exactly this reason.
|
|
897
|
+
*
|
|
898
|
+
* Returns the ids actually written. A node already carrying the right names
|
|
899
|
+
* is skipped, so a re-run costs nothing — which is what lets an
|
|
900
|
+
* at-least-once trigger start the job that calls this.
|
|
901
|
+
*/
|
|
902
|
+
async refreshSubtreeAncestors(categoryId) {
|
|
903
|
+
const descendants = await this.getDescendants(categoryId);
|
|
904
|
+
if (descendants.length === 0)
|
|
905
|
+
return [];
|
|
906
|
+
/*
|
|
907
|
+
* Every id named by any descendant's chain, resolved once. `parentIds`
|
|
908
|
+
* holds the FULL chain, so this is a single pass rather than a walk — and
|
|
909
|
+
* the ids are deduped across the subtree, so a 200-node subtree sharing
|
|
910
|
+
* four ancestors costs four reads, not 800.
|
|
911
|
+
*/
|
|
912
|
+
const needed = new Set();
|
|
913
|
+
for (const d of descendants)
|
|
914
|
+
for (const id of d.parentIds ?? [])
|
|
915
|
+
needed.add(id);
|
|
916
|
+
const byId = new Map();
|
|
917
|
+
const ids = [...needed];
|
|
918
|
+
for (let i = 0; i < ids.length; i += 30) {
|
|
919
|
+
const refs = ids.slice(i, i + 30).map((id) => this.db.collection(this.collection).doc(id));
|
|
920
|
+
const snaps = await this.db.getAll(...refs);
|
|
921
|
+
for (const snap of snaps) {
|
|
922
|
+
if (snap.exists)
|
|
923
|
+
byId.set(snap.id, { id: snap.id, ...snap.data() });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const writes = [];
|
|
927
|
+
for (const d of descendants) {
|
|
928
|
+
/*
|
|
929
|
+
* `ancestors` is root-first, matching `parentIds`. Rebuilt from the live
|
|
930
|
+
* documents rather than patched in place, so a tier that also changed is
|
|
931
|
+
* picked up in the same pass.
|
|
932
|
+
*/
|
|
933
|
+
const ancestors = (d.parentIds ?? [])
|
|
934
|
+
.map((id) => byId.get(id))
|
|
935
|
+
.filter((c) => !!c)
|
|
936
|
+
.map((c) => ({ id: c.id, name: c.name, tier: c.tier }));
|
|
937
|
+
const same = (d.ancestors ?? []).length === ancestors.length &&
|
|
938
|
+
(d.ancestors ?? []).every((a, i) => a?.id === ancestors[i]?.id && a?.name === ancestors[i]?.name);
|
|
939
|
+
if (same)
|
|
940
|
+
continue;
|
|
941
|
+
/*
|
|
942
|
+
* `searchTxt` is rebuilt with the NEW ancestors, not the stored ones —
|
|
943
|
+
* `buildSearchTxtFor` reads the document it is handed, so passing the
|
|
944
|
+
* stale row would re-index the stale names and the rename would appear
|
|
945
|
+
* to have worked while changing nothing searchable.
|
|
946
|
+
*/
|
|
947
|
+
const searchTxt = buildCategorySearchTxt({ ...d, ancestors });
|
|
948
|
+
writes.push({
|
|
949
|
+
id: d.id,
|
|
950
|
+
data: {
|
|
951
|
+
ancestors,
|
|
952
|
+
...(searchTxt ? { searchTxt } : {}),
|
|
953
|
+
updatedAt: new Date(),
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
if (writes.length === 0)
|
|
958
|
+
return [];
|
|
959
|
+
await this.commitChunked(writes);
|
|
960
|
+
return writes.map((w) => w.id);
|
|
961
|
+
}
|
|
816
962
|
/**
|
|
817
963
|
* SB-UNI-B — delete a sublisting category and unlink all products
|
|
818
964
|
* that referenced it. Mirrors the cascade behavior of the old
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G3b — the incremental leaf→root counter.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 MAINTAINED FROM THE REPOSITORY, NOT A FIRESTORE TRIGGER, and this is the
|
|
5
|
+
* decisive constraint rather than a preference. `FieldValue.increment` is
|
|
6
|
+
* atomic but **not idempotent**, and a Firestore trigger is delivered
|
|
7
|
+
* at-least-once — so a redelivered trigger double-counts permanently and
|
|
8
|
+
* invisibly, which is the worst failure available here (a wrong number is just
|
|
9
|
+
* a number; nothing errors). From `ProductRepository` a retry is the caller's
|
|
10
|
+
* and the increments are one batch.
|
|
11
|
+
*
|
|
12
|
+
* It walks the STORED chain, which is what makes it duplicate-free and acyclic
|
|
13
|
+
* by construction rather than by care:
|
|
14
|
+
*
|
|
15
|
+
* - `product.categorySlugs` is already `[leaf, …ancestors, root]`, derived by
|
|
16
|
+
* `deriveTaxonomy` on create and update. Each ancestor appears exactly
|
|
17
|
+
* once, so incrementing every element once cannot double-count.
|
|
18
|
+
* - It comes from `buildCategoryTree`'s DFS over a forest, so it is acyclic
|
|
19
|
+
* by construction and bounded at 5 (tiers 0–4).
|
|
20
|
+
*
|
|
21
|
+
* 🛑 Do NOT walk parent-by-parent at write time. That is N reads per mutation
|
|
22
|
+
* and it reintroduces the cycle question the stored chain already answers.
|
|
23
|
+
*/
|
|
24
|
+
import type { ListingType } from "../../products/types";
|
|
25
|
+
/** The only product fields this module reads. */
|
|
26
|
+
export interface CountableProductRow {
|
|
27
|
+
/** I3 — the ASSIGNED categories. Absent on every product written before I1. */
|
|
28
|
+
categoryIds?: string[] | null;
|
|
29
|
+
categorySlugs?: string[] | null;
|
|
30
|
+
category?: string | null;
|
|
31
|
+
brandSlug?: string | null;
|
|
32
|
+
listingType?: ListingType | string | null;
|
|
33
|
+
isLot?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One product's contribution to the counters, or `null` when it contributes
|
|
37
|
+
* nothing.
|
|
38
|
+
*
|
|
39
|
+
* `countsNow` is the caller's availability verdict — this module deliberately
|
|
40
|
+
* does NOT compute it, so there is exactly one implementation of "is this row
|
|
41
|
+
* available" in the codebase (`isListingRowAvailable`) and no chance of a
|
|
42
|
+
* second, disagreeing copy. Root Cause #73 is what four partial copies of that
|
|
43
|
+
* predicate cost.
|
|
44
|
+
*/
|
|
45
|
+
export interface CountDelta {
|
|
46
|
+
/**
|
|
47
|
+
* The ASSIGNED categories — where `own` moves. One entry per assignment.
|
|
48
|
+
*
|
|
49
|
+
* 🛑 I3: this was a single `leaf`. A product can now be filed under several
|
|
50
|
+
* categories (at most one per root), and it is genuinely "here" in each of
|
|
51
|
+
* them — so `own` bumps at every one, not just the default.
|
|
52
|
+
*
|
|
53
|
+
* `owns[0]` is the DEFAULT assignment, because the closure is ordered
|
|
54
|
+
* default-chain-first and this is read from its head.
|
|
55
|
+
*/
|
|
56
|
+
owns: string[];
|
|
57
|
+
/**
|
|
58
|
+
* The whole chain — where `total` moves. INCLUDES the leaf.
|
|
59
|
+
*
|
|
60
|
+
* 🛑 `total` therefore includes `own` and the two must never be summed; see
|
|
61
|
+
* the invariant on `CategoryCountsDocument`. The leaf ends up with own=1 and
|
|
62
|
+
* total=1 for one listing, which is one count in each of two fields
|
|
63
|
+
* answering two different questions — not the same item counted twice.
|
|
64
|
+
*/
|
|
65
|
+
chain: string[];
|
|
66
|
+
/**
|
|
67
|
+
* The brand row, counted as a FLAT dimension: own AND total both move,
|
|
68
|
+
* because a brand has no category children of its own beyond what points at
|
|
69
|
+
* it — so `own === total` by construction and `descendantTotalFor` is 0.
|
|
70
|
+
*
|
|
71
|
+
* 🛑 This existed in `countersReconcile` and NOT here until the own/children
|
|
72
|
+
* review. Without it the incremental path never touched a brand, so every
|
|
73
|
+
* brand's counter stayed stale until the nightly pass and then "drifted" in
|
|
74
|
+
* the log every single night — an audit signal permanently stuck on,
|
|
75
|
+
* which is the same as no signal at all.
|
|
76
|
+
*/
|
|
77
|
+
brand: string | null;
|
|
78
|
+
listingType: ListingType;
|
|
79
|
+
isLot: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read a product's countable identity.
|
|
83
|
+
*
|
|
84
|
+
* Mirrors `countersReconcile.tallyProduct`'s own field selection exactly —
|
|
85
|
+
* `categorySlugs[0]` first, then the `@deprecated` scalar `category` — so the
|
|
86
|
+
* incremental and the nightly values can agree. Two different leaf selections
|
|
87
|
+
* would drift silently and the reconciler would "correct" a count that was
|
|
88
|
+
* right.
|
|
89
|
+
*/
|
|
90
|
+
export declare function describeCountable(row: CountableProductRow): CountDelta | null;
|
|
91
|
+
/**
|
|
92
|
+
* Whether a mutation can possibly have changed the counters.
|
|
93
|
+
*
|
|
94
|
+
* 🛑 THE BUG THIS EXISTS TO PREVENT, and the plan called it in advance: a guard
|
|
95
|
+
* that asks only "did the taxonomy change?" MISSES EVERY STATUS TRANSITION.
|
|
96
|
+
* `draft → published` changes no `categorySlugs` at all, so the count would
|
|
97
|
+
* never move — silently, on a page that looks correct. Same for
|
|
98
|
+
* `published → archived`, a sell-out, and a soft delete.
|
|
99
|
+
*
|
|
100
|
+
* So the comparison is over (chain, leaf, listingType, isLot, countsNow), and
|
|
101
|
+
* the availability verdict is a first-class input rather than an afterthought.
|
|
102
|
+
*/
|
|
103
|
+
export declare function countsChanged(before: {
|
|
104
|
+
delta: CountDelta | null;
|
|
105
|
+
countsNow: boolean;
|
|
106
|
+
}, after: {
|
|
107
|
+
delta: CountDelta | null;
|
|
108
|
+
countsNow: boolean;
|
|
109
|
+
}): boolean;
|
|
110
|
+
/** A single field path → increment, keyed so the batch can merge them. */
|
|
111
|
+
export type CountPatch = Map<string, Map<string, number>>;
|
|
112
|
+
/**
|
|
113
|
+
* Build the field-path increments for one product transition.
|
|
114
|
+
*
|
|
115
|
+
* `sign` is +1 for an addition and −1 for a removal; an update is expressed as
|
|
116
|
+
* a removal of the old shape followed by an addition of the new, which is why
|
|
117
|
+
* a move between leaves nets to −1 on the old chain and +1 on the new with no
|
|
118
|
+
* node double-counted.
|
|
119
|
+
*/
|
|
120
|
+
export declare function buildCountPatch(deltas: Array<{
|
|
121
|
+
delta: CountDelta | null;
|
|
122
|
+
sign: 1 | -1;
|
|
123
|
+
}>): CountPatch;
|
|
124
|
+
/**
|
|
125
|
+
* Apply a patch.
|
|
126
|
+
*
|
|
127
|
+
* `set(..., { merge: true })` rather than `update`, because the counts document
|
|
128
|
+
* may not exist yet: `update` on a missing document throws, and a counter that
|
|
129
|
+
* refuses to start is worse than one that starts at the first mutation. Merge
|
|
130
|
+
* also leaves the OTHER listing types' keys untouched, which is what keeps the
|
|
131
|
+
* map `Partial`.
|
|
132
|
+
*
|
|
133
|
+
* 🛑 Failure is swallowed deliberately, and this is the one place in this
|
|
134
|
+
* codebase where that is right: a count is a display number, and failing a
|
|
135
|
+
* seller's publish because an ancestor counter could not be bumped would trade
|
|
136
|
+
* a cosmetic staleness for a lost listing. The nightly reconciler is the
|
|
137
|
+
* backstop — its `drifted` count is how a missed increment becomes visible.
|
|
138
|
+
*/
|
|
139
|
+
export declare function applyCountPatch(db: {
|
|
140
|
+
collection(name: string): any;
|
|
141
|
+
}, patch: CountPatch, onError?: (err: {
|
|
142
|
+
message: string;
|
|
143
|
+
}) => void): Promise<void>;
|