@mohasinac/appkit 4.35.3 → 4.36.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 (27) hide show
  1. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +73 -6
  2. package/dist/features/products/hooks/useProducts.js +40 -3
  3. package/dist/features/tester/schemas/firestore.d.ts +29 -0
  4. package/dist/features/tester/seed-data/authored/_types.d.ts +24 -0
  5. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +2 -0
  6. package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +22 -0
  7. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.d.ts +2 -0
  8. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +106 -0
  9. package/dist/features/tester/seed-data/authored/admin__site-system.js +2 -0
  10. package/dist/features/tester/seed-data/authored/buying__my-orders.js +2 -0
  11. package/dist/features/tester/seed-data/authored/buying__offers.js +2 -0
  12. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +2 -0
  13. package/dist/features/tester/seed-data/authored/content-discovery__search.js +48 -19
  14. package/dist/features/tester/seed-data/authored/index.js +6 -0
  15. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +4 -0
  16. package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +0 -16
  17. package/dist/features/tester/seed-data/authored/selling__product-upload-details.d.ts +2 -0
  18. package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +150 -0
  19. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.d.ts +2 -0
  20. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +154 -0
  21. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +2 -0
  22. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +111 -6
  23. package/dist/react/contexts/SessionContext.d.ts +13 -0
  24. package/dist/react/contexts/SessionContext.js +15 -0
  25. package/dist/ui/components/ImageLightbox.js +24 -1
  26. package/package.json +1 -1
  27. package/scripts/seed-cli.mjs +784 -752
