Files
ERP/scripts/seed.ts
T
denis_ireri 9e3e59a804 Make email and phone unique per business, not platform-wide
One person could not work for two businesses, and signup and staff
creation told anyone whether an email or phone was registered anywhere
on the platform.

- User email and phone are now unique per business: indexes on
  (businessId, email) and (businessId, phoneNumber). Duplicate checks in
  signup and branch-manager creation are scoped to the business, so they
  no longer reveal other businesses' users.
- Login finds the email in every business and checks the password
  against each account. One match signs straight in; several return 300
  with the businesses to choose from, and the login page shows a picker.
  A wrong password returns the same 401 as before and reveals nothing;
  failures count against each candidate account, and a correct password
  only resets the accounts it opens.
- Platform-operator access now also needs a platformAdmin flag on the
  account. With per-business emails, an email match alone would have let
  any owner create a staff account with the operator's email and open the
  cross-tenant console. The flag is only set by the seed and the
  migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes.
- npm run migrate:identity flags today's operators (while each listed
  email still matches one account) and syncs the User indexes, dropping
  the old unique email_1 and phoneNumber_1. Checked against a database in
  the old shape; safe to re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:31:45 +03:00

114 lines
5.0 KiB
TypeScript

import dotenv from "dotenv";
import path from "path";
// Plain `dotenv` only auto-loads a file literally named `.env` — it does
// NOT know about Next.js's `.env.local` convention. This script runs
// standalone via tsx (outside Next.js's own env loading), so it has to
// point dotenv at `.env.local` explicitly, or every var here silently
// stays undefined even when the file exists and is filled in correctly.
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
import bcrypt from "bcryptjs";
import mongoose from "mongoose";
import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business";
import { isPlatformAdminEmail } from "../src/lib/server/platform";
/**
* Run once against a fresh database: `npm run seed`.
* Creates the first SUPER_ADMIN (email/password, from env) so there's a
* way into the single /login page at all, and seeds a default jerrycan
* size catalog. Safe to re-run — it's idempotent (skips if already
* present).
*/
async function seed() {
await connectDb();
const email = process.env.SEED_SUPER_ADMIN_EMAIL;
const password = process.env.SEED_SUPER_ADMIN_PASSWORD;
// Everything the demo seed creates belongs to one demo Business tenant.
// In the live product, businesses are created via owner self-signup at
// /signup; this seed just mirrors that shape for a fresh local DB.
const demoBusinessName = process.env.SEED_BUSINESS_NAME || "Demo Water & Eggs Co.";
if (!email || !password) {
console.error("SEED_SUPER_ADMIN_EMAIL / SEED_SUPER_ADMIN_PASSWORD not set — nothing to seed for auth.");
await mongoose.disconnect();
process.exit(0);
}
let business = await Business.findOne({ name: demoBusinessName });
// Emails are unique per business, so look in the seeded business (or, for
// a legacy single-tenant database, for its one super admin).
const existing = business
? await User.findOne({ email, businessId: business._id })
: await User.findOne({ email, role: "SUPER_ADMIN" });
// The seed is the only way (besides the identity migration) to make an
// operator: the flag is honoured only while the email is also listed in
// PLATFORM_ADMIN_EMAILS.
const platformAdmin = isPlatformAdminEmail(email);
if (existing) {
console.log(`Super admin (${email}) already exists, skipping`);
if (platformAdmin && !existing.platformAdmin) {
existing.platformAdmin = true;
await existing.save();
console.log(`Marked ${email} as a platform operator`);
}
if (!business) {
// Backfill a business for a legacy single-tenant admin if needed.
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
existing.businessId = business._id;
await existing.save();
console.log(`Linked existing super admin to new business "${demoBusinessName}"`);
}
} else {
const passwordHash = await bcrypt.hash(password, 12);
// Create the owner first with a placeholder business, then the
// business, then point them at each other — mirrors signup ordering.
const owner = new User({
name: "Super Admin",
role: "SUPER_ADMIN",
branchId: null,
phoneNumber: "254700000000",
email,
passwordHash,
platformAdmin,
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
});
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
owner.businessId = business._id;
await owner.save();
console.log(`Business "${demoBusinessName}" + super admin created (${email}) — change the password after first login`);
}
const businessId = business!._id;
const existingProducts = await Product.countDocuments({ businessId });
if (existingProducts === 0) {
await Product.insertMany([
// Water catalog
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
{ businessId, label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
// Eggs catalog
{ businessId, label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
{ businessId, label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
{ businessId, label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
]);
console.log("Seeded default product catalog (water 5L/10L/20L + eggs tray/half/single)");
} else {
console.log("Products already exist for this business, skipping catalog seed");
}
await mongoose.disconnect();
process.exit(0);
}
seed().catch((err) => {
console.error("seed failed:", err.message);
process.exit(1);
});