@mohasinac/appkit 4.35.4 → 4.37.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 (31) hide show
  1. package/dist/_internal/server/features/site-settings/adapters.d.ts +2 -2
  2. package/dist/_internal/server/features/site-settings/adapters.js +25 -1
  3. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +73 -6
  4. package/dist/_internal/shared/features/site-settings/types.d.ts +12 -0
  5. package/dist/features/tester/schemas/firestore.d.ts +29 -0
  6. package/dist/features/tester/seed-data/_money-flows.js +13 -5
  7. package/dist/features/tester/seed-data/authored/_types.d.ts +24 -0
  8. package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +2 -0
  9. package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +22 -0
  10. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.d.ts +2 -0
  11. package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +106 -0
  12. package/dist/features/tester/seed-data/authored/admin__site-system.js +2 -0
  13. package/dist/features/tester/seed-data/authored/buying__my-orders.js +2 -0
  14. package/dist/features/tester/seed-data/authored/buying__offers.js +2 -0
  15. package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +2 -0
  16. package/dist/features/tester/seed-data/authored/content-discovery__search.js +48 -19
  17. package/dist/features/tester/seed-data/authored/index.js +6 -0
  18. package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +4 -0
  19. package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +0 -16
  20. package/dist/features/tester/seed-data/authored/selling__product-upload-details.d.ts +2 -0
  21. package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +150 -0
  22. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.d.ts +2 -0
  23. package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +154 -0
  24. package/dist/features/tester/seed-data/authored/selling__seller-orders.js +2 -0
  25. package/dist/features/tester/seed-data/index.d.ts +1 -0
  26. package/dist/features/tester/seed-data/index.js +1 -0
  27. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +111 -6
  28. package/dist/features/tester/seed-data/tester-responses-seed-data.d.ts +2 -0
  29. package/dist/features/tester/seed-data/tester-responses-seed-data.js +95 -0
  30. package/package.json +1 -1
  31. package/scripts/seed-cli.mjs +797 -752
