@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.
Files changed (160) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. 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 = 1; count <= maxAttempts; 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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.45.0",
3
+ "version": "4.47.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 }, LOAD_STARTED_AT));
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, LOAD_STARTED_AT));
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
- coll,
666
- { userId, items: items ?? [], updatedAt: updatedAt ?? new Date() },
667
- LOAD_STARTED_AT,
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
- data: encryptSeedPii(colName, stripUndefined(stampSeedPurpose(colName, data, LOAD_STARTED_AT))),
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(stripUndefined({ ownerType, ownerId, ...data }), [...ADDRESS_PII_FIELDS]);
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({ ownerType: "store", ownerId: storeSlug, ...data }),
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(stripUndefined({ couponId, ...data }), { merge: true });
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(stripUndefined(settings), { merge: true });
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);