@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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* categoryTaxonomyBackfill — re-derive a moved or renamed category's subtree.
|
|
3
|
+
*
|
|
4
|
+
* ── 🛑 THE GAP THIS CLOSES, measured 2026-10-13 ────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A category RENAME did nothing at all. `PUT /api/admin/categories/[id]` calls
|
|
7
|
+
* `categoriesRepository.update(id, fields)` and stops, so after renaming a node:
|
|
8
|
+
*
|
|
9
|
+
* - every product under it keeps the old `categoryNames`, and that array is
|
|
10
|
+
* what `buildProductSearchTxt` tokenises — so the listing stops being
|
|
11
|
+
* findable by its own category's NEW name and stays findable by the old
|
|
12
|
+
* one. Both halves are wrong and neither errors;
|
|
13
|
+
* - every DESCENDANT category keeps the old name inside its `ancestors[]`,
|
|
14
|
+
* and `buildCategorySearchTxt` indexes those deliberately ("typing burst
|
|
15
|
+
* reaches category-lost-longinus because Beyblade Burst is one of its
|
|
16
|
+
* ancestors") — so the subtree's own search breaks too.
|
|
17
|
+
*
|
|
18
|
+
* A REPARENT was already half-handled: `moveCategory` -> `reparentSubtree` ->
|
|
19
|
+
* `rewriteProductChains` does re-derive the chains. 🛑 But it runs UNBOUNDED and
|
|
20
|
+
* SYNCHRONOUSLY inside the request — one `.get()` over every product in the
|
|
21
|
+
* subtree plus batched writes, against a 10s route ceiling (Rule #6) — and it
|
|
22
|
+
* nets `metrics.*` only, never `categoryCounts`. So the LIVE count was stale
|
|
23
|
+
* until the nightly recount while the grid was already correct, which produces
|
|
24
|
+
* the worst symptom of the pair: a page reading "54 listings" above an empty
|
|
25
|
+
* grid. A disagreement between two surfaces is worse than either being wrong.
|
|
26
|
+
*
|
|
27
|
+
* ── Why a JOB and not the trigger body ────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Reparenting `category-beyblade-burst` touches hundreds of products. A trigger
|
|
30
|
+
* is at-least-once, and `FieldValue.increment` is atomic but NOT idempotent —
|
|
31
|
+
* which is exactly why counting lives in the repository rather than in a
|
|
32
|
+
* trigger (Root Cause #92's neighbourhood).
|
|
33
|
+
*
|
|
34
|
+
* 🛑 A RE-DERIVE, by contrast, IS idempotent: it is a pure function of the live
|
|
35
|
+
* tree, so running it twice writes the same values and the second run performs
|
|
36
|
+
* zero writes. That property is the whole licence for a trigger to start it.
|
|
37
|
+
*
|
|
38
|
+
* ── 🛑 THE ISR COST, stated rather than discovered ────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* Every product write fires `onProductWrite` -> `notifyRevalidate`, and this
|
|
41
|
+
* project was paused with ISR writes at 3.1M against a 200K cap (15.5x). A
|
|
42
|
+
* rename of one tier-2 node could therefore drop hundreds of ISR entries.
|
|
43
|
+
* `productCacheFieldsChanged` already exists as the gate for exactly this
|
|
44
|
+
* question, and `categoryNames` IS a cache field — a renamed category must
|
|
45
|
+
* reach the rendered card, so the revalidation is correct rather than wasteful.
|
|
46
|
+
* What would be wasteful is revalidating when only the ancestor IDS moved, which
|
|
47
|
+
* no card renders; that case writes `categorySlugs` and nothing else.
|
|
48
|
+
*
|
|
49
|
+
* EXPORTS: runCategoryTaxonomyBackfill, CategoryTaxonomyBackfillPayload
|
|
50
|
+
*
|
|
51
|
+
* @tag domain:categories
|
|
52
|
+
* @tag layer:job
|
|
53
|
+
* @tag pattern:none
|
|
54
|
+
* @tag access:server-only
|
|
55
|
+
* @tag consumers:JOB_RUNNERS via onJobCreated
|
|
56
|
+
* @tag sideEffects:rewrites products.categorySlugs/categoryNames/searchTxt and categories.ancestors/searchTxt
|
|
57
|
+
*/
|
|
58
|
+
import type { JobContext } from "../runtime/types";
|
|
59
|
+
import type { JobRunResult } from "./jobRunners";
|
|
60
|
+
export interface CategoryTaxonomyBackfillPayload {
|
|
61
|
+
/** The node that moved or was renamed. */
|
|
62
|
+
categoryId: string;
|
|
63
|
+
/**
|
|
64
|
+
* What changed. Both re-derive the product closure; only `"rename"` also
|
|
65
|
+
* rewrites the subtree's own `ancestors[]`, because only a rename changes a
|
|
66
|
+
* name a descendant has cached.
|
|
67
|
+
*/
|
|
68
|
+
kind: "rename" | "reparent";
|
|
69
|
+
}
|
|
70
|
+
export declare function runCategoryTaxonomyBackfill(payload: CategoryTaxonomyBackfillPayload, ctx: JobContext): Promise<JobRunResult>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* categoryTaxonomyBackfill — re-derive a moved or renamed category's subtree.
|
|
3
|
+
*
|
|
4
|
+
* ── 🛑 THE GAP THIS CLOSES, measured 2026-10-13 ────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A category RENAME did nothing at all. `PUT /api/admin/categories/[id]` calls
|
|
7
|
+
* `categoriesRepository.update(id, fields)` and stops, so after renaming a node:
|
|
8
|
+
*
|
|
9
|
+
* - every product under it keeps the old `categoryNames`, and that array is
|
|
10
|
+
* what `buildProductSearchTxt` tokenises — so the listing stops being
|
|
11
|
+
* findable by its own category's NEW name and stays findable by the old
|
|
12
|
+
* one. Both halves are wrong and neither errors;
|
|
13
|
+
* - every DESCENDANT category keeps the old name inside its `ancestors[]`,
|
|
14
|
+
* and `buildCategorySearchTxt` indexes those deliberately ("typing burst
|
|
15
|
+
* reaches category-lost-longinus because Beyblade Burst is one of its
|
|
16
|
+
* ancestors") — so the subtree's own search breaks too.
|
|
17
|
+
*
|
|
18
|
+
* A REPARENT was already half-handled: `moveCategory` -> `reparentSubtree` ->
|
|
19
|
+
* `rewriteProductChains` does re-derive the chains. 🛑 But it runs UNBOUNDED and
|
|
20
|
+
* SYNCHRONOUSLY inside the request — one `.get()` over every product in the
|
|
21
|
+
* subtree plus batched writes, against a 10s route ceiling (Rule #6) — and it
|
|
22
|
+
* nets `metrics.*` only, never `categoryCounts`. So the LIVE count was stale
|
|
23
|
+
* until the nightly recount while the grid was already correct, which produces
|
|
24
|
+
* the worst symptom of the pair: a page reading "54 listings" above an empty
|
|
25
|
+
* grid. A disagreement between two surfaces is worse than either being wrong.
|
|
26
|
+
*
|
|
27
|
+
* ── Why a JOB and not the trigger body ────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Reparenting `category-beyblade-burst` touches hundreds of products. A trigger
|
|
30
|
+
* is at-least-once, and `FieldValue.increment` is atomic but NOT idempotent —
|
|
31
|
+
* which is exactly why counting lives in the repository rather than in a
|
|
32
|
+
* trigger (Root Cause #92's neighbourhood).
|
|
33
|
+
*
|
|
34
|
+
* 🛑 A RE-DERIVE, by contrast, IS idempotent: it is a pure function of the live
|
|
35
|
+
* tree, so running it twice writes the same values and the second run performs
|
|
36
|
+
* zero writes. That property is the whole licence for a trigger to start it.
|
|
37
|
+
*
|
|
38
|
+
* ── 🛑 THE ISR COST, stated rather than discovered ────────────────────────
|
|
39
|
+
*
|
|
40
|
+
* Every product write fires `onProductWrite` -> `notifyRevalidate`, and this
|
|
41
|
+
* project was paused with ISR writes at 3.1M against a 200K cap (15.5x). A
|
|
42
|
+
* rename of one tier-2 node could therefore drop hundreds of ISR entries.
|
|
43
|
+
* `productCacheFieldsChanged` already exists as the gate for exactly this
|
|
44
|
+
* question, and `categoryNames` IS a cache field — a renamed category must
|
|
45
|
+
* reach the rendered card, so the revalidation is correct rather than wasteful.
|
|
46
|
+
* What would be wasteful is revalidating when only the ancestor IDS moved, which
|
|
47
|
+
* no card renders; that case writes `categorySlugs` and nothing else.
|
|
48
|
+
*
|
|
49
|
+
* EXPORTS: runCategoryTaxonomyBackfill, CategoryTaxonomyBackfillPayload
|
|
50
|
+
*
|
|
51
|
+
* @tag domain:categories
|
|
52
|
+
* @tag layer:job
|
|
53
|
+
* @tag pattern:none
|
|
54
|
+
* @tag access:server-only
|
|
55
|
+
* @tag consumers:JOB_RUNNERS via onJobCreated
|
|
56
|
+
* @tag sideEffects:rewrites products.categorySlugs/categoryNames/searchTxt and categories.ancestors/searchTxt
|
|
57
|
+
*/
|
|
58
|
+
import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
|
|
59
|
+
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
|
|
60
|
+
import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
|
|
61
|
+
import { buildProductSearchTxt } from "../../../../utils/search-txt-builders.js";
|
|
62
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
63
|
+
/** Firestore caps a batched write at 500 operations. */
|
|
64
|
+
const BATCH_LIMIT = 400;
|
|
65
|
+
/**
|
|
66
|
+
* One page of products per read.
|
|
67
|
+
*
|
|
68
|
+
* 🛑 A bound is the point of this job existing — `rewriteProductChains` has
|
|
69
|
+
* none, which is what makes it unsafe inside a request. Paged by document id so
|
|
70
|
+
* the cursor needs no index.
|
|
71
|
+
*/
|
|
72
|
+
const PAGE_SIZE = 200;
|
|
73
|
+
/**
|
|
74
|
+
* A hard ceiling on how much one run will touch.
|
|
75
|
+
*
|
|
76
|
+
* Saturating it is REPORTED and the run stops rather than silently truncating:
|
|
77
|
+
* a half-backfilled subtree is a set of products that disagree with their own
|
|
78
|
+
* tree, and `countersReconcile`'s `drifted` is the signal that says so. The
|
|
79
|
+
* alternative — carrying on unbounded — is what the 540s function budget
|
|
80
|
+
* cannot promise.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_PRODUCTS = 20000;
|
|
83
|
+
/**
|
|
84
|
+
* Commit one page of product rewrites, chunked at Firestore's batch limit.
|
|
85
|
+
*
|
|
86
|
+
* Extracted because inline it reached brace-depth 6 and `audit-code-quality`
|
|
87
|
+
* refuses that — correctly: the loop over a page, the chunking, and the
|
|
88
|
+
* per-chunk failure handling are three separate concerns that read better apart.
|
|
89
|
+
*
|
|
90
|
+
* 🛑 A failed chunk is recorded per ROW rather than aborting the run. The rest
|
|
91
|
+
* of the subtree is still worth fixing, and a partially-backfilled subtree is
|
|
92
|
+
* exactly what `countersReconcile` reports as drift — so the failure is visible
|
|
93
|
+
* either way, and stopping would make it larger.
|
|
94
|
+
*/
|
|
95
|
+
async function commitProductWrites(ctx, categoryId, writes, succeeded, failed) {
|
|
96
|
+
for (let i = 0; i < writes.length; i += BATCH_LIMIT) {
|
|
97
|
+
const chunk = writes.slice(i, i + BATCH_LIMIT);
|
|
98
|
+
const batch = ctx.db.batch();
|
|
99
|
+
for (const w of chunk) {
|
|
100
|
+
batch.update(ctx.db.collection(PRODUCT_COLLECTION).doc(w.id), {
|
|
101
|
+
...w.data,
|
|
102
|
+
updatedAt: new Date(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await batch.commit();
|
|
107
|
+
succeeded.push(...chunk.map((w) => w.id));
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const e = normalizeError(err);
|
|
111
|
+
for (const w of chunk)
|
|
112
|
+
failed.push({ id: w.id, reason: e.message });
|
|
113
|
+
ctx.logger.error("categoryTaxonomyBackfill: batch failed", err, { categoryId });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function runCategoryTaxonomyBackfill(payload, ctx) {
|
|
118
|
+
const { categoryId, kind } = payload;
|
|
119
|
+
const empty = {
|
|
120
|
+
summary: { total: 0, succeeded: 0, skipped: 0, failed: 0 },
|
|
121
|
+
succeeded: [],
|
|
122
|
+
skipped: [],
|
|
123
|
+
failed: [],
|
|
124
|
+
};
|
|
125
|
+
if (!categoryId) {
|
|
126
|
+
return {
|
|
127
|
+
...empty,
|
|
128
|
+
failed: [{ id: "-", reason: "categoryId is required" }],
|
|
129
|
+
summary: { total: 0, succeeded: 0, skipped: 0, failed: 1 },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const moved = await categoriesRepository.findById(categoryId);
|
|
133
|
+
if (!moved) {
|
|
134
|
+
/*
|
|
135
|
+
* Deleted between the write and this run. Not a fault: the delete route
|
|
136
|
+
* cascades products to the parent itself, so there is nothing here to fix.
|
|
137
|
+
*/
|
|
138
|
+
ctx.logger.warn("categoryTaxonomyBackfill: category not found", { categoryId });
|
|
139
|
+
return { ...empty, skipped: [categoryId], summary: { total: 0, succeeded: 0, skipped: 1, failed: 0 } };
|
|
140
|
+
}
|
|
141
|
+
/*
|
|
142
|
+
* The whole subtree, in one read. `getDescendants` is a single
|
|
143
|
+
* `array-contains` over `parentIds` — which holds the FULL ancestor chain —
|
|
144
|
+
* so depth costs nothing extra.
|
|
145
|
+
*/
|
|
146
|
+
const descendants = await categoriesRepository.getDescendants(categoryId);
|
|
147
|
+
const subtree = new Map([moved, ...descendants].map((c) => [c.id, c]));
|
|
148
|
+
const succeeded = [];
|
|
149
|
+
const skipped = [];
|
|
150
|
+
const failed = [];
|
|
151
|
+
/* ── 1. the subtree's own `ancestors[]`, on a rename only ──────────────── */
|
|
152
|
+
if (kind === "rename") {
|
|
153
|
+
try {
|
|
154
|
+
const refreshed = await categoriesRepository.refreshSubtreeAncestors(categoryId);
|
|
155
|
+
succeeded.push(...refreshed.map((id) => `category:${id}`));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const e = normalizeError(err);
|
|
159
|
+
failed.push({ id: `category:${categoryId}`, reason: e.message });
|
|
160
|
+
ctx.logger.error("categoryTaxonomyBackfill: ancestor refresh failed", err, { categoryId });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/* ── 2. every product whose closure contains the moved node ────────────── */
|
|
164
|
+
let scanned = 0;
|
|
165
|
+
let cursor = null;
|
|
166
|
+
let truncated = false;
|
|
167
|
+
for (;;) {
|
|
168
|
+
let q = ctx.db
|
|
169
|
+
.collection(PRODUCT_COLLECTION)
|
|
170
|
+
.where(PRODUCT_FIELDS.CATEGORY_SLUGS, "array-contains", categoryId)
|
|
171
|
+
.orderBy("__name__")
|
|
172
|
+
.limit(PAGE_SIZE);
|
|
173
|
+
if (cursor)
|
|
174
|
+
q = q.startAfter(cursor);
|
|
175
|
+
const page = await q.get();
|
|
176
|
+
if (page.empty)
|
|
177
|
+
break;
|
|
178
|
+
const writes = [];
|
|
179
|
+
for (const doc of page.docs) {
|
|
180
|
+
scanned += 1;
|
|
181
|
+
if (scanned > MAX_PRODUCTS) {
|
|
182
|
+
truncated = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
const data = doc.data();
|
|
186
|
+
/*
|
|
187
|
+
* Read the ASSIGNED list the same way every other consumer does —
|
|
188
|
+
* `categoryIds`, then the closure head, then the scalar. A different
|
|
189
|
+
* selection here would re-derive a chain from a leaf nothing else
|
|
190
|
+
* considers the leaf.
|
|
191
|
+
*/
|
|
192
|
+
const assignments = Array.isArray(data.categoryIds) && data.categoryIds.length > 0
|
|
193
|
+
? data.categoryIds
|
|
194
|
+
: [((Array.isArray(data.categorySlugs) && data.categorySlugs[0]) || data.category || "")].filter(Boolean);
|
|
195
|
+
/*
|
|
196
|
+
* 🛑 Resolve each assignment against the LIVE tree, falling back to the
|
|
197
|
+
* subtree map we already hold. An assignment OUTSIDE the moved subtree is
|
|
198
|
+
* unaffected by the move but still contributes its chain to the closure,
|
|
199
|
+
* so it has to be resolved too — dropping it would shorten the closure
|
|
200
|
+
* and un-list the product from a category it is genuinely in.
|
|
201
|
+
*/
|
|
202
|
+
const resolved = [];
|
|
203
|
+
let unresolved = false;
|
|
204
|
+
for (const id of assignments) {
|
|
205
|
+
const cat = subtree.get(id) ?? (await categoriesRepository.findById(id));
|
|
206
|
+
if (!cat) {
|
|
207
|
+
unresolved = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
resolved.push({
|
|
211
|
+
id,
|
|
212
|
+
parentIds: cat.parentIds ?? [],
|
|
213
|
+
name: cat.name,
|
|
214
|
+
ancestorNames: (cat.ancestors ?? []).map((a) => a?.name ?? ""),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (resolved.length === 0) {
|
|
218
|
+
// Never blank a chain we cannot improve on.
|
|
219
|
+
skipped.push(doc.id);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
/*
|
|
223
|
+
* The default is the DEEPEST assignment, and the tie-break is deliberately
|
|
224
|
+
* NOT consulted here: reading counts per product would be one extra read
|
|
225
|
+
* per row on a job that already pages thousands. A tie therefore falls to
|
|
226
|
+
* the stored order, which is the order `deriveTaxonomy` already chose —
|
|
227
|
+
* so a backfill never REORDERS a product's closure, it only repairs the
|
|
228
|
+
* ids and names inside it.
|
|
229
|
+
*/
|
|
230
|
+
const deepest = resolved.reduce((best, c) => {
|
|
231
|
+
const tier = (subtree.get(c.id)?.tier ?? -1);
|
|
232
|
+
const bestTier = (subtree.get(best.id)?.tier ?? -1);
|
|
233
|
+
return tier > bestTier ? c : best;
|
|
234
|
+
}, resolved[0]);
|
|
235
|
+
const ordered = [deepest, ...resolved.filter((c) => c.id !== deepest.id)];
|
|
236
|
+
const categorySlugs = [];
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const c of ordered) {
|
|
239
|
+
for (const id of [c.id, ...c.parentIds.slice().reverse()]) {
|
|
240
|
+
if (!id || seen.has(id))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(id);
|
|
243
|
+
categorySlugs.push(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const categoryNames = [deepest.name, ...deepest.ancestorNames.slice().reverse()].filter((n) => !!n);
|
|
247
|
+
const slugsSame = (data.categorySlugs ?? []).join(">") === categorySlugs.join(">");
|
|
248
|
+
const namesSame = (data.categoryNames ?? []).join(">") === categoryNames.join(">");
|
|
249
|
+
if (slugsSame && namesSame) {
|
|
250
|
+
// Idempotent: a synced row costs no write. This is what makes an
|
|
251
|
+
// at-least-once trigger safe to start the job.
|
|
252
|
+
skipped.push(doc.id);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
/*
|
|
256
|
+
* 🛑 `searchTxt` is rebuilt whenever the NAMES moved, and that is the half
|
|
257
|
+
* the reparent path never did. `buildProductSearchTxt` tokenises
|
|
258
|
+
* `categoryNames`, so a rename that stopped there would leave every
|
|
259
|
+
* product in the subtree findable by the old name and not the new one.
|
|
260
|
+
*/
|
|
261
|
+
const next = { categorySlugs, categoryNames };
|
|
262
|
+
if (!namesSame) {
|
|
263
|
+
next.searchTxt = buildProductSearchTxt({ ...data, categoryNames });
|
|
264
|
+
}
|
|
265
|
+
writes.push({ id: doc.id, data: next });
|
|
266
|
+
}
|
|
267
|
+
if (writes.length > 0) {
|
|
268
|
+
await commitProductWrites(ctx, categoryId, writes, succeeded, failed);
|
|
269
|
+
}
|
|
270
|
+
if (truncated)
|
|
271
|
+
break;
|
|
272
|
+
cursor = page.docs[page.docs.length - 1] ?? null;
|
|
273
|
+
if (page.size < PAGE_SIZE)
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
if (truncated) {
|
|
277
|
+
failed.push({
|
|
278
|
+
id: categoryId,
|
|
279
|
+
reason: `Stopped at the ${MAX_PRODUCTS}-product ceiling. The rest of the subtree still carries a stale chain — re-run, and expect countersReconcile to report drift.`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
ctx.logger.info("categoryTaxonomyBackfill: complete", {
|
|
283
|
+
categoryId,
|
|
284
|
+
kind,
|
|
285
|
+
subtreeSize: subtree.size,
|
|
286
|
+
productsScanned: scanned,
|
|
287
|
+
productsWritten: succeeded.filter((id) => !id.startsWith("category:")).length,
|
|
288
|
+
productsSkipped: skipped.length,
|
|
289
|
+
truncated,
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
summary: {
|
|
293
|
+
total: succeeded.length + skipped.length + failed.length,
|
|
294
|
+
succeeded: succeeded.length,
|
|
295
|
+
skipped: skipped.length,
|
|
296
|
+
failed: failed.length,
|
|
297
|
+
},
|
|
298
|
+
succeeded,
|
|
299
|
+
skipped,
|
|
300
|
+
failed,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -2,6 +2,7 @@ import { normalizeError } from "../../../../errors/normalize.js";
|
|
|
2
2
|
import { categoriesRepository, reviewRepository, storeRepository, } from "../../../../repositories/index.js";
|
|
3
3
|
import { ProductStatusValues, PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
|
|
4
4
|
import { CATEGORIES_COLLECTION } from "../../../../features/categories/schemas/firestore.js";
|
|
5
|
+
import { CATEGORY_COUNTS_COLLECTION } from "../../../../features/categories/schemas/category-counts.js";
|
|
5
6
|
import { ORDER_FIELDS, PRODUCT_FIELDS } from "../../../../constants/field-names.js";
|
|
6
7
|
import { isListingRowAvailable } from "../../../shared/listing-types/_registry.js";
|
|
7
8
|
import { QUERY_LIMIT } from "../handlers/messages.js";
|
|
@@ -14,22 +15,36 @@ const emptyTally = () => ({
|
|
|
14
15
|
auctionIds: [],
|
|
15
16
|
totalProducts: 0,
|
|
16
17
|
totalAuctions: 0,
|
|
18
|
+
own: {},
|
|
19
|
+
total: {},
|
|
20
|
+
lotsOwn: 0,
|
|
21
|
+
lotsTotal: 0,
|
|
17
22
|
});
|
|
18
23
|
/** Filed DIRECTLY under this row — `metrics.productCount` / `auctionCount`. */
|
|
19
|
-
function countOwn(t, productId, isAuction) {
|
|
24
|
+
function countOwn(t, productId, isAuction, listingType, isLot) {
|
|
20
25
|
if (isAuction)
|
|
21
26
|
t.auctionIds.push(productId);
|
|
22
27
|
else
|
|
23
28
|
t.productIds.push(productId);
|
|
29
|
+
// G3b: lots are counted SEPARATELY from singles, which is what lets a leaf's
|
|
30
|
+
// own count mean one thing wherever a lot is filed.
|
|
31
|
+
if (isLot)
|
|
32
|
+
t.lotsOwn++;
|
|
33
|
+
else
|
|
34
|
+
t.own[listingType] = (t.own[listingType] ?? 0) + 1;
|
|
24
35
|
}
|
|
25
36
|
/** This row or any ancestor — `metrics.totalProductCount` / `totalAuctionCount`. */
|
|
26
|
-
function countRollup(t, isAuction) {
|
|
37
|
+
function countRollup(t, isAuction, listingType, isLot) {
|
|
27
38
|
if (!t)
|
|
28
39
|
return;
|
|
29
40
|
if (isAuction)
|
|
30
41
|
t.totalAuctions++;
|
|
31
42
|
else
|
|
32
43
|
t.totalProducts++;
|
|
44
|
+
if (isLot)
|
|
45
|
+
t.lotsTotal++;
|
|
46
|
+
else
|
|
47
|
+
t.total[listingType] = (t.total[listingType] ?? 0) + 1;
|
|
33
48
|
}
|
|
34
49
|
/**
|
|
35
50
|
* Fold one published product into the tallies.
|
|
@@ -41,6 +56,11 @@ function countRollup(t, isAuction) {
|
|
|
41
56
|
*/
|
|
42
57
|
function tallyProduct(productId, data, tallies, parentsOf) {
|
|
43
58
|
const isAuction = data.listingType === PRODUCT_FIELDS.LISTING_TYPE_VALUES.AUCTION;
|
|
59
|
+
// G3b. Default to "standard" for a row written before `listingType` was
|
|
60
|
+
// required — the same default `describeCountable` applies, so the nightly
|
|
61
|
+
// and the incremental value land on the same key rather than on two.
|
|
62
|
+
const listingType = data.listingType || "standard";
|
|
63
|
+
const isLot = data.isLot === true;
|
|
44
64
|
/*
|
|
45
65
|
* Count only what a visitor can actually see. A published-but-sold, ended,
|
|
46
66
|
* closed or code-depleted row is excluded here for the same reason the listing
|
|
@@ -48,29 +68,59 @@ function tallyProduct(productId, data, tallies, parentsOf) {
|
|
|
48
68
|
*/
|
|
49
69
|
if (!isListingRowAvailable(data, new Date()))
|
|
50
70
|
return false;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
/*
|
|
72
|
+
* I3 — the ASSIGNED categories. `categoryIds` first, falling back to the old
|
|
73
|
+
* single-leaf selection for every row written before I1 added the field.
|
|
74
|
+
*
|
|
75
|
+
* 🛑 This selection must stay byte-identical to `describeCountable`'s. Two
|
|
76
|
+
* different ones drift silently, and the drift is invisible in the worst
|
|
77
|
+
* possible way: this job RECOUNTS FROM SCRATCH and overwrites, so it would
|
|
78
|
+
* "correct" a count the live trigger had right.
|
|
79
|
+
*/
|
|
80
|
+
const owns = Array.isArray(data.categoryIds) && data.categoryIds.length > 0
|
|
81
|
+
? data.categoryIds.filter(Boolean)
|
|
82
|
+
: [((Array.isArray(data.categorySlugs) && data.categorySlugs[0]) || data.category || "")].filter(Boolean);
|
|
56
83
|
let unknown = false;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
84
|
+
/*
|
|
85
|
+
* ✅ Ancestors come from `parentsOf` — the LIVE tree, seeded from every
|
|
86
|
+
* category document — and NOT from the product's stored closure. Do not
|
|
87
|
+
* "simplify" that: it is what makes a reparent self-heal overnight. A version
|
|
88
|
+
* reading the stored chain would agree with a stale one, and then the drift
|
|
89
|
+
* this job exists to catch would be undetectable by it.
|
|
90
|
+
*
|
|
91
|
+
* 🛑 `own` bumps at EVERY assignment; `total` bumps once per distinct id
|
|
92
|
+
* across all their chains. Without the dedupe, two assignments sharing an
|
|
93
|
+
* ancestor would roll that ancestor up twice for one listing — which under
|
|
94
|
+
* one-per-root cannot happen, but this job reads whatever is in Firestore
|
|
95
|
+
* rather than whatever the write path allowed.
|
|
96
|
+
*/
|
|
97
|
+
const rolledUp = new Set();
|
|
98
|
+
for (const own of owns) {
|
|
99
|
+
if (!tallies.has(own)) {
|
|
100
|
+
unknown = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
countOwn(tallies.get(own), productId, isAuction, listingType, isLot);
|
|
104
|
+
for (const id of [own, ...(parentsOf.get(own) ?? [])]) {
|
|
105
|
+
if (rolledUp.has(id))
|
|
106
|
+
continue;
|
|
107
|
+
rolledUp.add(id);
|
|
108
|
+
countRollup(tallies.get(id), isAuction, listingType, isLot);
|
|
66
109
|
}
|
|
67
110
|
}
|
|
68
111
|
// Brands are a flat dimension: a brand row has no product children of its own
|
|
69
112
|
// beyond what points at it, so own === total by construction — which is why
|
|
70
113
|
// it is counted on both axes at once.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
// 🛑 ...and never on the same row the chain already counted. If a leaf id were
|
|
115
|
+
// also a brand id, `countOwn` would fire twice for one listing. Today's
|
|
116
|
+
// `category-` / `brand-` prefixes make it impossible, which is precisely why
|
|
117
|
+
// it is guarded rather than trusted — the prefixes are a convention and this
|
|
118
|
+
// is an arithmetic invariant. Mirrors `describeCountable`'s own guard so the
|
|
119
|
+
// nightly and incremental values cannot disagree here.
|
|
120
|
+
const countedByChain = rolledUp;
|
|
121
|
+
if (data.brandSlug && !countedByChain.has(data.brandSlug) && tallies.has(data.brandSlug)) {
|
|
122
|
+
countOwn(tallies.get(data.brandSlug), productId, isAuction, listingType, isLot);
|
|
123
|
+
countRollup(tallies.get(data.brandSlug), isAuction, listingType, isLot);
|
|
74
124
|
}
|
|
75
125
|
return unknown;
|
|
76
126
|
}
|
|
@@ -156,11 +206,66 @@ async function reconcileCategories(ctx) {
|
|
|
156
206
|
if (page.size < PAGE_SIZE)
|
|
157
207
|
break;
|
|
158
208
|
}
|
|
209
|
+
/*
|
|
210
|
+
* G3b — the live `categoryCounts` documents, read so `drifted` stays an
|
|
211
|
+
* honest signal across BOTH stores of the count.
|
|
212
|
+
*
|
|
213
|
+
* 🛑 Read rather than blind-written. Writing only when `metrics` drifts would
|
|
214
|
+
* leave a counts document wrong forever in the narrow case where the
|
|
215
|
+
* product/auction aggregate happens to be right while a per-type split or a
|
|
216
|
+
* lot count is not — and that case is precisely what an incremental counter
|
|
217
|
+
* gets wrong, so it is the one the audit must see.
|
|
218
|
+
*
|
|
219
|
+
* One extra read per category row, once a night: 416 reads against a
|
|
220
|
+
* 50,000/day budget, under 1%.
|
|
221
|
+
*/
|
|
222
|
+
const countsSnap = await ctx.db.collection(CATEGORY_COUNTS_COLLECTION).get();
|
|
223
|
+
const liveCounts = new Map();
|
|
224
|
+
for (const d of countsSnap.docs)
|
|
225
|
+
liveCounts.set(d.id, d.data());
|
|
226
|
+
/** Field-by-field, because a key present as 0 and a key absent are the same count. */
|
|
227
|
+
const sameMap = (a, b) => {
|
|
228
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b ?? {})]);
|
|
229
|
+
for (const k of keys)
|
|
230
|
+
if ((a[k] ?? 0) !== ((b ?? {})[k] ?? 0))
|
|
231
|
+
return false;
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
159
234
|
let written = 0;
|
|
160
235
|
let drifted = 0;
|
|
236
|
+
let countsWritten = 0;
|
|
161
237
|
for (const doc of catSnap.docs) {
|
|
162
238
|
const t = tallies.get(doc.id) ?? emptyTally();
|
|
163
239
|
const m = doc.data().metrics ?? {};
|
|
240
|
+
const c = liveCounts.get(doc.id);
|
|
241
|
+
const countsSame = !!c &&
|
|
242
|
+
sameMap(t.own, c.own) &&
|
|
243
|
+
sameMap(t.total, c.total) &&
|
|
244
|
+
(c.lots?.own ?? 0) === t.lotsOwn &&
|
|
245
|
+
(c.lots?.total ?? 0) === t.lotsTotal;
|
|
246
|
+
if (!countsSame) {
|
|
247
|
+
try {
|
|
248
|
+
/*
|
|
249
|
+
* 🛑 A FULL OVERWRITE, not a merge. The reconciler is the authority and
|
|
250
|
+
* it enumerates the CATEGORIES rather than the products — which is what
|
|
251
|
+
* lets a row whose last listing was deleted reset to zero. Root Cause
|
|
252
|
+
* #102 is what a reconciler that could only ever add cost: 19 of 65
|
|
253
|
+
* rows wrong, every one low.
|
|
254
|
+
*/
|
|
255
|
+
await ctx.db.collection(CATEGORY_COUNTS_COLLECTION).doc(doc.id).set({
|
|
256
|
+
id: doc.id,
|
|
257
|
+
own: t.own,
|
|
258
|
+
total: t.total,
|
|
259
|
+
lots: { own: t.lotsOwn, total: t.lotsTotal },
|
|
260
|
+
updatedAt: new Date(),
|
|
261
|
+
});
|
|
262
|
+
countsWritten++;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
void normalizeError(err);
|
|
266
|
+
ctx.logger.error(`[categories] categoryCounts write failed for ${doc.id}`, err);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
164
269
|
const same = (m.productCount ?? 0) === t.productIds.length &&
|
|
165
270
|
(m.auctionCount ?? 0) === t.auctionIds.length &&
|
|
166
271
|
(m.totalProductCount ?? 0) === t.totalProducts &&
|
|
@@ -193,6 +298,10 @@ async function reconcileCategories(ctx) {
|
|
|
193
298
|
publishedProductsScanned: scanned,
|
|
194
299
|
drifted,
|
|
195
300
|
written,
|
|
301
|
+
// G3b — a non-zero value here on a quiet day means the repository's
|
|
302
|
+
// incremental counter missed something. That is the whole reason the
|
|
303
|
+
// nightly pass survives an incremental model.
|
|
304
|
+
countsWritten,
|
|
196
305
|
unknownCategory,
|
|
197
306
|
});
|
|
198
307
|
}
|
|
@@ -15,6 +15,7 @@ import { runWhatsAppNotify } from "./whatsappNotify.js";
|
|
|
15
15
|
import { runNewsletterExport } from "./newsletterExport.js";
|
|
16
16
|
import { runWhatsAppCatalogImport } from "./whatsappCatalogImport.js";
|
|
17
17
|
import { runStoreNameBackfill } from "./storeNameBackfill.js";
|
|
18
|
+
import { runCategoryTaxonomyBackfill } from "./categoryTaxonomyBackfill.js";
|
|
18
19
|
async function runPayoutsWeeklyJob(_payload, ctx) {
|
|
19
20
|
const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
|
|
20
21
|
return {
|
|
@@ -53,6 +54,16 @@ async function runWhatsAppCatalogImportJob(payload, ctx) {
|
|
|
53
54
|
async function runStoreNameBackfillJob(payload, ctx) {
|
|
54
55
|
return runStoreNameBackfill({ storeId: String(payload.storeId ?? "") }, ctx);
|
|
55
56
|
}
|
|
57
|
+
async function runCategoryTaxonomyBackfillJob(payload, ctx) {
|
|
58
|
+
/*
|
|
59
|
+
* `kind` is narrowed here rather than cast. An unrecognised value falls to
|
|
60
|
+
* "reparent", which is the SAFE default: it re-derives the product closure
|
|
61
|
+
* and skips the subtree ancestor refresh. The reverse default would run a
|
|
62
|
+
* subtree-wide write for a move that changed no name.
|
|
63
|
+
*/
|
|
64
|
+
const kind = payload.kind === "rename" ? "rename" : "reparent";
|
|
65
|
+
return runCategoryTaxonomyBackfill({ categoryId: String(payload.categoryId ?? ""), kind }, ctx);
|
|
66
|
+
}
|
|
56
67
|
export const JOB_RUNNERS = {
|
|
57
68
|
payoutsWeekly: runPayoutsWeeklyJob,
|
|
58
69
|
hardBanCascade: runHardBanCascadeJob,
|
|
@@ -61,4 +72,5 @@ export const JOB_RUNNERS = {
|
|
|
61
72
|
newsletterExport: runNewsletterExportJob,
|
|
62
73
|
whatsappCatalogImport: runWhatsAppCatalogImportJob,
|
|
63
74
|
storeNameBackfill: runStoreNameBackfillJob,
|
|
75
|
+
categoryTaxonomyBackfill: runCategoryTaxonomyBackfillJob,
|
|
64
76
|
};
|