@@ -1,752 +1,797 @@
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, testerResponsesSeedData, 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
+ TESTER_CHECKLIST_RESPONSE_COLLECTION,
214
+ // PII field/index constants (plain data, still in the main barrel — the
215
+ // crypto functions themselves come from appkitServer above)
216
+ USER_PII_FIELDS, USER_PII_INDEX_MAP,
217
+ ADDRESS_PII_FIELDS, ORDER_PII_FIELDS, BID_PII_FIELDS, PAYOUT_PII_FIELDS,
218
+ REVIEW_PII_FIELDS, OFFER_PII_FIELDS, EVENT_ENTRY_PII_FIELDS,
219
+ // misc
220
+ generateFAQId,
221
+ } = appkit;
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Collection registry
225
+ // ---------------------------------------------------------------------------
226
+ const COLLECTION_MAP = {
227
+ users: USER_COLLECTION,
228
+ addresses: "addresses",
229
+ storeAddresses: "storeAddresses",
230
+ couponUsage: "couponUsage",
231
+ categories: CATEGORIES_COLLECTION,
232
+ stores: STORE_COLLECTION,
233
+ products: PRODUCT_COLLECTION,
234
+ orders: ORDER_COLLECTION,
235
+ reviews: REVIEW_COLLECTION,
236
+ bids: BID_COLLECTION,
237
+ coupons: COUPONS_COLLECTION,
238
+ carousels: CAROUSELS_COLLECTION,
239
+ carouselSlides: CAROUSEL_SLIDES_COLLECTION,
240
+ homepageSections: HOMEPAGE_SECTIONS_COLLECTION,
241
+ siteSettings: SITE_SETTINGS_COLLECTION,
242
+ faqs: FAQS_COLLECTION,
243
+ notifications: NOTIFICATIONS_COLLECTION,
244
+ payouts: PAYOUT_COLLECTION,
245
+ offers: OFFER_COLLECTION,
246
+ /*
247
+ * String literals, not constants, because SUPPORT_TICKET_COLLECTION,
248
+ * CATALOGUE_COLLECTION and SHIPMENT_COLLECTION are NOT re-exported from
249
+ * appkit's barrel -- only their features' own schema files declare them.
250
+ * Adding three barrel exports to fix a seeding gap is a public-API change
251
+ * for a private need, so the names are duplicated here with the source
252
+ * named beside each. If one is ever renamed, this is the site that breaks.
253
+ */
254
+ supportTickets: "supportTickets", // features/support/schemas/firestore.ts:279
255
+ catalogueItems: "catalogueItems", // features/catalogue/schemas/firestore.ts:18
256
+ procurementShipments: "procurementShipments", // features/shipments/schemas/firestore.ts:26
257
+ shipmentLots: "shipmentLots",
258
+ shipmentItems: "shipmentItems",
259
+ blogPosts: BLOG_POSTS_COLLECTION,
260
+ events: EVENTS_COLLECTION,
261
+ eventEntries: EVENT_ENTRIES_COLLECTION,
262
+ sessions: SESSION_COLLECTION,
263
+ carts: CART_COLLECTION,
264
+ wishlists: WISHLIST_COLLECTION,
265
+ history: HISTORY_COLLECTION,
266
+ groupedListings: GROUPED_LISTINGS_COLLECTION,
267
+ scammerProfiles: SCAMMER_COLLECTION,
268
+ productFeatures: PRODUCT_FEATURES_COLLECTION,
269
+ testerChecklistItems: TESTER_CHECKLIST_ITEM_COLLECTION,
270
+ /*
271
+ * Tester submissions. DERIVED tier in the tester's own map (wiped at setup so
272
+ * a prior run's verdicts never leak into the next), AND listed in
273
+ * SEED_TRANSACTIONAL so these two demo fixtures are restored afterwards —
274
+ * that list is a RESTORE list, orthogonal to the delete tiers.
275
+ *
276
+ * Without the fixtures, three admin/bug-hunter-rewards cases have nothing to
277
+ * confirm or reopen and can only report blocked. Measured 2026-09-11:
278
+ * 0 pass / 0 fail / 5 blocked, both controls correct.
279
+ */
280
+ testerChecklistResponses: TESTER_CHECKLIST_RESPONSE_COLLECTION,
281
+ };
282
+
283
+ const SEED_DATA_MAP = {
284
+ users: usersSeedData,
285
+ addresses: addressesSeedData,
286
+ storeAddresses: storeAddressesSeedData,
287
+ // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
288
+ // standalone brands collection/seed exists anymore.
289
+ categories: [...(categoriesSeedData || []), ...(categoriesTesterSeedData || [])],
290
+ stores: [...(storesSeedData || []), ...(storesTesterSeedData || [])],
291
+ products: [
292
+ ...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
293
+ ...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
294
+ // art + stickers were written, exported from the seed barrel, and then never
295
+ // spread here — so 13 fixtures had NEVER reached the database, /art and
296
+ // /stickers were permanently empty, and listingType=art|stickers returned 0.
297
+ // Root Cause #58's family one layer down: added everywhere except the runner.
298
+ ...(productsArtSeedData || []), ...(productsStickersSeedData || []),
299
+ ...(productsTesterSeedData || []),
300
+ ],
301
+ orders: [...(ordersSeedData || []), ...(ordersTesterSeedData || [])],
302
+ reviews: reviewsSeedData,
303
+ bids: [...(bidsSeedData || []), ...(bidsTesterSeedData || [])],
304
+ coupons: [...(couponsSeedData || []), ...(couponsTesterSeedData || [])],
305
+ couponUsage: couponUsageSeedData,
306
+ carousels: carouselsSeedData,
307
+ carouselSlides: carouselSlidesSeedData,
308
+ homepageSections: homepageSectionsSeedData,
309
+ siteSettings: [siteSettingsSeedData],
310
+ faqs: faqSeedData,
311
+ notifications: notificationsSeedData,
312
+ payouts: payoutsSeedData,
313
+ // Both halves: the 10 marketplace offers AND the tester-sandbox one. The
314
+ // manifest already combined them (manifest.ts:294); only the LOADER was
315
+ // missing, so `offers` never appeared in `appkit-seed status` and no run has
316
+ // ever had an offer in Firestore. OFFER_PII_FIELDS was already imported and
317
+ // mapped below -- the PII half was wired and the loading half never was,
318
+ // which is why the gap looked like support rather than an omission.
319
+ offers: [...(offersSeedData ?? []), ...(offersTesterSeedData ?? [])],
320
+ // All four were unregistered like offers, all measured at 0 documents live.
321
+ // They are UNLISTED in the tester's tier map, which means PRESERVE -- so
322
+ // seeding them once persists across every future run rather than being
323
+ // wiped by the next tester:setup.
324
+ supportTickets: supportTicketsSeedData,
325
+ catalogueItems: catalogueSeedData,
326
+ procurementShipments: shipmentsSeedData,
327
+ shipmentLots: shipmentLotsSeedData,
328
+ shipmentItems: shipmentItemsSeedData,
329
+ blogPosts: [...(blogPostsSeedData || []), ...(blogTesterSeedData || [])],
330
+ events: [...(eventsSeedData || []), ...(eventsTesterSeedData || [])],
331
+ eventEntries: eventEntriesSeedData,
332
+ sessions: sessionsSeedData,
333
+ carts: [...(cartsSeedData || []), ...(cartsTesterSeedData || [])],
334
+ wishlists: wishlistsSeedData,
335
+ history: historySeedData,
336
+ groupedListings: groupedListingsSeedData,
337
+ scammerProfiles: scammersSeedData,
338
+ productFeatures: productFeaturesSeedData,
339
+ testerChecklistItems: testerChecklistSeedData,
340
+ testerChecklistResponses: testerResponsesSeedData,
341
+ };
342
+
343
+ const ALL_COLLECTIONS = Object.keys(COLLECTION_MAP);
344
+ const REPLACE_ON_LOAD = new Set(["homepageSections", "carouselSlides"]);
345
+
346
+ const collectionsToProcess = ONLY_SET
347
+ ? ALL_COLLECTIONS.filter((c) => ONLY_SET.has(c))
348
+ : ALL_COLLECTIONS;
349
+
350
+ if (ONLY_SET && collectionsToProcess.length === 0) {
351
+ console.error(`✗ No matching collections. Valid: ${ALL_COLLECTIONS.join(", ")}`);
352
+ process.exit(2);
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Generic helpers
357
+ // ---------------------------------------------------------------------------
358
+ function stripUndefined(obj) {
359
+ if (Array.isArray(obj)) return obj.map(stripUndefined);
360
+ if (obj instanceof Date) return obj;
361
+ if (obj !== null && typeof obj === "object") {
362
+ return Object.fromEntries(
363
+ Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => [k, stripUndefined(v)]),
364
+ );
365
+ }
366
+ return obj;
367
+ }
368
+
369
+ async function purgeCollection(path) {
370
+ const BATCH = 500;
371
+ let snap = await db.collection(path).limit(BATCH).get();
372
+ let removed = 0;
373
+ while (!snap.empty) {
374
+ const batch = db.batch();
375
+ snap.docs.forEach((d) => batch.delete(d.ref));
376
+ await batch.commit();
377
+ removed += snap.docs.length;
378
+ if (snap.docs.length < BATCH) break;
379
+ snap = await db.collection(path).limit(BATCH).get();
380
+ }
381
+ return removed;
382
+ }
383
+
384
+ function encryptSeedPii(collection, data) {
385
+ const PII_MAP = {
386
+ orders: ORDER_PII_FIELDS,
387
+ bids: BID_PII_FIELDS,
388
+ payouts: PAYOUT_PII_FIELDS,
389
+ reviews: REVIEW_PII_FIELDS,
390
+ products: ["sellerName", "sellerEmail"],
391
+ offers: OFFER_PII_FIELDS,
392
+ eventEntries: EVENT_ENTRY_PII_FIELDS,
393
+ };
394
+ const fields = PII_MAP[collection];
395
+ if (!fields) return data;
396
+ let result = encryptPiiFields(data, [...fields]);
397
+ if (collection === "payouts" && result.bankAccount) {
398
+ result.bankAccount = encryptPayoutBankAccount(result.bankAccount);
399
+ }
400
+ return result;
401
+ }
402
+
403
+ async function countExisting(colName) {
404
+ const seed = SEED_DATA_MAP[colName] || [];
405
+ if (seed.length === 0) return 0;
406
+
407
+ if (colName === "addresses") {
408
+ // SB-UNI-A: top-level `addresses` collection, not users/{uid}/addresses.
409
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").doc(d.id));
410
+ if (refs.length === 0) return 0;
411
+ const snaps = await db.getAll(...refs);
412
+ return snaps.filter((s) => s.exists).length;
413
+ }
414
+ if (colName === "storeAddresses") {
415
+ // Also lands in the top-level `addresses` collection (see loadStoreAddresses).
416
+ const refs = seed.filter((d) => d.id).map((d) => db.collection("addresses").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 === "couponUsage") {
422
+ const refs = seed.filter((d) => d.userId && d.couponId).map((d) =>
423
+ db.collection(USER_COLLECTION).doc(d.userId).collection("couponUsage").doc(d.couponId));
424
+ if (refs.length === 0) return 0;
425
+ const snaps = await db.getAll(...refs);
426
+ return snaps.filter((s) => s.exists).length;
427
+ }
428
+ if (colName === "wishlists" || colName === "history") {
429
+ const refs = seed.filter((d) => d.id).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id));
430
+ if (refs.length === 0) return 0;
431
+ const snaps = await db.getAll(...refs);
432
+ return snaps.filter((s) => s.exists).length;
433
+ }
434
+ if (colName === "siteSettings") {
435
+ const snap = await db.collection(COLLECTION_MAP[colName]).doc("global").get();
436
+ return snap.exists ? 1 : 0;
437
+ }
438
+ if (colName === "users") {
439
+ const refs = seed.filter((d) => d.uid).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.uid));
440
+ if (refs.length === 0) return 0;
441
+ const snaps = await db.getAll(...refs);
442
+ return snaps.filter((s) => s.exists).length;
443
+ }
444
+ if (colName === "faqs") {
445
+ // Mirrors loadGeneric: prefer the explicit id (every current faq record has
446
+ // one), only derive via generateFAQId for a record that genuinely lacks one.
447
+ const refs = seed.map((faq) => {
448
+ const id = faq.id ?? generateFAQId({ category: faq.category, question: faq.question });
449
+ return db.collection(COLLECTION_MAP[colName]).doc(id);
450
+ });
451
+ if (refs.length === 0) return 0;
452
+ const snaps = await db.getAll(...refs);
453
+ return snaps.filter((s) => s.exists).length;
454
+ }
455
+ // Mirrors loadGeneric's docId = id ?? slug resolution.
456
+ const refs = seed.filter((d) => d.id ?? d.slug).map((d) => db.collection(COLLECTION_MAP[colName]).doc(d.id ?? d.slug));
457
+ if (refs.length === 0) return 0;
458
+ const snaps = await db.getAll(...refs);
459
+ return snaps.filter((s) => s.exists).length;
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Status
464
+ // ---------------------------------------------------------------------------
465
+ async function cmdStatus() {
466
+ console.log(`\n${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)}`);
467
+ console.log("─".repeat(40));
468
+ for (const c of collectionsToProcess) {
469
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
470
+ const existing = await countExisting(c).catch((e) => { console.error(` err: ${c}: ${e.message}`); return 0; });
471
+ const marker = existing === seedCount ? "✓" : existing === 0 ? " " : "~";
472
+ console.log(`${marker} ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)}`);
473
+ }
474
+ console.log();
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Confirm prompt
479
+ // ---------------------------------------------------------------------------
480
+ async function confirm(msg) {
481
+ if (SKIP_CONFIRM) return true;
482
+ return new Promise((resolveP) => {
483
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
484
+ rl.question(`${msg} [y/N] `, (a) => { rl.close(); resolveP(/^y(es)?$/i.test(a.trim())); });
485
+ });
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Per-collection writers (mirror of /api/demo/seed POST)
490
+ // ---------------------------------------------------------------------------
491
+ async function resolveAuthConflicts(uid, authUserData) {
492
+ if (authUserData.email) {
493
+ try {
494
+ const conflict = await auth.getUserByEmail(authUserData.email);
495
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
496
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
497
+ }
498
+ if (authUserData.phoneNumber) {
499
+ try {
500
+ const conflict = await auth.getUserByPhoneNumber(authUserData.phoneNumber);
501
+ if (conflict.uid !== uid) await auth.deleteUser(conflict.uid);
502
+ } catch (e) { if (e?.code !== "auth/user-not-found") throw e; }
503
+ }
504
+ }
505
+
506
+ async function loadUsers(seed, stats) {
507
+ for (const u of seed) {
508
+ try {
509
+ const { uid, email, phoneNumber, displayName, photoURL, emailVerified, disabled } = u;
510
+ const docRef = db.collection(USER_COLLECTION).doc(uid);
511
+ let userExists = false;
512
+ try { await auth.getUser(uid); userExists = true; }
513
+ catch (e) { if (e.code !== "auth/user-not-found") throw e; }
514
+
515
+ const authUserData = { displayName, emailVerified, disabled };
516
+ if (email && typeof email === "string") authUserData.email = email;
517
+ if (phoneNumber && typeof phoneNumber === "string") authUserData.phoneNumber = phoneNumber;
518
+ // Firebase Auth's photoURL field requires an absolute URL. Seed data's
519
+ // photoURL is wrapped by seedExtMedia() into a relative /media/ext?url=...
520
+ // app-proxy path — valid for Firestore + the app's own avatar rendering,
521
+ // but auth.createUser()/updateUser() reject it with "must be a valid URL"
522
+ // and abort the WHOLE user record (Auth + Firestore both skipped) before
523
+ // ever reaching the Firestore write below. The app never reads Auth's own
524
+ // photoURL copy anyway — it always resolves avatars via the Firestore
525
+ // users doc — so just skip setting it on the Auth record for relative
526
+ // paths instead of failing the entire seed row.
527
+ if (photoURL && typeof photoURL === "string" && /^https?:\/\//.test(photoURL)) {
528
+ authUserData.photoURL = photoURL;
529
+ }
530
+
531
+ if (!userExists) {
532
+ await resolveAuthConflicts(uid, authUserData);
533
+ await auth.createUser({ uid, ...authUserData, password: "TempPass123!" });
534
+ }
535
+ if (u.role && u.role !== "user") {
536
+ await auth.setCustomUserClaims(uid, { role: u.role });
537
+ }
538
+
539
+ // Both arguments read the PLAINTEXT `plain`: indices must be hashed from
540
+ // the source text, and encryption must not see an already-hashed value.
541
+ // The previous form relied on ordering alone (indices first, THEN
542
+ // encrypt) — correct, but one line-swap away from spreading plaintext
543
+ // back over ciphertext, which is why `addPiiIndices` no longer exists.
544
+ const plain = stripUndefined({ ...u });
545
+ let docData = {
546
+ ...encryptPiiFields(plain, [...USER_PII_FIELDS]),
547
+ ...piiIndicesFor(plain, USER_PII_INDEX_MAP),
548
+ };
549
+ if (docData.payoutDetails) docData.payoutDetails = encryptPayoutDetails(docData.payoutDetails);
550
+ if (docData.shippingConfig) docData.shippingConfig = encryptShippingConfig(docData.shippingConfig);
551
+ await docRef.set(docData, { merge: true });
552
+ stats.created++;
553
+ if (VERBOSE) console.log(` + ${uid}`);
554
+ } catch (err) {
555
+ console.error(` ✗ user ${u.uid}: ${err.message}`);
556
+ stats.errors++;
557
+ }
558
+ }
559
+ }
560
+
561
+ async function loadSubcollection(seed, parentColl, parentKey, subColl, idKey, piiFields, stats) {
562
+ for (const d of seed) {
563
+ try {
564
+ const parent = d[parentKey];
565
+ const id = d[idKey];
566
+ if (!parent || !id) { stats.errors++; continue; }
567
+ const { [parentKey]: _p, [idKey]: _i, ...data } = d;
568
+ const docRef = db.collection(parentColl).doc(parent).collection(subColl).doc(id);
569
+ const payload = piiFields ? encryptPiiFields(stripUndefined(data), [...piiFields]) : stripUndefined(data);
570
+ await docRef.set(payload, { merge: true });
571
+ stats.created++;
572
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
573
+ }
574
+ }
575
+
576
+ async function loadOneDocPerUser(seed, coll, stats) {
577
+ for (const d of seed) {
578
+ try {
579
+ const { id, userId, items, updatedAt } = d;
580
+ if (!id || !userId) { stats.errors++; continue; }
581
+ await db.collection(coll).doc(id).set(
582
+ stripUndefined({ userId, items: items ?? [], updatedAt: updatedAt ?? new Date() }),
583
+ { merge: false },
584
+ );
585
+ stats.created++;
586
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
587
+ }
588
+ }
589
+
590
+ async function loadGeneric(colName, seed, firestoreCollection, stats) {
591
+ if (REPLACE_ON_LOAD.has(colName)) {
592
+ const removed = await purgeCollection(firestoreCollection);
593
+ if (VERBOSE) console.log(` purged ${removed} pre-existing docs`);
594
+ }
595
+ const items = [];
596
+ for (const docData of seed) {
597
+ let { id, ...data } = docData;
598
+ if (!id && colName === "faqs") {
599
+ id = generateFAQId({ category: docData.category, question: docData.question });
600
+ if (!id) { stats.errors++; continue; }
601
+ } else if (!id) { stats.errors++; continue; }
602
+ // `id` already carries the project's mandatory slug prefix (blog-, event-,
603
+ // faq-, etc. — see CLAUDE.md's Slug Prefix System). Preferring the bare
604
+ // `slug` field here (as this used to) wrote every blogPosts/events doc
605
+ // under its unprefixed slug instead, e.g. "auction-feature-launch"
606
+ // instead of "blog-auction-feature-launch" — found 2026-08-17 auditing
607
+ // real Firestore doc IDs after a full reseed.
608
+ const docId = id ?? docData.slug;
609
+ if (!data.slug) data.slug = docData.slug ?? docId;
610
+ items.push({
611
+ ref: db.collection(firestoreCollection).doc(docId),
612
+ data: encryptSeedPii(colName, stripUndefined(data)),
613
+ });
614
+ }
615
+ const BATCH = 500;
616
+ for (let i = 0; i < items.length; i += BATCH) {
617
+ const chunk = items.slice(i, i + BATCH);
618
+ const batch = db.batch();
619
+ for (const { ref, data } of chunk) batch.set(ref, data, { merge: true });
620
+ await batch.commit();
621
+ stats.created += chunk.length;
622
+ }
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // Load
627
+ // ---------------------------------------------------------------------------
628
+ async function cmdLoad() {
629
+ if (DRY_RUN) return cmdDryRun("load");
630
+
631
+ const stats = { created: 0, deleted: 0, skipped: 0, errors: 0 };
632
+ for (const colName of collectionsToProcess) {
633
+ const seed = SEED_DATA_MAP[colName];
634
+ if (!seed || seed.length === 0) { console.log(`▌ ${colName} — no seed data, skipping`); continue; }
635
+ console.log(`▌ ${colName} (${seed.length} docs)`);
636
+
637
+ try {
638
+ if (colName === "users") {
639
+ await loadUsers(seed, stats);
640
+ } else if (colName === "addresses") {
641
+ // SB-UNI-A (2026-05-13) unified addresses into one top-level collection
642
+ // with an ownerType/ownerId discriminator, replacing the two prior
643
+ // subcollections (users/{uid}/addresses, stores/{slug}/addresses) this
644
+ // loader used to write to. addressesSeedData already carries
645
+ // ownerType/ownerId directly — write it straight to the top-level
646
+ // collection instead of nesting under USER_COLLECTION's old subcollection.
647
+ for (const d of seed) {
648
+ try {
649
+ const { id, ownerType, ownerId, ...data } = d;
650
+ if (!id || !ownerType || !ownerId) { stats.errors++; continue; }
651
+ const payload = encryptPiiFields(stripUndefined({ ownerType, ownerId, ...data }), [...ADDRESS_PII_FIELDS]);
652
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
653
+ stats.created++;
654
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
655
+ }
656
+ } else if (colName === "storeAddresses") {
657
+ // storeAddressesSeedData predates the SB-UNI-A unification and still
658
+ // carries the old storeSlug field instead of ownerType/ownerId — derive
659
+ // the unified shape at load time rather than writing to the deleted
660
+ // stores/{slug}/addresses subcollection.
661
+ for (const d of seed) {
662
+ try {
663
+ const { id, storeSlug, ...data } = d;
664
+ if (!id || !storeSlug) { stats.errors++; continue; }
665
+ const payload = encryptPiiFields(
666
+ stripUndefined({ ownerType: "store", ownerId: storeSlug, ...data }),
667
+ [...ADDRESS_PII_FIELDS],
668
+ );
669
+ await db.collection("addresses").doc(id).set(payload, { merge: true });
670
+ stats.created++;
671
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
672
+ }
673
+ } else if (colName === "couponUsage") {
674
+ // shape: { userId, couponId, ... } — no PII
675
+ for (const d of seed) {
676
+ try {
677
+ const { userId, couponId, ...data } = d;
678
+ if (!userId || !couponId) { stats.errors++; continue; }
679
+ await db.collection(USER_COLLECTION).doc(userId).collection("couponUsage").doc(couponId)
680
+ .set(stripUndefined({ couponId, ...data }), { merge: true });
681
+ stats.created++;
682
+ } catch (err) { console.error(` ✗ ${err.message}`); stats.errors++; }
683
+ }
684
+ } else if (colName === "wishlists") {
685
+ await loadOneDocPerUser(seed, WISHLIST_COLLECTION, stats);
686
+ } else if (colName === "history") {
687
+ await loadOneDocPerUser(seed, HISTORY_COLLECTION, stats);
688
+ } else if (colName === "siteSettings") {
689
+ const settings = seed[0];
690
+ if (settings) {
691
+ await db.collection(SITE_SETTINGS_COLLECTION).doc("global").set(stripUndefined(settings), { merge: true });
692
+ stats.created++;
693
+ }
694
+ } else {
695
+ await loadGeneric(colName, seed, COLLECTION_MAP[colName], stats);
696
+ }
697
+ } catch (err) {
698
+ console.error(`✗ ${colName}: ${err.message}`);
699
+ stats.errors++;
700
+ }
701
+ }
702
+
703
+ console.log(`\n✓ Load complete — created ${stats.created}, errors ${stats.errors}\n`);
704
+ process.exit(stats.errors > 0 ? 1 : 0);
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Delete
709
+ // ---------------------------------------------------------------------------
710
+ async function cmdDelete() {
711
+ if (DRY_RUN) return cmdDryRun("delete");
712
+
713
+ if (!(await confirm(`This will purge ${collectionsToProcess.length} collection(s) in project '${process.env.FIREBASE_ADMIN_PROJECT_ID}'. Continue?`))) {
714
+ console.log("Aborted."); process.exit(0);
715
+ }
716
+
717
+ const PROTECTED_USER_UIDS = new Set(["user-admin-letitrip"]);
718
+ const stats = { deleted: 0, skipped: 0, errors: 0 };
719
+
720
+ for (const colName of collectionsToProcess) {
721
+ console.log(`▌ ${colName}`);
722
+ const seed = SEED_DATA_MAP[colName] || [];
723
+ try {
724
+ if (colName === "users") {
725
+ for (const u of seed) {
726
+ if (PROTECTED_USER_UIDS.has(u.uid)) { stats.skipped++; continue; }
727
+ try { await auth.deleteUser(u.uid); }
728
+ catch (e) { if (e?.code !== "auth/user-not-found") console.error(` auth: ${e.message}`); }
729
+ const ref = db.collection(USER_COLLECTION).doc(u.uid);
730
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
731
+ }
732
+ } else if (colName === "addresses" || colName === "storeAddresses") {
733
+ // SB-UNI-A (2026-05-13): both user and store addresses live in the
734
+ // single top-level `addresses` collection now, keyed by `id` — the
735
+ // old per-parent subcollection paths (users/{uid}/addresses,
736
+ // stores/{slug}/addresses) no longer exist, so deleting from them was
737
+ // always a silent no-op. Delete by doc id straight from `addresses`.
738
+ for (const d of seed) {
739
+ if (!d.id) { stats.skipped++; continue; }
740
+ const ref = db.collection("addresses").doc(d.id);
741
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; }
742
+ else stats.skipped++;
743
+ }
744
+ } else if (colName === "couponUsage") {
745
+ const userIds = [...new Set(seed.map((d) => d.userId).filter(Boolean))];
746
+ for (const uid of userIds) {
747
+ stats.deleted += await purgeCollection(`${USER_COLLECTION}/${uid}/couponUsage`);
748
+ }
749
+ } else if (colName === "siteSettings") {
750
+ const ref = db.collection(SITE_SETTINGS_COLLECTION).doc("global");
751
+ if ((await ref.get()).exists) { await ref.delete(); stats.deleted++; } else stats.skipped++;
752
+ } else {
753
+ stats.deleted += await purgeCollection(COLLECTION_MAP[colName]);
754
+ }
755
+ } catch (err) {
756
+ console.error(`✗ ${colName}: ${err.message}`);
757
+ stats.errors++;
758
+ }
759
+ }
760
+
761
+ console.log(`\n✓ Delete complete — removed ${stats.deleted}, errors ${stats.errors}\n`);
762
+ process.exit(stats.errors > 0 ? 1 : 0);
763
+ }
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // Dry run
767
+ // ---------------------------------------------------------------------------
768
+ async function cmdDryRun(intendedAction) {
769
+ console.log(`\nDry run (${intendedAction}) — no writes will happen.\n`);
770
+ console.log(`${"collection".padEnd(22)} ${"seed".padStart(6)} ${"in db".padStart(6)} ${"would " + (intendedAction === "load" ? "create" : "delete")}`);
771
+ console.log("─".repeat(56));
772
+ let totalCreate = 0, totalDelete = 0;
773
+ for (const c of collectionsToProcess) {
774
+ const seedCount = (SEED_DATA_MAP[c] || []).length;
775
+ const existing = await countExisting(c).catch(() => 0);
776
+ const wouldCreate = intendedAction === "load" ? Math.max(seedCount - existing, 0) : 0;
777
+ const wouldDelete = intendedAction === "delete" ? existing : 0;
778
+ totalCreate += wouldCreate; totalDelete += wouldDelete;
779
+ console.log(` ${c.padEnd(20)} ${String(seedCount).padStart(6)} ${String(existing).padStart(6)} ${String(intendedAction === "load" ? wouldCreate : wouldDelete).padStart(12)}`);
780
+ }
781
+ console.log("─".repeat(56));
782
+ console.log(`Total would ${intendedAction === "load" ? "create" : "delete"}: ${intendedAction === "load" ? totalCreate : totalDelete}\n`);
783
+ }
784
+
785
+ // ---------------------------------------------------------------------------
786
+ // Main
787
+ // ---------------------------------------------------------------------------
788
+ try {
789
+ if (ACTION === "status") await cmdStatus();
790
+ else if (ACTION === "load") await cmdLoad();
791
+ else if (ACTION === "delete") await cmdDelete();
792
+ process.exit(0);
793
+ } catch (err) {
794
+ console.error("✗ Fatal:", err.message);
795
+ if (VERBOSE) console.error(err.stack);
796
+ process.exit(1);
797
+ }