@@ -1,752 +1,784 @@
1
- #!/usr/bin/env node
2
- /**
3
- * seed-cli — standalone Firestore seed loader / deleter for LetItRip.
4
- *
5
- * Talks directly to Firestore via firebase-admin — does not require the Next.js
6
- * dev server to be running. Reads Firebase credentials and PII secrets from the
7
- * consumer project's `.env.local` (resolved against process.cwd()).
8
- *
9
- * This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
10
- *
11
- * Usage (from consumer project root, e.g. d:/proj/letitrip.in):
12
- * npx appkit-seed load # load all collections
13
- * npx appkit-seed load --collections users,categories
14
- * npx appkit-seed delete --yes # purge all (skip confirmation)
15
- * npx appkit-seed load --dry-run # plan without writing
16
- * npx appkit-seed status # show seed/existing counts
17
- *
18
- * Flags:
19
- * --collections <a,b,c> subset of collections (default: all)
20
- * --dry-run compute plan only, no writes
21
- * --yes skip the destructive-confirm prompt for `delete`
22
- * --verbose log every doc id
23
- * --service-account <p> path to a JSON key file (overrides env vars)
24
- *
25
- * Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
26
- */
27
-
28
- import { existsSync, readFileSync } from "node:fs";
29
- import { resolve, basename, dirname } from "node:path";
30
- import { createRequire, register } from "node:module";
31
- import { fileURLToPath, pathToFileURL } from "node:url";
32
- import readline from "node:readline";
33
-
34
- // Register an ESM resolver hook that retries with `.js` / `/index.js` when
35
- // appkit's bundler-target dist emits extension-less relative imports.
36
- const __dirname = dirname(fileURLToPath(import.meta.url));
37
- register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
38
-
39
- // ---------------------------------------------------------------------------
40
- // CLI parsing — entry point detects bin name so the same script powers
41
- // `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
42
- // ---------------------------------------------------------------------------
43
- const rawArgs = process.argv.slice(2);
44
- const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
45
- function flag(name, fallback = undefined) {
46
- const i = rawArgs.indexOf(`--${name}`);
47
- if (i === -1) return fallback;
48
- const v = rawArgs[i + 1];
49
- return v && !v.startsWith("--") ? v : true;
50
- }
51
-
52
- const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
53
- const BIN_ACTION_MAP = {
54
- "appkit-seed-add": "load",
55
- "appkit-seed-remove": "delete",
56
- "appkit-seed-status": "status",
57
- };
58
- const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
59
- if (!["load", "delete", "status"].includes(ACTION)) {
60
- console.error("usage:");
61
- console.error(" appkit-seed <load|delete|status> [flags]");
62
- console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
63
- console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
64
- console.error("");
65
- console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
66
- process.exit(2);
67
- }
68
-
69
- const ONLY = flag("collections");
70
- const DRY_RUN = flag("dry-run") === true;
71
- const SKIP_CONFIRM = flag("yes") === true;
72
- const VERBOSE = flag("verbose") === true;
73
- const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
74
-
75
- const ONLY_SET = ONLY && typeof ONLY === "string"
76
- ? new Set(ONLY.split(",").map((s) => s.trim()))
77
- : null;
78
-
79
- // ---------------------------------------------------------------------------
80
- // .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
81
- // ---------------------------------------------------------------------------
82
- const repoRoot = process.cwd();
83
- const envLocalPath = resolve(repoRoot, ".env.local");
84
- if (existsSync(envLocalPath)) {
85
- for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
86
- const t = line.trim();
87
- if (!t || t.startsWith("#")) continue;
88
- const eq = t.indexOf("=");
89
- if (eq === -1) continue;
90
- const k = t.slice(0, eq).trim();
91
- const raw = t.slice(eq + 1).trim();
92
- const v = raw.replace(/^["']|["']$/g, "");
93
- if (!(k in process.env)) process.env[k] = v;
94
- }
95
- if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
96
- } else {
97
- console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
98
- }
99
-
100
- // Validate config
101
- const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
102
- const missing = required.filter((k) => !process.env[k]);
103
- if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
104
- console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
105
- console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
106
- process.exit(2);
107
- }
108
-
109
- // PII keys are mandatory for collections that encrypt fields
110
- const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
111
- // PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
112
- // alternative and is read by no runtime code — so a machine with only that set
113
- // passed this check and then threw at the first encrypt, mid-load, with some
114
- // collections already written.
115
- const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
116
- if (ACTION === "load" && !PII_KEY_PRESENT) {
117
- // A hard stop, not a warning. Loading without the key does not skip the PII
118
- // collections — it writes some and then throws, leaving a half-seeded
119
- // database that looks like a successful run until someone reads it.
120
- console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
121
- process.exit(2);
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // firebase-admin init
126
- // ---------------------------------------------------------------------------
127
- const require = createRequire(import.meta.url);
128
- // appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
129
- // static import — deliberately, so bundlers like Turbopack never see a
130
- // node:module dependency and refuse to build the client bundle). A bare
131
- // `require` identifier resolves via the global scope in Node ESM, so it must
132
- // exist as a global before the dynamic `import("@mohasinac/appkit")` below
133
- // ever triggers that code path. Only this standalone-script environment
134
- // needs it — Next.js's own server bundle (webpack/Turbopack) provides an
135
- // ambient `require` automatically.
136
- globalThis.require = require;
137
- const admin = require("firebase-admin");
138
-
139
- function parsePrivateKey(raw) {
140
- if (!raw) return raw;
141
- return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
142
- }
143
-
144
- if (!admin.apps.length) {
145
- if (SERVICE_ACCOUNT_OVERRIDE) {
146
- const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
147
- admin.initializeApp({
148
- credential: admin.credential.cert(key),
149
- databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
150
- });
151
- } else {
152
- admin.initializeApp({
153
- credential: admin.credential.cert({
154
- projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
155
- clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
156
- privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
157
- }),
158
- databaseURL:
159
- process.env.FIREBASE_ADMIN_DATABASE_URL ||
160
- process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
161
- });
162
- }
163
- }
164
- const db = admin.firestore();
165
- const auth = admin.auth();
166
- console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
167
-
168
- // ---------------------------------------------------------------------------
169
- // Pull seed data + helpers from @mohasinac/appkit
170
- // ---------------------------------------------------------------------------
171
- const appkit = await import("@mohasinac/appkit");
172
- // PII crypto functions moved server-only in appkit 3.8.1 (they touch
173
- // firebase-admin's crypto chain) — no longer exported from the main
174
- // client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
175
- // / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
176
- const appkitServer = await import("@mohasinac/appkit/server");
177
- const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig } = appkitServer;
178
-
179
- const {
180
- // seed data
181
- usersSeedData, addressesSeedData, storeAddressesSeedData,
182
- categoriesSeedData, storesSeedData, sessionsSeedData,
183
- productsStandardSeedData, productsAuctionsSeedData, productsPreordersSeedData,
184
- productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
185
- productsArtSeedData, productsStickersSeedData,
186
- ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
187
- couponsSeedData, couponUsageSeedData,
188
- eventsSeedData, eventEntriesSeedData, payoutsSeedData,
189
- notificationsSeedData, blogPostsSeedData,
190
- carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
191
- siteSettingsSeedData, faqSeedData,
192
- wishlistsSeedData, historySeedData,
193
- groupedListingsSeedData,
194
- scammersSeedData, productFeaturesSeedData,
195
- testerChecklistSeedData, cartsTesterSeedData,
196
- storesTesterSeedData, categoriesTesterSeedData, productsTesterSeedData,
197
- blogTesterSeedData, eventsTesterSeedData,
198
- couponsTesterSeedData, bidsTesterSeedData, ordersTesterSeedData,
199
- // collection constants
200
- USER_COLLECTION,
201
- CATEGORIES_COLLECTION, STORE_COLLECTION,
202
- PRODUCT_COLLECTION, ORDER_COLLECTION, REVIEW_COLLECTION, BID_COLLECTION,
203
- COUPONS_COLLECTION, CAROUSELS_COLLECTION, CAROUSEL_SLIDES_COLLECTION,
204
- HOMEPAGE_SECTIONS_COLLECTION, SITE_SETTINGS_COLLECTION, FAQS_COLLECTION,
205
- NOTIFICATIONS_COLLECTION, PAYOUT_COLLECTION, BLOG_POSTS_COLLECTION,
206
- EVENTS_COLLECTION, EVENT_ENTRIES_COLLECTION, SESSION_COLLECTION,
207
- CART_COLLECTION,
208
- GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
209
- WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
210
- TESTER_CHECKLIST_ITEM_COLLECTION,
211
- // PII field/index constants (plain data, still in the main barrel — the
212
- // crypto functions themselves come from appkitServer above)
213
- USER_PII_FIELDS, USER_PII_INDEX_MAP,
214
- ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
215
- REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
216
- // misc
217
- generateFAQId,
218
- } = appkit;
219
-
220
- // ---------------------------------------------------------------------------
221
- // Collection registry
222
- // ---------------------------------------------------------------------------
223
- const COLLECTION_MAP = {
224
- users: USER_COLLECTION,
225
- addresses: "addresses",
226
- storeAddresses: "storeAddresses",
227
- couponUsage: "couponUsage",
228
- categories: CATEGORIES_COLLECTION,
229
- stores: STORE_COLLECTION,
230
- products: PRODUCT_COLLECTION,
231
- orders: ORDER_COLLECTION,
232
- reviews: REVIEW_COLLECTION,
233
- bids: BID_COLLECTION,
234
- coupons: COUPONS_COLLECTION,
235
- carousels: CAROUSELS_COLLECTION,
236
- carouselSlides: CAROUSEL_SLIDES_COLLECTION,
237
- homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
238
- siteSettings: SITE_SETTINGS_COLLECTION,
239
- faqs: FAQS_COLLECTION,
240
- notifications: NOTIFICATIONS_COLLECTION,
241
- payouts: PAYOUT_COLLECTION,
242
- blogPosts: BLOG_POSTS_COLLECTION,
243
- events: EVENTS_COLLECTION,
244
- eventEntries: EVENT_ENTRIES_COLLECTION,
245
- sessions: SESSION_COLLECTION,
246
- carts: CART_COLLECTION,
247
- wishlists: WISHLIST_COLLECTION,
248
- history: HISTORY_COLLECTION,
249
- groupedListings: GROUPED_LISTINGS_COLLECTION,
250
- scammerProfiles: SCAMMER_COLLECTION,
251
- productFeatures: PRODUCT_FEATURES_COLLECTION,
252
- testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
253
- };
254
-
255
- const SEED_DATA_MAP = {
256
- users: usersSeedData,
257
- addresses: addressesSeedData,
258
- storeAddresses: storeAddressesSeedData,
259
- // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
260
- // standalone brands collection/seed exists anymore.
261
- categories: [...(categoriesSeedData || []), ...(categoriesTesterSeedData || [])],
262
- stores: [...(storesSeedData || []), ...(storesTesterSeedData || [])],
263
- products: [
264
- ...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
265
- ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
266
- // art + stickers were written, exported from the seed barrel, and then never
267
- // spread here — so 13 fixtures had NEVER reached the database, /art and
268
- // /stickers were permanently empty, and listingType=art|stickers returned 0.
269
- // Root Cause #58's family one layer down: added everywhere except the runner.
270
- ...(productsArtSeedData || []), ...(productsStickersSeedData || []),
271
- ...(productsTesterSeedData || []),
272
- ],
273
- orders: [...(ordersSeedData || []), ...(ordersTesterSeedData || [])],
274
- reviews: reviewsSeedData,
275
- bids: [...(bidsSeedData || []), ...(bidsTesterSeedData || [])],
276
- coupons: [...(couponsSeedData || []), ...(couponsTesterSeedData || [])],
277
- couponUsage: couponUsageSeedData,
278
- carousels: carouselsSeedData,
279
- carouselSlides: carouselSlidesSeedData,
280
- homepageSections: homepageSectionsSeedData,
281
- siteSettings: [siteSettingsSeedData],
282
- faqs: faqSeedData,
283
- notifications: notificationsSeedData,
284
- payouts: payoutsSeedData,
285
- blogPosts: [...(blogPostsSeedData || []), ...(blogTesterSeedData || [])],
286
- events: [...(eventsSeedData || []), ...(eventsTesterSeedData || [])],
287
- eventEntries: eventEntriesSeedData,
288
- sessions: sessionsSeedData,
289
- carts: [...(cartsSeedData || []), ...(cartsTesterSeedData || [])],
290
- wishlists: wishlistsSeedData,
291
- history: historySeedData,
292
- groupedListings: groupedListingsSeedData,
293
- scammerProfiles: scammersSeedData,
294
- productFeatures: productFeaturesSeedData,
295
- testerChecklistItems: testerChecklistSeedData,
296
- };
297
-
298
- const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
299
- const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
300
-
301
- const collectionsToProcess = ONLY_SET
302
- ? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
303
- : ALL_COLLECTIONS;
304
-
305
- if (ONLY_SET && collectionsToProcess.length === 0) {
306
- console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
307
- process.exit(2);
308
- }
309
-
310
- // ---------------------------------------------------------------------------
311
- // Generic helpers
312
- // ---------------------------------------------------------------------------
313
- function stripUndefined(obj) {
314
- if (Array.isArray(obj)) return obj.map(stripUndefined);
315
- if (obj instanceof Date) return obj;
316
- if (obj !== null && typeof obj === "object") {
317
- return Object.fromEntries(
318
- Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
319
- );
320
- }
321
- return obj;
322
- }
323
-
324
- async function purgeCollection(path) {
325
- const BATCH = 500;
326
- let snap = await db.collection(path).limit(BATCH).get();
327
- let removed = 0;
328
- while (!snap.empty) {
329
- const batch = db.batch();
330
- snap.docs.forEach((d) => batch.delete(d.ref));
331
- await batch.commit();
332
- removed += snap.docs.length;
333
- if (snap.docs.length < BATCH) break;
334
- snap = await db.collection(path).limit(BATCH).get();
335
- }
336
- return removed;
337
- }
338
-
339
- function encryptSeedPii(collection, data) {
340
- const PII_MAP = {
341
- orders: ORDER_PII_FIELDS,
342
- bids: BID_PII_FIELDS,
343
- payouts: PAYOUT_PII_FIELDS,
344
- reviews: REVIEW_PII_FIELDS,
345
- products: ["sellerName", "sellerEmail"],
346
- offers: OFFER_PII_FIELDS,
347
- eventEntries: EVENT_ENTRY_PII_FIELDS,
348
- };
349
- const fields = PII_MAP[collection];
350
- if (!fields) return data;
351
- let result = encryptPiiFields(data, [...fields]);
352
- if (collection === "payouts" && result.bankAccount) {
353
- result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
354
- }
355
- return result;
356
- }
357
-
358
- async function countExisting(colName) {
359
- const seed = SEED_DATA_MAP[colName] || [];
360
- if (seed.length === 0) return 0;
361
-
362
- if (colName === "addresses") {
363
- // SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
364
- const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
365
- if (refs.length === 0) return 0;
366
- const snaps = await db.getAll(...refs);
367
- return snaps.filter((s) => s.exists).length;
368
- }
369
- if (colName === "storeAddresses") {
370
- // Also lands in the top-level `addresses` collection (see loadStoreAddresses).
371
- const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
372
- if (refs.length === 0) return 0;
373
- const snaps = await db.getAll(...refs);
374
- return snaps.filter((s) => s.exists).length;
375
- }
376
- if (colName === "couponUsage") {
377
- const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
378
- db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
379
- if (refs.length === 0) return 0;
380
- const snaps = await db.getAll(...refs);
381
- return snaps.filter((s) => s.exists).length;
382
- }
383
- if (colName === "wishlists" || colName === "history") {
384
- const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
385
- if (refs.length === 0) return 0;
386
- const snaps = await db.getAll(...refs);
387
- return snaps.filter((s) => s.exists).length;
388
- }
389
- if (colName === "siteSettings") {
390
- const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
391
- return snap.exists ? 1 : 0;
392
- }
393
- if (colName === "users") {
394
- const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
395
- if (refs.length === 0) return 0;
396
- const snaps = await db.getAll(...refs);
397
- return snaps.filter((s) => s.exists).length;
398
- }
399
- if (colName === "faqs") {
400
- // Mirrors loadGeneric: prefer the explicit id (every current faq record has
401
- // one), only derive via generateFAQId for a record that genuinely lacks one.
402
- const refs = seed.map((faq) => {
403
- const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
404
- return db.collection(COLLECTION_MAP[colName]).doc(id);
405
- });
406
- if (refs.length === 0) return 0;
407
- const snaps = await db.getAll(...refs);
408
- return snaps.filter((s) => s.exists).length;
409
- }
410
- // Mirrors loadGeneric's docId = id ?? slug resolution.
411
- const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
412
- if (refs.length === 0) return 0;
413
- const snaps = await db.getAll(...refs);
414
- return snaps.filter((s) => s.exists).length;
415
- }
416
-
417
- // ---------------------------------------------------------------------------
418
- // Status
419
- // ---------------------------------------------------------------------------
420
- async function cmdStatus() {
421
- console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
422
- console.log("─".repeat(40));
423
- for (const c of collectionsToProcess) {
424
- const seedCount = (SEED_DATA_MAP[c] || []).length;
425
- const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
426
- const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
427
- console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
428
- }
429
- console.log();
430
- }
431
-
432
- // ---------------------------------------------------------------------------
433
- // Confirm prompt
434
- // ---------------------------------------------------------------------------
435
- async function confirm(msg) {
436
- if (SKIP_CONFIRM) return true;
437
- return new Promise((resolveP) => {
438
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
439
- rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
440
- });
441
- }
442
-
443
- // ---------------------------------------------------------------------------
444
- // Per-collection writers (mirror of /api/demo/seed POST)
445
- // ---------------------------------------------------------------------------
446
- async function resolveAuthConflicts(uid, authUserData) {
447
- if (authUserData.email) {
448
- try {
449
- const conflict = await auth.getUserByEmail(authUserData.email);
450
- if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
451
- } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
452
- }
453
- if (authUserData.phoneNumber) {
454
- try {
455
- const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
456
- if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
457
- } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
458
- }
459
- }
460
-
461
- async function loadUsers(seed, stats) {
462
- for (const u of seed) {
463
- try {
464
- const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
465
- const docRef = db.collection(USER_COLLECTION).doc(uid);
466
- let userExists = false;
467
- try { await auth.getUser(uid); userExists = true; }
468
- catch (e) { if (e.code !== "auth/user-not-found") throw e; }
469
-
470
- const authUserData = { displayName, emailVerified, disabled };
471
- if (email && typeof email === "string") authUserData.email = email;
472
- if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
473
- // Firebase Auth's photoURL field requires an absolute URL. Seed data's
474
- // photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
475
- // app-proxy path — valid for Firestore + the app's own avatar rendering,
476
- // but auth.createUser()/updateUser() reject it with "must be a valid URL"
477
- // and abort the WHOLE user record (Auth + Firestore both skipped) before
478
- // ever reaching the Firestore write below. The app never reads Auth's own
479
- // photoURL copy anyway — it always resolves avatars via the Firestore
480
- // users doc — so just skip setting it on the Auth record for relative
481
- // paths instead of failing the entire seed row.
482
- if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
483
- authUserData.photoURL = photoURL;
484
- }
485
-
486
- if (!userExists) {
487
- await resolveAuthConflicts(uid, authUserData);
488
- await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
489
- }
490
- if (u.role && u.role !== "user") {
491
- await auth.setCustomUserClaims(uid, { role: u.role });
492
- }
493
-
494
- // Both arguments read the PLAINTEXT `plain`: indices must be hashed from
495
- // the source text, and encryption must not see an already-hashed value.
496
- // The previous form relied on ordering alone (indices first, THEN
497
- // encrypt) — correct, but one line-swap away from spreading plaintext
498
- // back over ciphertext, which is why `addPiiIndices` no longer exists.
499
- const plain = stripUndefined({ ...u });
500
- let docData = {
501
- ...encryptPiiFields(plain, [...USER_PII_FIELDS]),
502
- ...piiIndicesFor(plain, USER_PII_INDEX_MAP),
503
- };
504
- if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
505
- if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
506
- await docRef.set(docData, { merge: true });
507
- stats.created++;
508
- if (VERBOSE) console.log(` + ${uid}`);
509
- } catch (err) {
510
- console.error(` ✗ user ${u.uid}: ${err.message}`);
511
- stats.errors++;
512
- }
513
- }
514
- }
515
-
516
- async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
517
- for (const d of seed) {
518
- try {
519
- const parent = d[parentKey];
520
- const id = d[idKey];
521
- if (!parent || !id) { stats.errors++; continue; }
522
- const { [parentKey]: _p, [idKey]: _i, ...data } = d;
523
- const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
524
- const payload = piiFields ? encryptPiiFields(stripUndefined(data), [...piiFields]) : stripUndefined(data);
525
- await docRef.set(payload, { merge: true });
526
- stats.created++;
527
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
528
- }
529
- }
530
-
531
- async function loadOneDocPerUser(seed, coll, stats) {
532
- for (const d of seed) {
533
- try {
534
- const { id, userId, items, updatedAt } = d;
535
- if (!id || !userId) { stats.errors++; continue; }
536
- await db.collection(coll).doc(id).set(
537
- stripUndefined({ userId, items: items ?? [], updatedAt: updatedAt ?? new Date() }),
538
- { merge: false },
539
- );
540
- stats.created++;
541
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
542
- }
543
- }
544
-
545
- async function loadGeneric(colName, seed, firestoreCollection, stats) {
546
- if (REPLACE_ON_LOAD.has(colName)) {
547
- const removed = await purgeCollection(firestoreCollection);
548
- if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
549
- }
550
- const items = [];
551
- for (const docData of seed) {
552
- let { id, ...data } = docData;
553
- if (!id && colName === "faqs") {
554
- id = generateFAQId({ category: docData.category, question: docData.question });
555
- if (!id) { stats.errors++; continue; }
556
- } else if (!id) { stats.errors++; continue; }
557
- // `id` already carries the project's mandatory slug prefix (blog-, event-,
558
- // faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
559
- // `slug` field here (as this used to) wrote every blogPosts/events doc
560
- // under its unprefixed slug instead, e.g. "auction-feature-launch"
561
- // instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
562
- // real Firestore doc IDs after a full reseed.
563
- const docId = id ?? docData.slug;
564
- if (!data.slug) data.slug = docData.slug ?? docId;
565
- items.push({
566
- ref: db.collection(firestoreCollection).doc(docId),
567
- data: encryptSeedPii(colName, stripUndefined(data)),
568
- });
569
- }
570
- const BATCH = 500;
571
- for (let i = 0; i < items.length; i += BATCH) {
572
- const chunk = items.slice(i, i + BATCH);
573
- const batch = db.batch();
574
- for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
575
- await batch.commit();
576
- stats.created += chunk.length;
577
- }
578
- }
579
-
580
- // ---------------------------------------------------------------------------
581
- // Load
582
- // ---------------------------------------------------------------------------
583
- async function cmdLoad() {
584
- if (DRY_RUN) return cmdDryRun("load");
585
-
586
- const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
587
- for (const colName of collectionsToProcess) {
588
- const seed = SEED_DATA_MAP[colName];
589
- if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
590
- console.log(`▌ ${colName} (${seed.length} docs)`);
591
-
592
- try {
593
- if (colName === "users") {
594
- await loadUsers(seed, stats);
595
- } else if (colName === "addresses") {
596
- // SB-UNI-A (2026-05-13) unified addresses into one top-level collection
597
- // with an ownerType/ownerId discriminator, replacing the two prior
598
- // subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
599
- // loader used to write to. addressesSeedData already carries
600
- // ownerType/ownerId directly — write it straight to the top-level
601
- // collection instead of nesting under USER_COLLECTION's old subcollection.
602
- for (const d of seed) {
603
- try {
604
- const { id, ownerType, ownerId, ...data } = d;
605
- if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
606
- const payload = encryptPiiFields(stripUndefined({ ownerType, ownerId, ...data }), [...ADDRESS_PII_FIELDS]);
607
- await db.collection("addresses").doc(id).set(payload, { merge: true });
608
- stats.created++;
609
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
610
- }
611
- } else if (colName === "storeAddresses") {
612
- // storeAddressesSeedData predates the SB-UNI-A unification and still
613
- // carries the old storeSlug field instead of ownerType/ownerId — derive
614
- // the unified shape at load time rather than writing to the deleted
615
- // stores/{slug}/addresses subcollection.
616
- for (const d of seed) {
617
- try {
618
- const { id, storeSlug, ...data } = d;
619
- if (!id || !storeSlug) { stats.errors++; continue; }
620
- const payload = encryptPiiFields(
621
- stripUndefined({ ownerType: "store", ownerId: storeSlug, ...data }),
622
- [...ADDRESS_PII_FIELDS],
623
- );
624
- await db.collection("addresses").doc(id).set(payload, { merge: true });
625
- stats.created++;
626
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
627
- }
628
- } else if (colName === "couponUsage") {
629
- // shape: { userId, couponId, ... } — no PII
630
- for (const d of seed) {
631
- try {
632
- const { userId, couponId, ...data } = d;
633
- if (!userId || !couponId) { stats.errors++; continue; }
634
- await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
635
- .set(stripUndefined({ couponId, ...data }), { merge: true });
636
- stats.created++;
637
- } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
638
- }
639
- } else if (colName === "wishlists") {
640
- await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
641
- } else if (colName === "history") {
642
- await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
643
- } else if (colName === "siteSettings") {
644
- const settings = seed[0];
645
- if (settings) {
646
- await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(stripUndefined(settings), { merge: true });
647
- stats.created++;
648
- }
649
- } else {
650
- await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
651
- }
652
- } catch (err) {
653
- console.error(`✗ ${colName}: ${err.message}`);
654
- stats.errors++;
655
- }
656
- }
657
-
658
- console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
659
- process.exit(stats.errors > 0 ? 1 : 0);
660
- }
661
-
662
- // ---------------------------------------------------------------------------
663
- // Delete
664
- // ---------------------------------------------------------------------------
665
- async function cmdDelete() {
666
- if (DRY_RUN) return cmdDryRun("delete");
667
-
668
- if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
669
- console.log("Aborted."); process.exit(0);
670
- }
671
-
672
- const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
673
- const stats = { deleted: 0, skipped: 0, errors: 0 };
674
-
675
- for (const colName of collectionsToProcess) {
676
- console.log(`▌ ${colName}`);
677
- const seed = SEED_DATA_MAP[colName] || [];
678
- try {
679
- if (colName === "users") {
680
- for (const u of seed) {
681
- if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
682
- try { await auth.deleteUser(u.uid); }
683
- catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
684
- const ref = db.collection(USER_COLLECTION).doc(u.uid);
685
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
686
- }
687
- } else if (colName === "addresses" || colName === "storeAddresses") {
688
- // SB-UNI-A (2026-05-13): both user and store addresses live in the
689
- // single top-level `addresses` collection now, keyed by `id` — the
690
- // old per-parent subcollection paths (users/{uid}/addresses,
691
- // stores/{slug}/addresses) no longer exist, so deleting from them was
692
- // always a silent no-op. Delete by doc id straight from `addresses`.
693
- for (const d of seed) {
694
- if (!d.id) { stats.skipped++; continue; }
695
- const ref = db.collection("addresses").doc(d.id);
696
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
697
- else stats.skipped++;
698
- }
699
- } else if (colName === "couponUsage") {
700
- const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
701
- for (const uid of userIds) {
702
- stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
703
- }
704
- } else if (colName === "siteSettings") {
705
- const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
706
- if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
707
- } else {
708
- stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
709
- }
710
- } catch (err) {
711
- console.error(`✗ ${colName}: ${err.message}`);
712
- stats.errors++;
713
- }
714
- }
715
-
716
- console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
717
- process.exit(stats.errors > 0 ? 1 : 0);
718
- }
719
-
720
- // ---------------------------------------------------------------------------
721
- // Dry run
722
- // ---------------------------------------------------------------------------
723
- async function cmdDryRun(intendedAction) {
724
- console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
725
- console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
726
- console.log("─".repeat(56));
727
- let totalCreate = 0, totalDelete = 0;
728
- for (const c of collectionsToProcess) {
729
- const seedCount = (SEED_DATA_MAP[c] || []).length;
730
- const existing = await countExisting(c).catch(() => 0);
731
- const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
732
- const wouldDelete = intendedAction === "delete" ? existing : 0;
733
- totalCreate += wouldCreate; totalDelete += wouldDelete;
734
- console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
735
- }
736
- console.log("─".repeat(56));
737
- console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
738
- }
739
-
740
- // ---------------------------------------------------------------------------
741
- // Main
742
- // ---------------------------------------------------------------------------
743
- try {
744
- if (ACTION === "status") await cmdStatus();
745
- else if (ACTION === "load") await cmdLoad();
746
- else if (ACTION === "delete") await cmdDelete();
747
- process.exit(0);
748
- } catch (err) {
749
- console.error("✗ Fatal:", err.message);
750
- if (VERBOSE) console.error(err.stack);
751
- process.exit(1);
752
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seed-cli — standalone Firestore seed loader / deleter for LetItRip.
4
+ *
5
+ * Talks directly to Firestore via firebase-admin — does not require the Next.js
6
+ * dev server to be running. Reads Firebase credentials and PII secrets from the
7
+ * consumer project's `.env.local` (resolved against process.cwd()).
8
+ *
9
+ * This mirrors the logic in src/app/api/demo/seed/route.ts. Keep them in sync.
10
+ *
11
+ * Usage (from consumer project root, e.g. d:/proj/letitrip.in):
12
+ * npx appkit-seed load # load all collections
13
+ * npx appkit-seed load --collections users,categories
14
+ * npx appkit-seed delete --yes # purge all (skip confirmation)
15
+ * npx appkit-seed load --dry-run # plan without writing
16
+ * npx appkit-seed status # show seed/existing counts
17
+ *
18
+ * Flags:
19
+ * --collections <a,b,c> subset of collections (default: all)
20
+ * --dry-run compute plan only, no writes
21
+ * --yes skip the destructive-confirm prompt for `delete`
22
+ * --verbose log every doc id
23
+ * --service-account <p> path to a JSON key file (overrides env vars)
24
+ *
25
+ * Exit: 0 = success, 1 = errors during run, 2 = misconfiguration.
26
+ */
27
+
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { resolve, basename, dirname } from "node:path";
30
+ import { createRequire, register } from "node:module";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import readline from "node:readline";
33
+
34
+ // Register an ESM resolver hook that retries with `.js` / `/index.js` when
35
+ // appkit's bundler-target dist emits extension-less relative imports.
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ register("./node-esm-loader.mjs", pathToFileURL(__dirname + "/"));
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // CLI parsing — entry point detects bin name so the same script powers
41
+ // `appkit-seed <verb>`, `appkit-seed-add`, and `appkit-seed-remove`.
42
+ // ---------------------------------------------------------------------------
43
+ const rawArgs = process.argv.slice(2);
44
+ const POSITIONAL = rawArgs.filter((a) => !a.startsWith("--"));
45
+ function flag(name, fallback = undefined) {
46
+ const i = rawArgs.indexOf(`--${name}`);
47
+ if (i === -1) return fallback;
48
+ const v = rawArgs[i + 1];
49
+ return v && !v.startsWith("--") ? v : true;
50
+ }
51
+
52
+ const binName = basename(process.argv[1] || "").replace(/\.(mjs|js|cmd|exe|ps1)$/i, "");
53
+ const BIN_ACTION_MAP = {
54
+ "appkit-seed-add": "load",
55
+ "appkit-seed-remove": "delete",
56
+ "appkit-seed-status": "status",
57
+ };
58
+ const ACTION = BIN_ACTION_MAP[binName] ?? POSITIONAL[0];
59
+ if (!["load", "delete", "status"].includes(ACTION)) {
60
+ console.error("usage:");
61
+ console.error(" appkit-seed <load|delete|status> [flags]");
62
+ console.error(" appkit-seed-add [flags] # alias for `appkit-seed load`");
63
+ console.error(" appkit-seed-remove [flags] # alias for `appkit-seed delete`");
64
+ console.error("");
65
+ console.error("flags: --collections a,b --dry-run --yes --verbose --service-account <path>");
66
+ process.exit(2);
67
+ }
68
+
69
+ const ONLY = flag("collections");
70
+ const DRY_RUN = flag("dry-run") === true;
71
+ const SKIP_CONFIRM = flag("yes") === true;
72
+ const VERBOSE = flag("verbose") === true;
73
+ const SERVICE_ACCOUNT_OVERRIDE = typeof flag("service-account") === "string" ? flag("service-account") : null;
74
+
75
+ const ONLY_SET = ONLY && typeof ONLY === "string"
76
+ ? new Set(ONLY.split(",").map((s) => s.trim()))
77
+ : null;
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // .env.local loader (avoids a dotenv dep, same shape as firebase-reset.mjs)
81
+ // ---------------------------------------------------------------------------
82
+ const repoRoot = process.cwd();
83
+ const envLocalPath = resolve(repoRoot, ".env.local");
84
+ if (existsSync(envLocalPath)) {
85
+ for (const line of readFileSync(envLocalPath, "utf8").split("\n")) {
86
+ const t = line.trim();
87
+ if (!t || t.startsWith("#")) continue;
88
+ const eq = t.indexOf("=");
89
+ if (eq === -1) continue;
90
+ const k = t.slice(0, eq).trim();
91
+ const raw = t.slice(eq + 1).trim();
92
+ const v = raw.replace(/^["']|["']$/g, "");
93
+ if (!(k in process.env)) process.env[k] = v;
94
+ }
95
+ if (VERBOSE) console.log(`✓ Loaded ${envLocalPath}`);
96
+ } else {
97
+ console.warn(`⚠ .env.local not found at ${envLocalPath} — relying on existing process.env`);
98
+ }
99
+
100
+ // Validate config
101
+ const required = ["FIREBASE_ADMIN_PROJECT_ID", "FIREBASE_ADMIN_CLIENT_EMAIL", "FIREBASE_ADMIN_PRIVATE_KEY"];
102
+ const missing = required.filter((k) => !process.env[k]);
103
+ if (!SERVICE_ACCOUNT_OVERRIDE && missing.length > 0) {
104
+ console.error(`✗ Missing required env vars: ${missing.join(", ")}`);
105
+ console.error(" Either set them in .env.local or pass --service-account <path-to-key.json>");
106
+ process.exit(2);
107
+ }
108
+
109
+ // PII keys are mandatory for collections that encrypt fields
110
+ const PII_REQUIRED = ["users", "addresses", "storeAddresses", "products", "orders", "reviews", "bids", "payouts", "eventEntries"];
111
+ // PII_ENCRYPTION_KEY ONLY. `PII_SECRET` used to be accepted here as an
112
+ // alternative and is read by no runtime code — so a machine with only that set
113
+ // passed this check and then threw at the first encrypt, mid-load, with some
114
+ // collections already written.
115
+ const PII_KEY_PRESENT = Boolean(process.env.PII_ENCRYPTION_KEY);
116
+ if (ACTION === "load" && !PII_KEY_PRESENT) {
117
+ // A hard stop, not a warning. Loading without the key does not skip the PII
118
+ // collections — it writes some and then throws, leaving a half-seeded
119
+ // database that looks like a successful run until someone reads it.
120
+ console.error("✖ PII_ENCRYPTION_KEY is not set — users, addresses, orders, reviews, bids, payouts and eventEntries all encrypt fields at write time.");
121
+ process.exit(2);
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // firebase-admin init
126
+ // ---------------------------------------------------------------------------
127
+ const require = createRequire(import.meta.url);
128
+ // appkit's pii-encrypt.ts calls a bare `require("crypto")` at runtime (not a
129
+ // static import — deliberately, so bundlers like Turbopack never see a
130
+ // node:module dependency and refuse to build the client bundle). A bare
131
+ // `require` identifier resolves via the global scope in Node ESM, so it must
132
+ // exist as a global before the dynamic `import("@mohasinac/appkit")` below
133
+ // ever triggers that code path. Only this standalone-script environment
134
+ // needs it — Next.js's own server bundle (webpack/Turbopack) provides an
135
+ // ambient `require` automatically.
136
+ globalThis.require = require;
137
+ const admin = require("firebase-admin");
138
+
139
+ function parsePrivateKey(raw) {
140
+ if (!raw) return raw;
141
+ return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
142
+ }
143
+
144
+ if (!admin.apps.length) {
145
+ if (SERVICE_ACCOUNT_OVERRIDE) {
146
+ const key = JSON.parse(readFileSync(resolve(repoRoot, SERVICE_ACCOUNT_OVERRIDE), "utf8"));
147
+ admin.initializeApp({
148
+ credential: admin.credential.cert(key),
149
+ databaseURL: process.env.FIREBASE_ADMIN_DATABASE_URL || process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
150
+ });
151
+ } else {
152
+ admin.initializeApp({
153
+ credential: admin.credential.cert({
154
+ projectId: process.env.FIREBASE_ADMIN_PROJECT_ID.trim(),
155
+ clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL.trim(),
156
+ privateKey: parsePrivateKey(process.env.FIREBASE_ADMIN_PRIVATE_KEY),
157
+ }),
158
+ databaseURL:
159
+ process.env.FIREBASE_ADMIN_DATABASE_URL ||
160
+ process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
161
+ });
162
+ }
163
+ }
164
+ const db = admin.firestore();
165
+ const auth = admin.auth();
166
+ console.log(`✓ Connected to project: ${process.env.FIREBASE_ADMIN_PROJECT_ID || "(service-account)"}`);
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Pull seed data + helpers from @mohasinac/appkit
170
+ // ---------------------------------------------------------------------------
171
+ const appkit = await import("@mohasinac/appkit");
172
+ // PII crypto functions moved server-only in appkit 3.8.1 (they touch
173
+ // firebase-admin's crypto chain) — no longer exported from the main
174
+ // client-safe barrel, only from "@mohasinac/appkit/server". The *_PII_FIELDS
175
+ // / *_PII_INDEX_MAP constants are plain data and stay in the main barrel.
176
+ const appkitServer = await import("@mohasinac/appkit/server");
177
+ const { encryptPiiFields, piiIndicesFor, encryptPayoutDetails, encryptPayoutBankAccount, encryptShippingConfig } = appkitServer;
178
+
179
+ const {
180
+ // seed data
181
+ usersSeedData, addressesSeedData, storeAddressesSeedData,
182
+ categoriesSeedData, storesSeedData, sessionsSeedData,
183
+ productsStandardSeedData, productsAuctionsSeedData, productsPreordersSeedData,
184
+ productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
185
+ productsArtSeedData, productsStickersSeedData,
186
+ ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
187
+ couponsSeedData, couponUsageSeedData,
188
+ eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
189
+ notificationsSeedData, blogPostsSeedData,
190
+ carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
191
+ siteSettingsSeedData, faqSeedData,
192
+ wishlistsSeedData, historySeedData,
193
+ groupedListingsSeedData,
194
+ scammersSeedData, productFeaturesSeedData,
195
+ testerChecklistSeedData, cartsTesterSeedData, offersTesterSeedData,
196
+ supportTicketsSeedData, catalogueSeedData,
197
+ shipmentsSeedData, shipmentLotsSeedData, shipmentItemsSeedData,
198
+ storesTesterSeedData, categoriesTesterSeedData, productsTesterSeedData,
199
+ blogTesterSeedData, eventsTesterSeedData,
200
+ couponsTesterSeedData, bidsTesterSeedData, ordersTesterSeedData,
201
+ // collection constants
202
+ USER_COLLECTION,
203
+ CATEGORIES_COLLECTION, STORE_COLLECTION,
204
+ PRODUCT_COLLECTION, ORDER_COLLECTION, REVIEW_COLLECTION, BID_COLLECTION,
205
+ COUPONS_COLLECTION, CAROUSELS_COLLECTION, CAROUSEL_SLIDES_COLLECTION,
206
+ HOMEPAGE_SECTIONS_COLLECTION, SITE_SETTINGS_COLLECTION, FAQS_COLLECTION,
207
+ NOTIFICATIONS_COLLECTION, PAYOUT_COLLECTION, OFFER_COLLECTION, BLOG_POSTS_COLLECTION,
208
+ EVENTS_COLLECTION, EVENT_ENTRIES_COLLECTION, SESSION_COLLECTION,
209
+ CART_COLLECTION,
210
+ GROUPED_LISTINGS_COLLECTION, SCAMMER_COLLECTION,
211
+ WISHLIST_COLLECTION, HISTORY_COLLECTION, PRODUCT_FEATURES_COLLECTION,
212
+ TESTER_CHECKLIST_ITEM_COLLECTION,
213
+ // PII field/index constants (plain data, still in the main barrel — the
214
+ // crypto functions themselves come from appkitServer above)
215
+ USER_PII_FIELDS, USER_PII_INDEX_MAP,
216
+ ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
217
+ REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
218
+ // misc
219
+ generateFAQId,
220
+ } = appkit;
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Collection registry
224
+ // ---------------------------------------------------------------------------
225
+ const COLLECTION_MAP = {
226
+ users: USER_COLLECTION,
227
+ addresses: "addresses",
228
+ storeAddresses: "storeAddresses",
229
+ couponUsage: "couponUsage",
230
+ categories: CATEGORIES_COLLECTION,
231
+ stores: STORE_COLLECTION,
232
+ products: PRODUCT_COLLECTION,
233
+ orders: ORDER_COLLECTION,
234
+ reviews: REVIEW_COLLECTION,
235
+ bids: BID_COLLECTION,
236
+ coupons: COUPONS_COLLECTION,
237
+ carousels: CAROUSELS_COLLECTION,
238
+ carouselSlides: CAROUSEL_SLIDES_COLLECTION,
239
+ homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
240
+ siteSettings: SITE_SETTINGS_COLLECTION,
241
+ faqs: FAQS_COLLECTION,
242
+ notifications: NOTIFICATIONS_COLLECTION,
243
+ payouts: PAYOUT_COLLECTION,
244
+ offers: OFFER_COLLECTION,
245
+ /*
246
+ * String literals, not constants, because SUPPORT_TICKET_COLLECTION,
247
+ * CATALOGUE_COLLECTION and SHIPMENT_COLLECTION are NOT re-exported from
248
+ * appkit's barrel -- only their features' own schema files declare them.
249
+ * Adding three barrel exports to fix a seeding gap is a public-API change
250
+ * for a private need, so the names are duplicated here with the source
251
+ * named beside each. If one is ever renamed, this is the site that breaks.
252
+ */
253
+ supportTickets: "supportTickets", // features/support/schemas/firestore.ts:279
254
+ catalogueItems: "catalogueItems", // features/catalogue/schemas/firestore.ts:18
255
+ procurementShipments: "procurementShipments", // features/shipments/schemas/firestore.ts:26
256
+ shipmentLots: "shipmentLots",
257
+ shipmentItems: "shipmentItems",
258
+ blogPosts: BLOG_POSTS_COLLECTION,
259
+ events: EVENTS_COLLECTION,
260
+ eventEntries: EVENT_ENTRIES_COLLECTION,
261
+ sessions: SESSION_COLLECTION,
262
+ carts: CART_COLLECTION,
263
+ wishlists: WISHLIST_COLLECTION,
264
+ history: HISTORY_COLLECTION,
265
+ groupedListings: GROUPED_LISTINGS_COLLECTION,
266
+ scammerProfiles: SCAMMER_COLLECTION,
267
+ productFeatures: PRODUCT_FEATURES_COLLECTION,
268
+ testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
269
+ };
270
+
271
+ const SEED_DATA_MAP = {
272
+ users: usersSeedData,
273
+ addresses: addressesSeedData,
274
+ storeAddresses: storeAddressesSeedData,
275
+ // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
276
+ // standalone brands collection/seed exists anymore.
277
+ categories: [...(categoriesSeedData || []), ...(categoriesTesterSeedData || [])],
278
+ stores: [...(storesSeedData || []), ...(storesTesterSeedData || [])],
279
+ products: [
280
+ ...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
281
+ ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
282
+ // art + stickers were written, exported from the seed barrel, and then never
283
+ // spread here — so 13 fixtures had NEVER reached the database, /art and
284
+ // /stickers were permanently empty, and listingType=art|stickers returned 0.
285
+ // Root Cause #58's family one layer down: added everywhere except the runner.
286
+ ...(productsArtSeedData || []), ...(productsStickersSeedData || []),
287
+ ...(productsTesterSeedData || []),
288
+ ],
289
+ orders: [...(ordersSeedData || []), ...(ordersTesterSeedData || [])],
290
+ reviews: reviewsSeedData,
291
+ bids: [...(bidsSeedData || []), ...(bidsTesterSeedData || [])],
292
+ coupons: [...(couponsSeedData || []), ...(couponsTesterSeedData || [])],
293
+ couponUsage: couponUsageSeedData,
294
+ carousels: carouselsSeedData,
295
+ carouselSlides: carouselSlidesSeedData,
296
+ homepageSections: homepageSectionsSeedData,
297
+ siteSettings: [siteSettingsSeedData],
298
+ faqs: faqSeedData,
299
+ notifications: notificationsSeedData,
300
+ payouts: payoutsSeedData,
301
+ // Both halves: the 10 marketplace offers AND the tester-sandbox one. The
302
+ // manifest already combined them (manifest.ts:294); only the LOADER was
303
+ // missing, so `offers` never appeared in `appkit-seed status` and no run has
304
+ // ever had an offer in Firestore. OFFER_PII_FIELDS was already imported and
305
+ // mapped below -- the PII half was wired and the loading half never was,
306
+ // which is why the gap looked like support rather than an omission.
307
+ offers: [...(offersSeedData ?? []), ...(offersTesterSeedData ?? [])],
308
+ // All four were unregistered like offers, all measured at 0 documents live.
309
+ // They are UNLISTED in the tester's tier map, which means PRESERVE -- so
310
+ // seeding them once persists across every future run rather than being
311
+ // wiped by the next tester:setup.
312
+ supportTickets: supportTicketsSeedData,
313
+ catalogueItems: catalogueSeedData,
314
+ procurementShipments: shipmentsSeedData,
315
+ shipmentLots: shipmentLotsSeedData,
316
+ shipmentItems: shipmentItemsSeedData,
317
+ blogPosts: [...(blogPostsSeedData || []), ...(blogTesterSeedData || [])],
318
+ events: [...(eventsSeedData || []), ...(eventsTesterSeedData || [])],
319
+ eventEntries: eventEntriesSeedData,
320
+ sessions: sessionsSeedData,
321
+ carts: [...(cartsSeedData || []), ...(cartsTesterSeedData || [])],
322
+ wishlists: wishlistsSeedData,
323
+ history: historySeedData,
324
+ groupedListings: groupedListingsSeedData,
325
+ scammerProfiles: scammersSeedData,
326
+ productFeatures: productFeaturesSeedData,
327
+ testerChecklistItems: testerChecklistSeedData,
328
+ };
329
+
330
+ const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
331
+ const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
332
+
333
+ const collectionsToProcess = ONLY_SET
334
+ ? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
335
+ : ALL_COLLECTIONS;
336
+
337
+ if (ONLY_SET && collectionsToProcess.length === 0) {
338
+ console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
339
+ process.exit(2);
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Generic helpers
344
+ // ---------------------------------------------------------------------------
345
+ function stripUndefined(obj) {
346
+ if (Array.isArray(obj)) return obj.map(stripUndefined);
347
+ if (obj instanceof Date) return obj;
348
+ if (obj !== null && typeof obj === "object") {
349
+ return Object.fromEntries(
350
+ Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
351
+ );
352
+ }
353
+ return obj;
354
+ }
355
+
356
+ async function purgeCollection(path) {
357
+ const BATCH = 500;
358
+ let snap = await db.collection(path).limit(BATCH).get();
359
+ let removed = 0;
360
+ while (!snap.empty) {
361
+ const batch = db.batch();
362
+ snap.docs.forEach((d) => batch.delete(d.ref));
363
+ await batch.commit();
364
+ removed += snap.docs.length;
365
+ if (snap.docs.length < BATCH) break;
366
+ snap = await db.collection(path).limit(BATCH).get();
367
+ }
368
+ return removed;
369
+ }
370
+
371
+ function encryptSeedPii(collection, data) {
372
+ const PII_MAP = {
373
+ orders: ORDER_PII_FIELDS,
374
+ bids: BID_PII_FIELDS,
375
+ payouts: PAYOUT_PII_FIELDS,
376
+ reviews: REVIEW_PII_FIELDS,
377
+ products: ["sellerName", "sellerEmail"],
378
+ offers: OFFER_PII_FIELDS,
379
+ eventEntries: EVENT_ENTRY_PII_FIELDS,
380
+ };
381
+ const fields = PII_MAP[collection];
382
+ if (!fields) return data;
383
+ let result = encryptPiiFields(data, [...fields]);
384
+ if (collection === "payouts" && result.bankAccount) {
385
+ result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
386
+ }
387
+ return result;
388
+ }
389
+
390
+ async function countExisting(colName) {
391
+ const seed = SEED_DATA_MAP[colName] || [];
392
+ if (seed.length === 0) return 0;
393
+
394
+ if (colName === "addresses") {
395
+ // SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
396
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
397
+ if (refs.length === 0) return 0;
398
+ const snaps = await db.getAll(...refs);
399
+ return snaps.filter((s) => s.exists).length;
400
+ }
401
+ if (colName === "storeAddresses") {
402
+ // Also lands in the top-level `addresses` collection (see loadStoreAddresses).
403
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
404
+ if (refs.length === 0) return 0;
405
+ const snaps = await db.getAll(...refs);
406
+ return snaps.filter((s) => s.exists).length;
407
+ }
408
+ if (colName === "couponUsage") {
409
+ const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
410
+ db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
411
+ if (refs.length === 0) return 0;
412
+ const snaps = await db.getAll(...refs);
413
+ return snaps.filter((s) => s.exists).length;
414
+ }
415
+ if (colName === "wishlists" || colName === "history") {
416
+ const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
417
+ if (refs.length === 0) return 0;
418
+ const snaps = await db.getAll(...refs);
419
+ return snaps.filter((s) => s.exists).length;
420
+ }
421
+ if (colName === "siteSettings") {
422
+ const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
423
+ return snap.exists ? 1 : 0;
424
+ }
425
+ if (colName === "users") {
426
+ const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
427
+ if (refs.length === 0) return 0;
428
+ const snaps = await db.getAll(...refs);
429
+ return snaps.filter((s) => s.exists).length;
430
+ }
431
+ if (colName === "faqs") {
432
+ // Mirrors loadGeneric: prefer the explicit id (every current faq record has
433
+ // one), only derive via generateFAQId for a record that genuinely lacks one.
434
+ const refs = seed.map((faq) => {
435
+ const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
436
+ return db.collection(COLLECTION_MAP[colName]).doc(id);
437
+ });
438
+ if (refs.length === 0) return 0;
439
+ const snaps = await db.getAll(...refs);
440
+ return snaps.filter((s) => s.exists).length;
441
+ }
442
+ // Mirrors loadGeneric's docId = id ?? slug resolution.
443
+ const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
444
+ if (refs.length === 0) return 0;
445
+ const snaps = await db.getAll(...refs);
446
+ return snaps.filter((s) => s.exists).length;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Status
451
+ // ---------------------------------------------------------------------------
452
+ async function cmdStatus() {
453
+ console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
454
+ console.log("─".repeat(40));
455
+ for (const c of collectionsToProcess) {
456
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
457
+ const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
458
+ const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
459
+ console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
460
+ }
461
+ console.log();
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Confirm prompt
466
+ // ---------------------------------------------------------------------------
467
+ async function confirm(msg) {
468
+ if (SKIP_CONFIRM) return true;
469
+ return new Promise((resolveP) => {
470
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
471
+ rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
472
+ });
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Per-collection writers (mirror of /api/demo/seed POST)
477
+ // ---------------------------------------------------------------------------
478
+ async function resolveAuthConflicts(uid, authUserData) {
479
+ if (authUserData.email) {
480
+ try {
481
+ const conflict = await auth.getUserByEmail(authUserData.email);
482
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
483
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
484
+ }
485
+ if (authUserData.phoneNumber) {
486
+ try {
487
+ const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
488
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
489
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
490
+ }
491
+ }
492
+
493
+ async function loadUsers(seed, stats) {
494
+ for (const u of seed) {
495
+ try {
496
+ const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
497
+ const docRef = db.collection(USER_COLLECTION).doc(uid);
498
+ let userExists = false;
499
+ try { await auth.getUser(uid); userExists = true; }
500
+ catch (e) { if (e.code !== "auth/user-not-found") throw e; }
501
+
502
+ const authUserData = { displayName, emailVerified, disabled };
503
+ if (email && typeof email === "string") authUserData.email = email;
504
+ if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
505
+ // Firebase Auth's photoURL field requires an absolute URL. Seed data's
506
+ // photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
507
+ // app-proxy path — valid for Firestore + the app's own avatar rendering,
508
+ // but auth.createUser()/updateUser() reject it with "must be a valid URL"
509
+ // and abort the WHOLE user record (Auth + Firestore both skipped) before
510
+ // ever reaching the Firestore write below. The app never reads Auth's own
511
+ // photoURL copy anyway — it always resolves avatars via the Firestore
512
+ // users doc — so just skip setting it on the Auth record for relative
513
+ // paths instead of failing the entire seed row.
514
+ if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
515
+ authUserData.photoURL = photoURL;
516
+ }
517
+
518
+ if (!userExists) {
519
+ await resolveAuthConflicts(uid, authUserData);
520
+ await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
521
+ }
522
+ if (u.role && u.role !== "user") {
523
+ await auth.setCustomUserClaims(uid, { role: u.role });
524
+ }
525
+
526
+ // Both arguments read the PLAINTEXT `plain`: indices must be hashed from
527
+ // the source text, and encryption must not see an already-hashed value.
528
+ // The previous form relied on ordering alone (indices first, THEN
529
+ // encrypt) — correct, but one line-swap away from spreading plaintext
530
+ // back over ciphertext, which is why `addPiiIndices` no longer exists.
531
+ const plain = stripUndefined({ ...u });
532
+ let docData = {
533
+ ...encryptPiiFields(plain, [...USER_PII_FIELDS]),
534
+ ...piiIndicesFor(plain, USER_PII_INDEX_MAP),
535
+ };
536
+ if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
537
+ if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
538
+ await docRef.set(docData, { merge: true });
539
+ stats.created++;
540
+ if (VERBOSE) console.log(` + ${uid}`);
541
+ } catch (err) {
542
+ console.error(` ✗ user ${u.uid}: ${err.message}`);
543
+ stats.errors++;
544
+ }
545
+ }
546
+ }
547
+
548
+ async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
549
+ for (const d of seed) {
550
+ try {
551
+ const parent = d[parentKey];
552
+ const id = d[idKey];
553
+ if (!parent || !id) { stats.errors++; continue; }
554
+ const { [parentKey]: _p, [idKey]: _i, ...data } = d;
555
+ const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
556
+ const payload = piiFields ? encryptPiiFields(stripUndefined(data), [...piiFields]) : stripUndefined(data);
557
+ await docRef.set(payload, { merge: true });
558
+ stats.created++;
559
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
560
+ }
561
+ }
562
+
563
+ async function loadOneDocPerUser(seed, coll, stats) {
564
+ for (const d of seed) {
565
+ try {
566
+ const { id, userId, items, updatedAt } = d;
567
+ if (!id || !userId) { stats.errors++; continue; }
568
+ await db.collection(coll).doc(id).set(
569
+ stripUndefined({ userId, items: items ?? [], updatedAt: updatedAt ?? new Date() }),
570
+ { merge: false },
571
+ );
572
+ stats.created++;
573
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
574
+ }
575
+ }
576
+
577
+ async function loadGeneric(colName, seed, firestoreCollection, stats) {
578
+ if (REPLACE_ON_LOAD.has(colName)) {
579
+ const removed = await purgeCollection(firestoreCollection);
580
+ if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
581
+ }
582
+ const items = [];
583
+ for (const docData of seed) {
584
+ let { id, ...data } = docData;
585
+ if (!id && colName === "faqs") {
586
+ id = generateFAQId({ category: docData.category, question: docData.question });
587
+ if (!id) { stats.errors++; continue; }
588
+ } else if (!id) { stats.errors++; continue; }
589
+ // `id` already carries the project's mandatory slug prefix (blog-, event-,
590
+ // faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
591
+ // `slug` field here (as this used to) wrote every blogPosts/events doc
592
+ // under its unprefixed slug instead, e.g. "auction-feature-launch"
593
+ // instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
594
+ // real Firestore doc IDs after a full reseed.
595
+ const docId = id ?? docData.slug;
596
+ if (!data.slug) data.slug = docData.slug ?? docId;
597
+ items.push({
598
+ ref: db.collection(firestoreCollection).doc(docId),
599
+ data: encryptSeedPii(colName, stripUndefined(data)),
600
+ });
601
+ }
602
+ const BATCH = 500;
603
+ for (let i = 0; i < items.length; i += BATCH) {
604
+ const chunk = items.slice(i, i + BATCH);
605
+ const batch = db.batch();
606
+ for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
607
+ await batch.commit();
608
+ stats.created += chunk.length;
609
+ }
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Load
614
+ // ---------------------------------------------------------------------------
615
+ async function cmdLoad() {
616
+ if (DRY_RUN) return cmdDryRun("load");
617
+
618
+ const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
619
+ for (const colName of collectionsToProcess) {
620
+ const seed = SEED_DATA_MAP[colName];
621
+ if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
622
+ console.log(`▌ ${colName} (${seed.length} docs)`);
623
+
624
+ try {
625
+ if (colName === "users") {
626
+ await loadUsers(seed, stats);
627
+ } else if (colName === "addresses") {
628
+ // SB-UNI-A (2026-05-13) unified addresses into one top-level collection
629
+ // with an ownerType/ownerId discriminator, replacing the two prior
630
+ // subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
631
+ // loader used to write to. addressesSeedData already carries
632
+ // ownerType/ownerId directly — write it straight to the top-level
633
+ // collection instead of nesting under USER_COLLECTION's old subcollection.
634
+ for (const d of seed) {
635
+ try {
636
+ const { id, ownerType, ownerId, ...data } = d;
637
+ if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
638
+ const payload = encryptPiiFields(stripUndefined({ ownerType, ownerId, ...data }), [...ADDRESS_PII_FIELDS]);
639
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
640
+ stats.created++;
641
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
642
+ }
643
+ } else if (colName === "storeAddresses") {
644
+ // storeAddressesSeedData predates the SB-UNI-A unification and still
645
+ // carries the old storeSlug field instead of ownerType/ownerId — derive
646
+ // the unified shape at load time rather than writing to the deleted
647
+ // stores/{slug}/addresses subcollection.
648
+ for (const d of seed) {
649
+ try {
650
+ const { id, storeSlug, ...data } = d;
651
+ if (!id || !storeSlug) { stats.errors++; continue; }
652
+ const payload = encryptPiiFields(
653
+ stripUndefined({ ownerType: "store", ownerId: storeSlug, ...data }),
654
+ [...ADDRESS_PII_FIELDS],
655
+ );
656
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
657
+ stats.created++;
658
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
659
+ }
660
+ } else if (colName === "couponUsage") {
661
+ // shape: { userId, couponId, ... } — no PII
662
+ for (const d of seed) {
663
+ try {
664
+ const { userId, couponId, ...data } = d;
665
+ if (!userId || !couponId) { stats.errors++; continue; }
666
+ await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
667
+ .set(stripUndefined({ couponId, ...data }), { merge: true });
668
+ stats.created++;
669
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
670
+ }
671
+ } else if (colName === "wishlists") {
672
+ await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
673
+ } else if (colName === "history") {
674
+ await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
675
+ } else if (colName === "siteSettings") {
676
+ const settings = seed[0];
677
+ if (settings) {
678
+ await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(stripUndefined(settings), { merge: true });
679
+ stats.created++;
680
+ }
681
+ } else {
682
+ await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
683
+ }
684
+ } catch (err) {
685
+ console.error(`✗ ${colName}: ${err.message}`);
686
+ stats.errors++;
687
+ }
688
+ }
689
+
690
+ console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
691
+ process.exit(stats.errors > 0 ? 1 : 0);
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Delete
696
+ // ---------------------------------------------------------------------------
697
+ async function cmdDelete() {
698
+ if (DRY_RUN) return cmdDryRun("delete");
699
+
700
+ if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
701
+ console.log("Aborted."); process.exit(0);
702
+ }
703
+
704
+ const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
705
+ const stats = { deleted: 0, skipped: 0, errors: 0 };
706
+
707
+ for (const colName of collectionsToProcess) {
708
+ console.log(`▌ ${colName}`);
709
+ const seed = SEED_DATA_MAP[colName] || [];
710
+ try {
711
+ if (colName === "users") {
712
+ for (const u of seed) {
713
+ if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
714
+ try { await auth.deleteUser(u.uid); }
715
+ catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
716
+ const ref = db.collection(USER_COLLECTION).doc(u.uid);
717
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
718
+ }
719
+ } else if (colName === "addresses" || colName === "storeAddresses") {
720
+ // SB-UNI-A (2026-05-13): both user and store addresses live in the
721
+ // single top-level `addresses` collection now, keyed by `id` — the
722
+ // old per-parent subcollection paths (users/{uid}/addresses,
723
+ // stores/{slug}/addresses) no longer exist, so deleting from them was
724
+ // always a silent no-op. Delete by doc id straight from `addresses`.
725
+ for (const d of seed) {
726
+ if (!d.id) { stats.skipped++; continue; }
727
+ const ref = db.collection("addresses").doc(d.id);
728
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
729
+ else stats.skipped++;
730
+ }
731
+ } else if (colName === "couponUsage") {
732
+ const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
733
+ for (const uid of userIds) {
734
+ stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
735
+ }
736
+ } else if (colName === "siteSettings") {
737
+ const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
738
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
739
+ } else {
740
+ stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
741
+ }
742
+ } catch (err) {
743
+ console.error(`✗ ${colName}: ${err.message}`);
744
+ stats.errors++;
745
+ }
746
+ }
747
+
748
+ console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
749
+ process.exit(stats.errors > 0 ? 1 : 0);
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // Dry run
754
+ // ---------------------------------------------------------------------------
755
+ async function cmdDryRun(intendedAction) {
756
+ console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
757
+ console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
758
+ console.log("─".repeat(56));
759
+ let totalCreate = 0, totalDelete = 0;
760
+ for (const c of collectionsToProcess) {
761
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
762
+ const existing = await countExisting(c).catch(() => 0);
763
+ const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
764
+ const wouldDelete = intendedAction === "delete" ? existing : 0;
765
+ totalCreate += wouldCreate; totalDelete += wouldDelete;
766
+ console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
767
+ }
768
+ console.log("─".repeat(56));
769
+ console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
770
+ }
771
+
772
+ // ---------------------------------------------------------------------------
773
+ // Main
774
+ // ---------------------------------------------------------------------------
775
+ try {
776
+ if (ACTION === "status") await cmdStatus();
777
+ else if (ACTION === "load") await cmdLoad();
778
+ else if (ACTION === "delete") await cmdDelete();
779
+ process.exit(0);
780
+ } catch (err) {
781
+ console.error("✗ Fatal:", err.message);
782
+ if (VERBOSE) console.error(err.stack);
783
+ process.exit(1);
784
+ }