@mohasinac/appkit 4.35.0 → 4.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/checkout/actions.js +28 -22
- package/dist/_internal/server/features/orders/actions.js +58 -49
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +8 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
- package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
- package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
- package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
- package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
- package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
- package/dist/_internal/server/notifications/send-guard.js +142 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
- package/dist/_internal/shared/features/messaging/config.js +44 -0
- package/dist/_internal/shared/features/notifications/action-url.js +9 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
- package/dist/constants/api-endpoints.d.ts +30 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/admin/actions/notification-actions.d.ts +20 -0
- package/dist/features/admin/actions/notification-actions.js +43 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
- package/dist/features/admin/schemas/firestore.d.ts +32 -2
- package/dist/features/admin/schemas/firestore.js +28 -0
- package/dist/features/auth/actions/profile-actions.d.ts +14 -1
- package/dist/features/auth/actions/profile-actions.js +16 -2
- package/dist/features/auth/hooks/useAuth.d.ts +5 -0
- package/dist/features/auth/hooks/useAuth.js +42 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
- package/dist/features/contact/email.d.ts +30 -26
- package/dist/features/contact/email.js +65 -105
- package/dist/features/email/notification-templates.js +11 -0
- package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
- package/dist/features/messaging/repository/message-budget.repository.js +138 -0
- package/dist/features/messaging/schemas/firestore.d.ts +62 -0
- package/dist/features/messaging/schemas/firestore.js +36 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.d.ts +3 -0
- package/dist/features/tester/seed-data/carts-tester-seed-data.js +70 -0
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
- package/dist/seed/cart-seed-data.js +11 -0
- package/dist/seed/index.d.ts +1 -1
- package/dist/seed/index.js +1 -1
- package/dist/seed/manifest.js +2 -2
- package/dist/seed/notifications-seed-data.js +5 -1
- package/dist/seed/site-settings-seed-data.js +39 -10
- package/dist/server.d.ts +3 -5
- package/dist/server.js +27 -13
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +752 -752
package/scripts/seed-cli.mjs
CHANGED
|
@@ -1,752 +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,
|
|
189
|
-
notificationsSeedData, blogPostsSeedData,
|
|
190
|
-
carouselsSeedData, carouselSlidesSeedData, homepageSectionsSeedData,
|
|
191
|
-
siteSettingsSeedData, faqSeedData,
|
|
192
|
-
wishlistsSeedData, historySeedData,
|
|
193
|
-
groupedListingsSeedData,
|
|
194
|
-
scammersSeedData, productFeaturesSeedData,
|
|
195
|
-
testerChecklistSeedData,
|
|
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,
|
|
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,
|
|
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
|
+
}
|