@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
|
@@ -118,6 +118,36 @@ export interface GenerateOfferIdInput {
|
|
|
118
118
|
}
|
|
119
119
|
export declare function generateOfferId(input: GenerateOfferIdInput): string;
|
|
120
120
|
export declare function idExists(getExistingId: () => Promise<unknown>): Promise<boolean>;
|
|
121
|
+
/**
|
|
122
|
+
* Find the first id a `checkExists` probe says is free, trying
|
|
123
|
+
* `generateId(0)`, `generateId(1)`, … up to `maxAttempts`.
|
|
124
|
+
*
|
|
125
|
+
* 🛑 **The loop starts at 0, and it used to start at 1.** That one-character
|
|
126
|
+
* difference made the first candidate unreachable, because its only caller
|
|
127
|
+
* passes:
|
|
128
|
+
*
|
|
129
|
+
* (count) => (count === 0 ? baseSlug : `${baseSlug}-${count}`)
|
|
130
|
+
*
|
|
131
|
+
* so `count === 0` — the BARE slug, which is obviously what the author
|
|
132
|
+
* intended as the first choice — was dead code. Every product created through
|
|
133
|
+
* the UI got a gratuitous suffix: the first-ever Dragoon G listing became
|
|
134
|
+
* `product-dragoon-g-1`, not `product-dragoon-g`.
|
|
135
|
+
*
|
|
136
|
+
* Verified by simulating the real helper against the real closure: three
|
|
137
|
+
* creations yielded `-1`, `-2`, `-3` and never the bare slug.
|
|
138
|
+
*
|
|
139
|
+
* It stayed invisible because all 70 production products come from the seed,
|
|
140
|
+
* which hand-writes clean ids and never calls this — so the catalogue looks
|
|
141
|
+
* right and only a UI-created listing shows the defect. Same shape as Root
|
|
142
|
+
* Cause #101: a path exercised by no fixture.
|
|
143
|
+
*
|
|
144
|
+
* 🛑 A product id is a URL and a `categorySlugs` reference, and the slug is
|
|
145
|
+
* immutable after creation by convention, so this is not cosmetic — a listing
|
|
146
|
+
* created today keeps the wrong URL forever.
|
|
147
|
+
*
|
|
148
|
+
* `maxAttempts` still bounds the ATTEMPTS, so the loop runs `0..maxAttempts`
|
|
149
|
+
* and the random-suffix fallback is unchanged.
|
|
150
|
+
*/
|
|
121
151
|
export declare function generateUniqueId(generateId: (count: number) => string, checkExists: (id: string) => Promise<boolean>, maxAttempts?: number): Promise<string>;
|
|
122
152
|
export declare function generateBarcodeFromId(id: string): string;
|
|
123
153
|
export declare function generateQRCodeData(id: string, baseUrl: string): string;
|
|
@@ -171,8 +171,38 @@ export async function idExists(getExistingId) {
|
|
|
171
171
|
return false;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Find the first id a `checkExists` probe says is free, trying
|
|
176
|
+
* `generateId(0)`, `generateId(1)`, … up to `maxAttempts`.
|
|
177
|
+
*
|
|
178
|
+
* 🛑 **The loop starts at 0, and it used to start at 1.** That one-character
|
|
179
|
+
* difference made the first candidate unreachable, because its only caller
|
|
180
|
+
* passes:
|
|
181
|
+
*
|
|
182
|
+
* (count) => (count === 0 ? baseSlug : `${baseSlug}-${count}`)
|
|
183
|
+
*
|
|
184
|
+
* so `count === 0` — the BARE slug, which is obviously what the author
|
|
185
|
+
* intended as the first choice — was dead code. Every product created through
|
|
186
|
+
* the UI got a gratuitous suffix: the first-ever Dragoon G listing became
|
|
187
|
+
* `product-dragoon-g-1`, not `product-dragoon-g`.
|
|
188
|
+
*
|
|
189
|
+
* Verified by simulating the real helper against the real closure: three
|
|
190
|
+
* creations yielded `-1`, `-2`, `-3` and never the bare slug.
|
|
191
|
+
*
|
|
192
|
+
* It stayed invisible because all 70 production products come from the seed,
|
|
193
|
+
* which hand-writes clean ids and never calls this — so the catalogue looks
|
|
194
|
+
* right and only a UI-created listing shows the defect. Same shape as Root
|
|
195
|
+
* Cause #101: a path exercised by no fixture.
|
|
196
|
+
*
|
|
197
|
+
* 🛑 A product id is a URL and a `categorySlugs` reference, and the slug is
|
|
198
|
+
* immutable after creation by convention, so this is not cosmetic — a listing
|
|
199
|
+
* created today keeps the wrong URL forever.
|
|
200
|
+
*
|
|
201
|
+
* `maxAttempts` still bounds the ATTEMPTS, so the loop runs `0..maxAttempts`
|
|
202
|
+
* and the random-suffix fallback is unchanged.
|
|
203
|
+
*/
|
|
174
204
|
export async function generateUniqueId(generateId, checkExists, maxAttempts = 100) {
|
|
175
|
-
for (let count =
|
|
205
|
+
for (let count = 0; count <= maxAttempts; count++) {
|
|
176
206
|
const id = generateId(count);
|
|
177
207
|
if (!(await checkExists(id)))
|
|
178
208
|
return id;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { buildSearchTxt } from "./search-txt.js";
|
|
31
31
|
import { aliasVariantsForName } from "../_internal/shared/search/alias-variants.js";
|
|
32
|
+
import { partAliasTokens } from "../_internal/shared/search/part-aliases.js";
|
|
32
33
|
import { physicalLocationTokens } from "./physical-location.js";
|
|
33
34
|
const stripHtml = (s) => (s ?? "").replace(/<[^>]+>/g, " ");
|
|
34
35
|
/** products — title, description, brand, taxonomy, condition, card/grading, specs. */
|
|
@@ -46,6 +47,27 @@ export function buildProductSearchTxt(p) {
|
|
|
46
47
|
p.card?.cardNumber,
|
|
47
48
|
p.grading?.service,
|
|
48
49
|
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
50
|
+
/*
|
|
51
|
+
* D2 Fix 3 — the PART CODE ⇄ NAME bridge, so "rubber flat" finds a listing
|
|
52
|
+
* titled "Storm Pegasus 105RF" and `RF` finds one titled "… Rubber Flat".
|
|
53
|
+
*
|
|
54
|
+
* Firestore cannot fuzzy-match, but nothing stops us indexing MORE tokens
|
|
55
|
+
* per document — which is the whole mechanism, and it costs nothing at
|
|
56
|
+
* query time because `searchTxt` is already `array-contains`-indexed.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 Fed the TITLE and the specs only, not `description`. A 300-word body
|
|
59
|
+
* mentioning a dozen parts would add a dozen aliases and push real tokens
|
|
60
|
+
* off the 600 cap — the same reasoning the misspelling generator records
|
|
61
|
+
* for itself, and the cap TRUNCATES SILENTLY.
|
|
62
|
+
*
|
|
63
|
+
* Emitted BEFORE the misspelling variants: a glossary alias is evidence
|
|
64
|
+
* from the corpus, a generated misspelling is a guess, so the guess is what
|
|
65
|
+
* should be dropped first if anything is.
|
|
66
|
+
*/
|
|
67
|
+
partAliasTokens([
|
|
68
|
+
p.title ?? "",
|
|
69
|
+
...(p.specifications ?? []).map((sp) => `${sp.name} ${sp.value}`),
|
|
70
|
+
].join(" ")),
|
|
49
71
|
/*
|
|
50
72
|
* The warehouse location — C4, and this one line is what makes the hard
|
|
51
73
|
* direction work.
|
|
@@ -28,6 +28,28 @@
|
|
|
28
28
|
* a `searchTxt` for a person's name IS that name, re-encoded. Encrypted fields
|
|
29
29
|
* stay searchable only through their HMAC blind index (exact match).
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Ceiling on tokens per document. Firestore allows 40k index entries per doc and
|
|
33
|
+
* each array element costs one per index containing the field, so a long body
|
|
34
|
+
* left unbounded is a real limit to hit.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* The field every MIGRATED collection stores its tokens in.
|
|
38
|
+
*
|
|
39
|
+
* 🛑 ONE shared constant because `searchTxt` is a CROSS-COLLECTION convention,
|
|
40
|
+
* not a per-feature field: 12 collections carry it (products, categories,
|
|
41
|
+
* stores, blogPosts, events, faqs, reviews, coupons, offers, orders,
|
|
42
|
+
* scammerProfiles, supportTickets) and the name is identical in all of them by
|
|
43
|
+
* design.
|
|
44
|
+
*
|
|
45
|
+
* `field-names.ts` declares `SEARCH_TXT: "searchTxt"` FOUR separate times —
|
|
46
|
+
* once each in the product, order, FAQ and category blocks — which is the very
|
|
47
|
+
* duplication `audit-registry-constants` exists to stop, one level up. A module
|
|
48
|
+
* that queries SIX collections cannot honestly borrow any single feature's
|
|
49
|
+
* copy: `PRODUCT_FIELDS.SEARCH_TXT` naming the field on a `faqs` query is a
|
|
50
|
+
* true string and a false statement.
|
|
51
|
+
*/
|
|
52
|
+
export declare const SEARCH_TXT_FIELD = "searchTxt";
|
|
31
53
|
export interface SearchTxtOptions {
|
|
32
54
|
/**
|
|
33
55
|
* Emit prefixes for these sources. Use for short, high-signal text (title,
|
package/dist/utils/search-txt.js
CHANGED
|
@@ -37,6 +37,23 @@ const MIN_PREFIX_LENGTH = 1;
|
|
|
37
37
|
* each array element costs one per index containing the field, so a long body
|
|
38
38
|
* left unbounded is a real limit to hit.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The field every MIGRATED collection stores its tokens in.
|
|
42
|
+
*
|
|
43
|
+
* 🛑 ONE shared constant because `searchTxt` is a CROSS-COLLECTION convention,
|
|
44
|
+
* not a per-feature field: 12 collections carry it (products, categories,
|
|
45
|
+
* stores, blogPosts, events, faqs, reviews, coupons, offers, orders,
|
|
46
|
+
* scammerProfiles, supportTickets) and the name is identical in all of them by
|
|
47
|
+
* design.
|
|
48
|
+
*
|
|
49
|
+
* `field-names.ts` declares `SEARCH_TXT: "searchTxt"` FOUR separate times —
|
|
50
|
+
* once each in the product, order, FAQ and category blocks — which is the very
|
|
51
|
+
* duplication `audit-registry-constants` exists to stop, one level up. A module
|
|
52
|
+
* that queries SIX collections cannot honestly borrow any single feature's
|
|
53
|
+
* copy: `PRODUCT_FIELDS.SEARCH_TXT` naming the field on a `faqs` query is a
|
|
54
|
+
* true string and a false statement.
|
|
55
|
+
*/
|
|
56
|
+
export const SEARCH_TXT_FIELD = "searchTxt";
|
|
40
57
|
const MAX_TOKENS = 600;
|
|
41
58
|
/** Words longer than this contribute whole-word only — no prefix expansion. */
|
|
42
59
|
const LONG_WORD_LIMIT = 24;
|
|
@@ -216,10 +216,6 @@ export declare const SUCCESS_MESSAGES: {
|
|
|
216
216
|
readonly ROOM_CREATED: "Chat opened";
|
|
217
217
|
readonly ROOM_DELETED: "Chat deleted";
|
|
218
218
|
};
|
|
219
|
-
readonly COPILOT: {
|
|
220
|
-
readonly RESPONSE_OK: "Response generated";
|
|
221
|
-
readonly FEEDBACK_SAVED: "Feedback saved";
|
|
222
|
-
};
|
|
223
219
|
};
|
|
224
220
|
export declare const INFO_MESSAGES: {
|
|
225
221
|
readonly EMAIL: {
|
|
@@ -245,11 +245,6 @@ export const SUCCESS_MESSAGES = {
|
|
|
245
245
|
ROOM_CREATED: "Chat opened",
|
|
246
246
|
ROOM_DELETED: "Chat deleted",
|
|
247
247
|
},
|
|
248
|
-
// AI Copilot
|
|
249
|
-
COPILOT: {
|
|
250
|
-
RESPONSE_OK: "Response generated",
|
|
251
|
-
FEEDBACK_SAVED: "Feedback saved",
|
|
252
|
-
},
|
|
253
248
|
};
|
|
254
249
|
export const INFO_MESSAGES = {
|
|
255
250
|
EMAIL: {
|
package/package.json
CHANGED
package/scripts/seed-cli.mjs
CHANGED
|
@@ -56,12 +56,19 @@ const BIN_ACTION_MAP = {
|
|
|
56
56
|
"appkit-seed-status": "status",
|
|
57
57
|
};
|
|
58
58
|
const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
|
|
59
|
-
if (!["load", "delete", "status"].includes(ACTION)) {
|
|
59
|
+
if (!["load", "delete", "status", "purge"].includes(ACTION)) {
|
|
60
60
|
console.error("usage:");
|
|
61
|
-
console.error(" appkit-seed <load|delete|status> [flags]");
|
|
61
|
+
console.error(" appkit-seed <load|delete|status|purge> [flags]");
|
|
62
62
|
console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
|
|
63
63
|
console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
|
|
64
64
|
console.error("");
|
|
65
|
+
console.error(" purge delete every document whose seedPurpose is \"test\", and NOTHING else.");
|
|
66
|
+
console.error(" Unlike `delete`, which empties whole collections, this keeps real");
|
|
67
|
+
console.error(" content (faqs, categories, taxCodes, productFeatures, blogPosts,");
|
|
68
|
+
console.error(" carousels, homepageSections, siteSettings, the tester checklist) and");
|
|
69
|
+
console.error(" keeps the two keep-list rows: the admin account and store-letitrip-official.");
|
|
70
|
+
console.error(" Organic rows carry no seedPurpose at all and are unreachable by it.");
|
|
71
|
+
console.error("");
|
|
65
72
|
console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
|
|
66
73
|
process.exit(2);
|
|
67
74
|
}
|
|
@@ -196,6 +203,7 @@ const {
|
|
|
196
203
|
wishlistsSeedData, historySeedData,
|
|
197
204
|
groupedListingsSeedData,
|
|
198
205
|
scammersSeedData, productFeaturesSeedData, taxCodesSeedData,
|
|
206
|
+
partGlossarySeedData, modelCompositionSeedData,
|
|
199
207
|
testerChecklistSeedData, testerResponsesSeedData,
|
|
200
208
|
supportTicketsSeedData, catalogueSeedData,
|
|
201
209
|
shipmentsSeedData, shipmentLotsSeedData, shipmentItemsSeedData,
|
|
@@ -211,6 +219,8 @@ const {
|
|
|
211
219
|
GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
|
|
212
220
|
WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
|
|
213
221
|
TAX_CODES_COLLECTION,
|
|
222
|
+
PART_GLOSSARY_COLLECTION,
|
|
223
|
+
MODEL_COMPOSITION_COLLECTION,
|
|
214
224
|
TESTER_CHECKLIST_ITEM_COLLECTION,
|
|
215
225
|
TESTER_CHECKLIST_RESPONSE_COLLECTION,
|
|
216
226
|
// PII field/index constants (plain data, still in the main barrel — the
|
|
@@ -297,6 +307,9 @@ const COLLECTION_MAP = {
|
|
|
297
307
|
scammerProfiles: SCAMMER_COLLECTION,
|
|
298
308
|
productFeatures: PRODUCT_FEATURES_COLLECTION,
|
|
299
309
|
taxCodes: TAX_CODES_COLLECTION,
|
|
310
|
+
// E4/D12 — extracted from the local corpus, generated into the seed.
|
|
311
|
+
partGlossary: PART_GLOSSARY_COLLECTION,
|
|
312
|
+
modelComposition: MODEL_COMPOSITION_COLLECTION,
|
|
300
313
|
testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
|
|
301
314
|
/*
|
|
302
315
|
* Tester submissions. DERIVED tier in the tester's own map (wiped at setup so
|
|
@@ -311,6 +324,109 @@ const COLLECTION_MAP = {
|
|
|
311
324
|
testerChecklistResponses: TESTER_CHECKLIST_RESPONSE_COLLECTION,
|
|
312
325
|
};
|
|
313
326
|
|
|
327
|
+
/*
|
|
328
|
+
* B6 — stamp `gstRate` + `hsnCode` onto every seeded product.
|
|
329
|
+
*
|
|
330
|
+
* 🛑 WITHOUT THIS, EVERY SEEDED PRODUCT IS TAXED AT ZERO, and the Tax row can
|
|
331
|
+
* never render no matter what the GSTIN says.
|
|
332
|
+
*
|
|
333
|
+
* The resolution order C0c settled lives in `ProductRepository.deriveTaxonomy`
|
|
334
|
+
* — product rate, else the CATEGORY's via `taxCodeId`, else a warned floor —
|
|
335
|
+
* and it runs on a repository write. **The seed writes through
|
|
336
|
+
* `set(..., { merge: true })` and never touches the repository**, so no seeded
|
|
337
|
+
* row has ever carried a rate: measured 2026-10-12, `gstRate` was absent on
|
|
338
|
+
* all 70 live products. At checkout `sumGroupGst` reads `product.gstRate`
|
|
339
|
+
* directly with a `?? 0`, so the whole catalogue would have computed GST = 0
|
|
340
|
+
* with a valid GSTIN and `gstSettings.enabled: true`.
|
|
341
|
+
*
|
|
342
|
+
* That is the same class as Root Cause #101 (a schema that strips a field plus
|
|
343
|
+
* a read-side backfill equals a field no query can see) — here it is a
|
|
344
|
+
* write-side derivation the seed path does not run.
|
|
345
|
+
*
|
|
346
|
+
* 🛑 This is NOT a second implementation of the resolution. The product seed
|
|
347
|
+
* already hand-writes the FULL `categorySlugs` chain, so there is no walk to
|
|
348
|
+
* re-derive: take the chain, find the first element whose category declares a
|
|
349
|
+
* `taxCodeId`, and read the rate off the `taxCodes` seed. Nearest-first, which
|
|
350
|
+
* is the order `resolveCategoryDefaults` uses, so a more specific category
|
|
351
|
+
* still wins.
|
|
352
|
+
*
|
|
353
|
+
* 🛑 An explicit rate on a product WINS and is never overwritten — including
|
|
354
|
+
* `gstRate: 0`, which is a deliberate exemption and not an absence
|
|
355
|
+
* (`ProductDocument.gstRate`'s own JSDoc: "undefined and 0 MEAN DIFFERENT
|
|
356
|
+
* THINGS"). The test is `== null`, never falsy.
|
|
357
|
+
*
|
|
358
|
+
* 🛑 And it REFUSES rather than defaulting. A product whose chain resolves no
|
|
359
|
+
* tax code throws at load time: C0c defines reaching the floor as a triage
|
|
360
|
+
* signal, so a silent 5% here would hide exactly what it is meant to surface —
|
|
361
|
+
* and a wrong rate on a real invoice is worse than a failed load.
|
|
362
|
+
*/
|
|
363
|
+
function withDerivedCategoryFields(products) {
|
|
364
|
+
const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
|
|
365
|
+
const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
|
|
366
|
+
const unresolved = [];
|
|
367
|
+
const out = products.map((p) => {
|
|
368
|
+
// 🛑 Must NOT early-exit on tax alone now that authenticity rides along —
|
|
369
|
+
// a product with a hand-written rate would silently keep no authenticity.
|
|
370
|
+
if (p.gstRate != null && p.hsnCode && p.authenticity) return p;
|
|
371
|
+
const chain = Array.isArray(p.categorySlugs) && p.categorySlugs.length
|
|
372
|
+
? p.categorySlugs
|
|
373
|
+
: (p.category ? [p.category] : []);
|
|
374
|
+
let code = null;
|
|
375
|
+
for (const id of chain) {
|
|
376
|
+
const c = catById.get(String(id));
|
|
377
|
+
const t = c?.productDefaults?.taxCodeId;
|
|
378
|
+
if (t) { code = t; break; }
|
|
379
|
+
}
|
|
380
|
+
const tax = code ? taxById.get(String(code)) : null;
|
|
381
|
+
if (!tax) { unresolved.push(`${p.id} (leaf ${chain[0] ?? "none"})`); return p; }
|
|
382
|
+
|
|
383
|
+
/*
|
|
384
|
+
* 🛑 AUTHENTICITY, the same gap one field over — and it was making a HERO
|
|
385
|
+
* CLAIM false.
|
|
386
|
+
*
|
|
387
|
+
* `productDefaults.defaultAuthenticity` is declared on 122 categories and
|
|
388
|
+
* `authenticity` was absent on **all 71 live products**, because it derives
|
|
389
|
+
* on a repository write and the seed does not make one. The homepage
|
|
390
|
+
* authenticity slide says "the seller has to pick one before it can go
|
|
391
|
+
* live" — which was untrue in two ways at once: the write schema has it
|
|
392
|
+
* `.optional()`, and not one product carried a value.
|
|
393
|
+
*
|
|
394
|
+
* `unverified` is the floor, never `original`: `ProductAuthenticity`'s own
|
|
395
|
+
* docstring says that without it "an honest seller who does not know would
|
|
396
|
+
* have to pick 'original', and silence would be indistinguishable from a
|
|
397
|
+
* genuine claim." The platform must not make an authenticity claim nobody
|
|
398
|
+
* checked.
|
|
399
|
+
*
|
|
400
|
+
* An explicit product value always wins, so the 6 categories that declare
|
|
401
|
+
* `original` still reach their products.
|
|
402
|
+
*/
|
|
403
|
+
let authenticity = null;
|
|
404
|
+
for (const id of chain) {
|
|
405
|
+
const c = catById.get(String(id));
|
|
406
|
+
const a = c?.productDefaults?.defaultAuthenticity;
|
|
407
|
+
if (a) { authenticity = a; break; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
...p,
|
|
412
|
+
gstRate: p.gstRate != null ? p.gstRate : tax.gstRate,
|
|
413
|
+
// An exempt code has no HSN, and `hsnCode: ""` would read as a declared
|
|
414
|
+
// empty code rather than as an inapplicable one.
|
|
415
|
+
...(p.hsnCode || !tax.hsnCode ? {} : { hsnCode: tax.hsnCode }),
|
|
416
|
+
...(p.authenticity || !authenticity ? {} : { authenticity }),
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
if (unresolved.length) {
|
|
420
|
+
const detail = unresolved.slice(0, 10).join(", ");
|
|
421
|
+
throw new Error(
|
|
422
|
+
`seed: ${unresolved.length} product(s) resolve NO taxCodeId from their category chain, ` +
|
|
423
|
+
"so they would be taxed at zero. Give the category a taxCodeId " +
|
|
424
|
+
`(the roots carry a floor - see ROOT_TAX_DEFAULTS): ${detail}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
314
430
|
const SEED_DATA_MAP = {
|
|
315
431
|
users: usersSeedData,
|
|
316
432
|
/*
|
|
@@ -336,7 +452,7 @@ const SEED_DATA_MAP = {
|
|
|
336
452
|
// standalone brands collection/seed exists anymore.
|
|
337
453
|
categories: categoriesSeedData,
|
|
338
454
|
stores: storesSeedData,
|
|
339
|
-
products: [
|
|
455
|
+
products: withDerivedCategoryFields([
|
|
340
456
|
...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
|
|
341
457
|
...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
|
|
342
458
|
// art + stickers were written, exported from the seed barrel, and then never
|
|
@@ -344,7 +460,7 @@ const SEED_DATA_MAP = {
|
|
|
344
460
|
// /stickers were permanently empty, and listingType=art|stickers returned 0.
|
|
345
461
|
// Root Cause #58's family one layer down: added everywhere except the runner.
|
|
346
462
|
...(productsArtSeedData || []), ...(productsStickersSeedData || []),
|
|
347
|
-
],
|
|
463
|
+
]),
|
|
348
464
|
orders: ordersSeedData,
|
|
349
465
|
reviews: reviewsSeedData,
|
|
350
466
|
bids: bidsSeedData,
|
|
@@ -383,6 +499,8 @@ const SEED_DATA_MAP = {
|
|
|
383
499
|
scammerProfiles: scammersSeedData,
|
|
384
500
|
productFeatures: productFeaturesSeedData,
|
|
385
501
|
taxCodes: taxCodesSeedData,
|
|
502
|
+
partGlossary: partGlossarySeedData,
|
|
503
|
+
modelComposition: modelCompositionSeedData,
|
|
386
504
|
testerChecklistItems: testerChecklistSeedData,
|
|
387
505
|
testerChecklistResponses: testerResponsesSeedData,
|
|
388
506
|
};
|
|
@@ -603,7 +721,7 @@ async function loadUsers(seed, stats) {
|
|
|
603
721
|
* hashed from plaintext and the encryption must not see a hashed value,
|
|
604
722
|
* so nothing may be added to `docData` after that pair runs.
|
|
605
723
|
*/
|
|
606
|
-
const plain = stripUndefined(stampSeedPurpose("users", { ...u }
|
|
724
|
+
const plain = stripUndefined(stampSeedPurpose("users", { ...u }));
|
|
607
725
|
let docData = {
|
|
608
726
|
...encryptPiiFields(plain, [...USER_PII_FIELDS]),
|
|
609
727
|
...piiIndicesFor(plain, USER_PII_INDEX_MAP),
|
|
@@ -635,7 +753,7 @@ async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, pi
|
|
|
635
753
|
* conflating the two would make a parent's classification silently
|
|
636
754
|
* govern children it knows nothing about.
|
|
637
755
|
*/
|
|
638
|
-
const stamped = stripUndefined(stampSeedPurpose(subColl, data
|
|
756
|
+
const stamped = stripUndefined(stampSeedPurpose(subColl, data));
|
|
639
757
|
const payload = piiFields ? encryptPiiFields(stamped, [...piiFields]) : stamped;
|
|
640
758
|
await docRef.set(payload, { merge: true });
|
|
641
759
|
stats.created++;
|
|
@@ -661,11 +779,11 @@ async function loadOneDocPerUser(seed, coll, stats) {
|
|
|
661
779
|
*/
|
|
662
780
|
await db.collection(coll).doc(id).set(
|
|
663
781
|
stripUndefined(
|
|
664
|
-
stampSeedPurpose(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
),
|
|
782
|
+
stampSeedPurpose(coll, {
|
|
783
|
+
userId,
|
|
784
|
+
items: items ?? [],
|
|
785
|
+
updatedAt: updatedAt ?? new Date(),
|
|
786
|
+
}),
|
|
669
787
|
),
|
|
670
788
|
{ merge: false },
|
|
671
789
|
);
|
|
@@ -709,7 +827,14 @@ async function loadGeneric(colName, seed, firestoreCollection, stats) {
|
|
|
709
827
|
* a heuristic, and `users`/`orders` are reachable by it for the first
|
|
710
828
|
* time.
|
|
711
829
|
*/
|
|
712
|
-
|
|
830
|
+
/*
|
|
831
|
+
* 🛑 `docId` is passed EXPLICITLY because this loader destructures `id`
|
|
832
|
+
* out (`let { id, ...data } = docData`), so the row it hands over has no
|
|
833
|
+
* id for the per-row overrides to match on. Measured without it:
|
|
834
|
+
* `store-letitrip-official` loaded as `test` — the platform's own store,
|
|
835
|
+
* which is one of exactly two rows the keep-list exists to protect.
|
|
836
|
+
*/
|
|
837
|
+
data: encryptSeedPii(colName, stripUndefined(stampSeedPurpose(colName, data, docId))),
|
|
713
838
|
});
|
|
714
839
|
}
|
|
715
840
|
/*
|
|
@@ -846,7 +971,10 @@ async function cmdLoad() {
|
|
|
846
971
|
try {
|
|
847
972
|
const { id, ownerType, ownerId, ...data } = d;
|
|
848
973
|
if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
|
|
849
|
-
const payload = encryptPiiFields(
|
|
974
|
+
const payload = encryptPiiFields(
|
|
975
|
+
stripUndefined(stampSeedPurpose("addresses", { ownerType, ownerId, ...data }, id)),
|
|
976
|
+
[...ADDRESS_PII_FIELDS],
|
|
977
|
+
);
|
|
850
978
|
await db.collection("addresses").doc(id).set(payload, { merge: true });
|
|
851
979
|
stats.created++;
|
|
852
980
|
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
@@ -860,8 +988,17 @@ async function cmdLoad() {
|
|
|
860
988
|
try {
|
|
861
989
|
const { id, storeSlug, ...data } = d;
|
|
862
990
|
if (!id || !storeSlug) { stats.errors++; continue; }
|
|
991
|
+
/*
|
|
992
|
+
* Stamped under the SEED KEY `storeAddresses`, not the target
|
|
993
|
+
* collection `addresses`. Both resolve `test`, so nothing differs
|
|
994
|
+
* today — but the purpose table is keyed on the seed key, and
|
|
995
|
+
* stamping under the write target would make the two disagree the
|
|
996
|
+
* day one of them changes.
|
|
997
|
+
*/
|
|
863
998
|
const payload = encryptPiiFields(
|
|
864
|
-
stripUndefined(
|
|
999
|
+
stripUndefined(
|
|
1000
|
+
stampSeedPurpose("storeAddresses", { ownerType: "store", ownerId: storeSlug, ...data }, id),
|
|
1001
|
+
),
|
|
865
1002
|
[...ADDRESS_PII_FIELDS],
|
|
866
1003
|
);
|
|
867
1004
|
await db.collection("addresses").doc(id).set(payload, { merge: true });
|
|
@@ -875,7 +1012,10 @@ async function cmdLoad() {
|
|
|
875
1012
|
const { userId, couponId, ...data } = d;
|
|
876
1013
|
if (!userId || !couponId) { stats.errors++; continue; }
|
|
877
1014
|
await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
|
|
878
|
-
.set(
|
|
1015
|
+
.set(
|
|
1016
|
+
stripUndefined(stampSeedPurpose("couponUsage", { couponId, ...data }, couponId)),
|
|
1017
|
+
{ merge: true },
|
|
1018
|
+
);
|
|
879
1019
|
stats.created++;
|
|
880
1020
|
} catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
|
|
881
1021
|
}
|
|
@@ -886,7 +1026,8 @@ async function cmdLoad() {
|
|
|
886
1026
|
} else if (colName === "siteSettings") {
|
|
887
1027
|
const settings = seed[0];
|
|
888
1028
|
if (settings) {
|
|
889
|
-
await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(
|
|
1029
|
+
await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(
|
|
1030
|
+
stripUndefined(stampSeedPurpose("siteSettings", settings, "global")), { merge: true });
|
|
890
1031
|
stats.created++;
|
|
891
1032
|
}
|
|
892
1033
|
} else {
|
|
@@ -982,11 +1123,146 @@ async function cmdDryRun(intendedAction) {
|
|
|
982
1123
|
|
|
983
1124
|
// ---------------------------------------------------------------------------
|
|
984
1125
|
// Main
|
|
1126
|
+
/**
|
|
1127
|
+
* `appkit-seed purge` — delete every `seedPurpose: "test"` document and NOTHING
|
|
1128
|
+
* else.
|
|
1129
|
+
*
|
|
1130
|
+
* ## Why this is not `delete`
|
|
1131
|
+
*
|
|
1132
|
+
* `delete` empties whole COLLECTIONS. That is the right tool for a full reset
|
|
1133
|
+
* and the wrong one for "clear the test data": it would take the 85 authored
|
|
1134
|
+
* FAQs, the 416-node taxonomy every product's `categorySlugs` chain points
|
|
1135
|
+
* into, the real HSN tax codes, the 1,337 tester cases and the AES-encrypted
|
|
1136
|
+
* site credentials with it.
|
|
1137
|
+
*
|
|
1138
|
+
* This is a QUERY over a declaration — `where("seedPurpose","==","test")` —
|
|
1139
|
+
* which gives four properties no heuristic can:
|
|
1140
|
+
*
|
|
1141
|
+
* 1. **A row the seeder never wrote is unreachable.** Organic documents (a
|
|
1142
|
+
* real buyer's cart, a runtime notification, a real signup) carry no
|
|
1143
|
+
* `seedPurpose` at all, so an equality on it cannot match them. Measured
|
|
1144
|
+
* live: 64 of 84 users are organic. That is the single most important
|
|
1145
|
+
* safety property here, and it comes from the field's ABSENCE rather than
|
|
1146
|
+
* from a list someone has to maintain.
|
|
1147
|
+
* 2. **The keep-list is data, not code.** `store-letitrip-official` and
|
|
1148
|
+
* `user-admin-letitrip` resolve `permanent`, so the same query skips them.
|
|
1149
|
+
* This replaces `cmdDelete`'s private `PROTECTED_USER_UIDS` set.
|
|
1150
|
+
* 3. **No composite index.** A single-field equality is served by Firestore's
|
|
1151
|
+
* automatic index, so this adds nothing to `firestore.indexes.json` — the
|
|
1152
|
+
* gap that left `priceIndex` queryless.
|
|
1153
|
+
* 4. **The cascade is mostly automatic.** Orders, bids, reviews, offers,
|
|
1154
|
+
* carts, payouts and notifications are all `test`, so a purged product's
|
|
1155
|
+
* dependents go in the same pass rather than needing a reference walk.
|
|
1156
|
+
*
|
|
1157
|
+
* 🛑 **Auth is a SECOND delete and `delete`/`purge` are the only places that
|
|
1158
|
+
* know it.** A Firestore user row and its Firebase Auth record are two objects;
|
|
1159
|
+
* removing the row and leaving the login is the worst of the three outcomes,
|
|
1160
|
+
* because the account then looks absent and can still authenticate.
|
|
1161
|
+
*
|
|
1162
|
+
* 🛑 **What it deliberately does NOT do**: delete organic rows that reference a
|
|
1163
|
+
* purged one. An organic notification pointing at a purged test order becomes a
|
|
1164
|
+
* dangling reference — and it is a real person's record, so it is REPORTED
|
|
1165
|
+
* rather than removed. Guessing in the destructive direction on someone's own
|
|
1166
|
+
* data is not a trade this command gets to make.
|
|
1167
|
+
*/
|
|
1168
|
+
async function cmdPurge() {
|
|
1169
|
+
const targets = collectionsToProcess;
|
|
1170
|
+
|
|
1171
|
+
/* Subcollections are not reachable by a top-level query, so they are walked
|
|
1172
|
+
* explicitly. `couponUsage` lives at `users/{uid}/couponUsage`. */
|
|
1173
|
+
const SUBCOLLECTIONS = [{ key: "couponUsage", parent: USER_COLLECTION, name: "couponUsage" }];
|
|
1174
|
+
|
|
1175
|
+
const plan = [];
|
|
1176
|
+
for (const colName of targets) {
|
|
1177
|
+
const path = COLLECTION_MAP[colName];
|
|
1178
|
+
if (!path) continue;
|
|
1179
|
+
const sub = SUBCOLLECTIONS.find((s) => s.key === colName);
|
|
1180
|
+
try {
|
|
1181
|
+
if (sub) {
|
|
1182
|
+
/* A collection-group query reaches every parent at once. */
|
|
1183
|
+
const snap = await db.collectionGroup(sub.name).where("seedPurpose", "==", "test").get();
|
|
1184
|
+
if (snap.size > 0) plan.push({ colName, docs: snap.docs, isSub: true });
|
|
1185
|
+
} else {
|
|
1186
|
+
const snap = await db.collection(path).where("seedPurpose", "==", "test").get();
|
|
1187
|
+
if (snap.size > 0) plan.push({ colName, docs: snap.docs, isSub: false });
|
|
1188
|
+
}
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
console.error(`✗ ${colName}: ${err.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const total = plan.reduce((n, p) => n + p.docs.length, 0);
|
|
1195
|
+
console.log(`\n${"collection".padEnd(26)} ${"purge".padStart(6)}`);
|
|
1196
|
+
console.log("─".repeat(36));
|
|
1197
|
+
for (const p of plan) {
|
|
1198
|
+
console.log(` ${p.colName.padEnd(24)} ${String(p.docs.length).padStart(6)}`);
|
|
1199
|
+
}
|
|
1200
|
+
console.log("─".repeat(36));
|
|
1201
|
+
console.log(` ${"TOTAL".padEnd(24)} ${String(total).padStart(6)}\n`);
|
|
1202
|
+
|
|
1203
|
+
if (total === 0) {
|
|
1204
|
+
console.log("Nothing to purge — no document carries seedPurpose \"test\".");
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (DRY_RUN) {
|
|
1209
|
+
console.log("--dry-run — nothing was deleted.");
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (
|
|
1214
|
+
!(await confirm(
|
|
1215
|
+
`Delete ${total} test document(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'? Permanent content and organic rows are untouched.`,
|
|
1216
|
+
))
|
|
1217
|
+
) {
|
|
1218
|
+
console.log("Aborted.");
|
|
1219
|
+
process.exit(0);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const stats = { deleted: 0, auth: 0, errors: 0 };
|
|
1223
|
+
for (const p of plan) {
|
|
1224
|
+
/*
|
|
1225
|
+
* 🛑 `users` first deletes the AUTH record, then the Firestore row — in that
|
|
1226
|
+
* order, because the reverse can leave an authenticating account whose
|
|
1227
|
+
* profile is gone, and this loop is the only thing that knows both exist.
|
|
1228
|
+
*/
|
|
1229
|
+
if (p.colName === "users") {
|
|
1230
|
+
for (const d of p.docs) {
|
|
1231
|
+
try {
|
|
1232
|
+
await auth.deleteUser(d.id);
|
|
1233
|
+
stats.auth++;
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
if (e?.code !== "auth/user-not-found") console.error(` auth ${d.id}: ${e.message}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
for (let i = 0; i < p.docs.length; i += 450) {
|
|
1240
|
+
const chunk = p.docs.slice(i, i + 450);
|
|
1241
|
+
const batch = db.batch();
|
|
1242
|
+
for (const d of chunk) batch.delete(d.ref);
|
|
1243
|
+
try {
|
|
1244
|
+
await batch.commit();
|
|
1245
|
+
stats.deleted += chunk.length;
|
|
1246
|
+
if (VERBOSE) for (const d of chunk) console.log(` - ${p.colName}/${d.id}`);
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
console.error(`✗ ${p.colName}: ${err.message}`);
|
|
1249
|
+
stats.errors++;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
console.log(
|
|
1255
|
+
`\n${stats.errors === 0 ? "✓" : "✗"} Purge complete — deleted ${stats.deleted}, auth records ${stats.auth}, errors ${stats.errors}`,
|
|
1256
|
+
);
|
|
1257
|
+
if (stats.errors > 0) process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
985
1260
|
// ---------------------------------------------------------------------------
|
|
986
1261
|
try {
|
|
987
1262
|
if (ACTION === "status") await cmdStatus();
|
|
988
1263
|
else if (ACTION === "load") await cmdLoad();
|
|
989
1264
|
else if (ACTION === "delete") await cmdDelete();
|
|
1265
|
+
else if (ACTION === "purge") await cmdPurge();
|
|
990
1266
|
process.exit(0);
|
|
991
1267
|
} catch (err) {
|
|
992
1268
|
console.error("✗ Fatal:", err.message);
|