/** * One-off seed script: provision 2 B2B demo accounts (DEVELOPER + PARK_OPERATOR) * and backfill ownership for a subset of existing projects / industrial parks. * Safe to re-run — uses upsert semantics. */ import { createHash } from 'node:crypto'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcrypt'; import pg from 'pg'; const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); function getRequiredEnv(name: string): string { const value = process.env[name]?.trim(); if (!value) { throw new Error(`${name} must be set before running B2B seed`); } return value; } function getBcryptRounds(): number { const raw = process.env['BCRYPT_ROUNDS'] ?? '12'; const rounds = Number.parseInt(raw, 10); if (!Number.isInteger(rounds) || rounds < 4) { throw new Error('BCRYPT_ROUNDS must be an integer >= 4'); } return rounds; } const SEED_DEFAULT_PASSWORD = getRequiredEnv('SEED_DEFAULT_PASSWORD'); const BCRYPT_ROUNDS = getBcryptRounds(); // Matches RegisterUserHandler hashing while allowing faster rounds in tests. async function hashPassword(raw: string): Promise { return bcrypt.hash(raw, BCRYPT_ROUNDS); } function hash(value: string): string { return createHash('sha256').update(value.toLowerCase().trim()).digest('hex'); } async function main() { const passwordHash = await hashPassword(SEED_DEFAULT_PASSWORD); // ── 1. DEVELOPER: CĐT Vingroup ── const developerPhone = '+84912000001'; const developerEmail = 'cdt-vingroup@goodgo.vn'; const developer = await prisma.user.upsert({ where: { phoneHash: hash(developerPhone) }, update: { role: 'DEVELOPER', isActive: true }, create: { id: 'seed-developer-001', phone: developerPhone, phoneHash: hash(developerPhone), email: developerEmail, emailHash: hash(developerEmail), passwordHash, fullName: 'CĐT Vingroup', role: 'DEVELOPER', kycStatus: 'VERIFIED', avatarUrl: 'https://ui-avatars.com/api/?name=CDT+Vingroup&background=7c3aed&color=fff', isActive: true, }, }); // Link Vingroup-led projects. const vingroupProjectIds = [ 'seed-project-001', // Vinhomes Grand Park 'seed-project-005', // Vinhomes Central Park 'seed-project-007', // Vinhomes Ocean Park 'seed-project-010', // Vinhomes Smart City ]; const vingroupRes = await prisma.projectDevelopment.updateMany({ where: { id: { in: vingroupProjectIds } }, data: { ownerId: developer.id }, }); // ── 2. DEVELOPER: CĐT Masterise Homes ── const devMasterPhone = '+84912000003'; const devMasterEmail = 'cdt-masterise@goodgo.vn'; const devMaster = await prisma.user.upsert({ where: { phoneHash: hash(devMasterPhone) }, update: { role: 'DEVELOPER', isActive: true }, create: { id: 'seed-developer-002', phone: devMasterPhone, phoneHash: hash(devMasterPhone), email: devMasterEmail, emailHash: hash(devMasterEmail), passwordHash, fullName: 'CĐT Masterise Homes', role: 'DEVELOPER', kycStatus: 'VERIFIED', avatarUrl: 'https://ui-avatars.com/api/?name=Masterise&background=6366f1&color=fff', isActive: true, }, }); const masterProjectIds = ['seed-project-002', 'seed-project-008']; // Masteri Thảo Điền + The Global City const masterRes = await prisma.projectDevelopment.updateMany({ where: { id: { in: masterProjectIds } }, data: { ownerId: devMaster.id }, }); // ── 3. PARK_OPERATOR: KCN VSIP ── const parkPhone = '+84912000002'; const parkEmail = 'kcn-vsip@goodgo.vn'; const parkOp = await prisma.user.upsert({ where: { phoneHash: hash(parkPhone) }, update: { role: 'PARK_OPERATOR', isActive: true }, create: { id: 'seed-park-operator-001', phone: parkPhone, phoneHash: hash(parkPhone), email: parkEmail, emailHash: hash(parkEmail), passwordHash, fullName: 'Vận hành KCN VSIP', role: 'PARK_OPERATOR', kycStatus: 'VERIFIED', avatarUrl: 'https://ui-avatars.com/api/?name=VSIP&background=0891b2&color=fff', isActive: true, }, }); // Link any KCN with "vsip" in the slug or name. const parks = await prisma.industrialPark.findMany({ where: { OR: [ { slug: { contains: 'vsip', mode: 'insensitive' } }, { name: { contains: 'VSIP', mode: 'insensitive' } }, ], }, select: { id: true, name: true, slug: true, ownerId: true }, }); let parkLinked = 0; if (parks.length > 0) { const res = await prisma.industrialPark.updateMany({ where: { id: { in: parks.map((p) => p.id) } }, data: { ownerId: parkOp.id }, }); parkLinked = res.count; } console.log('─── B2B seed summary ───'); console.log(`DEVELOPER: ${developer.fullName} (${developerPhone}) — linked ${vingroupRes.count} projects`); console.log(`DEVELOPER: ${devMaster.fullName} (${devMasterPhone}) — linked ${masterRes.count} projects`); console.log(`PARK_OPERATOR: ${parkOp.fullName} (${parkPhone}) — linked ${parkLinked} KCN(s)`); console.log('Password for all: configured via SEED_DEFAULT_PASSWORD'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());