feat(auth): add DEVELOPER + PARK_OPERATOR roles with owner scoping (B2B accounts)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 16s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 50s
Deploy / Build API Image (push) Failing after 25s
Deploy / Build Web Image (push) Failing after 11s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 12s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m16s
Security Scanning / Trivy Scan — Web Image (push) Failing after 1m2s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 50s
Security Scanning / Trivy Filesystem Scan (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Production (push) Has been skipped
Deploy / Rollback Staging (push) Failing after 10m50s

Two new B2B roles for CĐT (project developers) and KCN operators, provisioned by
admin. Each account owns a subset of ProjectDevelopment / IndustrialPark records
and can CRUD them from the dashboard; admin retains full access.

Phase 1 — Schema
- Extend UserRole enum with DEVELOPER + PARK_OPERATOR (before ADMIN)
- ProjectDevelopment.ownerId FK (User, ON DELETE SET NULL) + index
- IndustrialPark.ownerId FK + index
- Migration 20260420030000

Phase 2a — Backend authorization
- CreateProjectCommand + CreateIndustrialParkCommand accept ownerId; controllers
  auto-set it to the caller's user id when role=DEVELOPER / PARK_OPERATOR
- Update + Delete commands gain (requesterUserId, requesterRole) and enforce
  ADMIN-or-owner via ForbiddenException; reassigning ownerId is admin-only
- Search params gain optional ownerId filter wired through Prisma repos
- New endpoints: GET /projects/mine/list, GET /industrial/parks/mine/list
- user-rate-limit guard: add DEVELOPER + PARK_OPERATOR entries (300/window)

Phase 2b — Admin provision
- ProvisionDeveloperCommand/Handler: create user (role=DEVELOPER), pre-validate
  target projects have no existing owner, batch-assign ownerId
- ProvisionParkOperatorCommand/Handler: same for PARK_OPERATOR + IndustrialPark
- POST /admin/accounts/developers, POST /admin/accounts/park-operators (admin-only)
- DTOs with phone/password/fullName/email + optional {project,park}Ids[]

Phase 2c — Project stats for developer dashboard
- GetProjectStatsQuery + handler: aggregates linkedListingCount, activeListingCount,
  totalInquiries, unreadInquiries, savedByUsers via Property → Listing → Inquiry chain
- GET /projects/:id/stats — admin sees all, DEVELOPER only their own (403 otherwise)

Phase 3 — Frontend
- Dashboard layout role-aware: DEVELOPER sees "Dự án của tôi" + CRM + Profile (hides
  listings/analytics/subscription); PARK_OPERATOR sees "KCN của tôi" equivalent
- /projects dashboard page switches to duAnApi.searchMine() when role=DEVELOPER
- /industrial-parks page switches to industrialApi.searchMine() when role=PARK_OPERATOR
- Admin nav gains "Tài khoản CĐT" + "Tài khoản KCN" entries
- New pages /admin/accounts/developers + /admin/accounts/park-operators with
  checkbox-based multi-select for linking entities
- adminApi.provisionDeveloper + provisionParkOperator + types
- duAnApi.searchMine + getStats; industrialApi.searchMine
- Login demo accounts list includes CĐT Vingroup + KCN VSIP

Phase 4 — Seed (prisma/seed-b2b-accounts.ts)
- DEVELOPER "CĐT Vingroup" (+84912000001) owns 4 projects
- DEVELOPER "CĐT Masterise Homes" (+84912000003) owns 2 projects
- PARK_OPERATOR "Vận hành KCN VSIP" (+84912000002) owns 2 seeded KCN
- Password Velik@2026 for all

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-20 22:12:16 +07:00
parent dd3ad4aeca
commit 33a5ff407b
51 changed files with 1727 additions and 221 deletions

View File

@@ -0,0 +1,27 @@
-- Add DEVELOPER + PARK_OPERATOR roles and ownership FKs for ProjectDevelopment
-- and IndustrialPark.
--
-- Rationale: B2B accounts provisioned by admin. A DEVELOPER account owns 0..N
-- ProjectDevelopment records; a PARK_OPERATOR account owns 0..N IndustrialPark
-- records. Existing rows remain unassigned (ownerId NULL) — admin continues to
-- manage those until they're explicitly linked to a CĐT / operator.
-- 1. Extend UserRole enum. New values placed before ADMIN to keep ADMIN last.
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'DEVELOPER' BEFORE 'ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'PARK_OPERATOR' BEFORE 'ADMIN';
-- 2. ProjectDevelopment.ownerId FK to User.id (nullable, SET NULL on user delete).
ALTER TABLE "ProjectDevelopment" ADD COLUMN "ownerId" TEXT;
ALTER TABLE "ProjectDevelopment"
ADD CONSTRAINT "ProjectDevelopment_ownerId_fkey"
FOREIGN KEY ("ownerId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "ProjectDevelopment_ownerId_idx" ON "ProjectDevelopment"("ownerId");
-- 3. IndustrialPark.ownerId FK to User.id (nullable, SET NULL on user delete).
ALTER TABLE "IndustrialPark" ADD COLUMN "ownerId" TEXT;
ALTER TABLE "IndustrialPark"
ADD CONSTRAINT "IndustrialPark_ownerId_fkey"
FOREIGN KEY ("ownerId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "IndustrialPark_ownerId_idx" ON "IndustrialPark"("ownerId");

View File

@@ -21,6 +21,12 @@ enum UserRole {
BUYER
SELLER
AGENT
/// Chủ đầu tư dự án BĐS — được admin cấp tài khoản; CRUD dự án của mình và
/// xem inquiry/lead/analytics cho các dự án đó.
DEVELOPER
/// Đơn vị vận hành Khu Công Nghiệp — được admin cấp tài khoản; CRUD KCN và
/// industrial listings của mình.
PARK_OPERATOR
ADMIN
}
@@ -55,22 +61,26 @@ model User {
totpBackupCodes String[] // Bcrypt-hashed backup codes
totpEnabledAt DateTime?
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
buyerOrders Order[] @relation("BuyerOrders")
sellerOrders Order[] @relation("SellerOrders")
mfaChallenges MfaChallenge[]
transferListings TransferListing[]
reports Report[]
savedListings SavedListing[]
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
buyerOrders Order[] @relation("BuyerOrders")
sellerOrders Order[] @relation("SellerOrders")
mfaChallenges MfaChallenge[]
transferListings TransferListing[]
reports Report[]
savedListings SavedListing[]
/// Dự án BĐS do user này làm chủ đầu tư (role=DEVELOPER).
ownedProjects ProjectDevelopment[] @relation("ProjectOwner")
/// KCN do user này vận hành (role=PARK_OPERATOR).
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
@@index([role])
@@index([kycStatus])
@@ -200,10 +210,14 @@ model ProjectDevelopment {
suitableFor String[] @default([])
whyThisLocation String? @db.Text
isVerified Boolean @default(false)
/// Optional owning developer user (role=DEVELOPER). NULL for projects not
/// yet assigned to a CĐT account — admin still manages those.
ownerId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
properties Property[]
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
@@index([status])
@@index([district, city])
@@ -212,6 +226,7 @@ model ProjectDevelopment {
@@index([isVerified])
@@index([createdAt])
@@index([district, city, status])
@@index([ownerId])
}
// =============================================================================
@@ -370,11 +385,11 @@ model Listing {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
inquiries Inquiry[]
orders Order[]
transactions Transaction[]
inquiries Inquiry[]
orders Order[]
priceHistories PriceHistory[]
savedByUsers SavedListing[]
savedByUsers SavedListing[]
// --- Single-column indexes ---
@@index([status])
@@ -901,18 +916,18 @@ enum POIType {
}
model POI {
id String @id @default(cuid())
name String
type POIType
location Unsupported("geometry(Point, 4326)")
address String?
ward String?
district String
city String
osmId String? @unique
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
type POIType
location Unsupported("geometry(Point, 4326)")
address String?
ward String?
district String
city String
osmId String? @unique
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([type])
@@index([district, city])
@@ -925,14 +940,14 @@ model NeighborhoodScore {
id String @id @default(cuid())
district String
city String
educationScore Float // 0-10: schools/universities within 2km
healthcareScore Float // 0-10: hospitals/clinics within 3km
transportScore Float // 0-10: metro/bus within 1km
shoppingScore Float // 0-10: mall/market within 2km
greeneryScore Float // 0-10: parks within 1km
safetyScore Float // 0-10: police/fire stations + safety index
totalScore Float // 0-100: weighted average
poiCounts Json // { education: 12, healthcare: 5, ... }
educationScore Float // 0-10: schools/universities within 2km
healthcareScore Float // 0-10: hospitals/clinics within 3km
transportScore Float // 0-10: metro/bus within 1km
shoppingScore Float // 0-10: mall/market within 2km
greeneryScore Float // 0-10: parks within 1km
safetyScore Float // 0-10: police/fire stations + safety index
totalScore Float // 0-100: weighted average
poiCounts Json // { education: 12, healthcare: 5, ... }
calculatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1036,10 +1051,14 @@ model IndustrialPark {
description String? @db.Text
descriptionEn String? @db.Text
isVerified Boolean @default(false)
/// Optional owning operator user (role=PARK_OPERATOR). NULL for parks not
/// yet assigned to an operator account — admin still manages those.
ownerId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings IndustrialListing[]
owner User? @relation("IndustrialParkOwner", fields: [ownerId], references: [id], onDelete: SetNull)
@@index([status])
@@index([province])
@@ -1051,45 +1070,46 @@ model IndustrialPark {
@@index([landRentUsdM2Year])
@@index([region, province, status])
@@index([createdAt])
@@index([ownerId])
}
model IndustrialListing {
id String @id @default(cuid())
parkId String
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
agentId String?
sellerId String
propertyType IndustrialPropertyType
leaseType IndustrialLeaseType
status IndustrialListingStatus @default(DRAFT)
title String
description String? @db.Text
areaM2 Float
ceilingHeightM Float?
floorLoadTonM2 Float?
columnSpacingM Float?
dockCount Int?
craneCapacityTon Float?
hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false)
officeAreaM2 Float?
priceUsdM2 Float?
pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float?
managementFee Float?
depositMonths Int?
minLeaseYears Int?
maxLeaseYears Int?
leaseExpiry DateTime?
availableFrom DateTime?
powerCapacityKva Float?
waterSupplyM3Day Float?
media Json?
viewCount Int @default(0)
inquiryCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
parkId String
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
agentId String?
sellerId String
propertyType IndustrialPropertyType
leaseType IndustrialLeaseType
status IndustrialListingStatus @default(DRAFT)
title String
description String? @db.Text
areaM2 Float
ceilingHeightM Float?
floorLoadTonM2 Float?
columnSpacingM Float?
dockCount Int?
craneCapacityTon Float?
hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false)
officeAreaM2 Float?
priceUsdM2 Float?
pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float?
managementFee Float?
depositMonths Int?
minLeaseYears Int?
maxLeaseYears Int?
leaseExpiry DateTime?
availableFrom DateTime?
powerCapacityKva Float?
waterSupplyM3Day Float?
media Json?
viewCount Int @default(0)
inquiryCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parkId])
@@index([propertyType])
@@ -1116,14 +1136,14 @@ enum ConversationStatus {
}
model Conversation {
id String @id @default(cuid())
listingId String?
subject String?
status ConversationStatus @default(ACTIVE)
lastMessage String? @db.Text
id String @id @default(cuid())
listingId String?
subject String?
status ConversationStatus @default(ACTIVE)
lastMessage String? @db.Text
lastMessageAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
participants ConversationParticipant[]
messages Message[]
@@ -1175,20 +1195,20 @@ model Message {
// =============================================================================
enum TransferCategory {
FURNITURE // Nội thất (sofa, bàn, tủ, giường)
APPLIANCE // Thiết bị gia dụng (máy lạnh, tủ lạnh, máy giặt)
OFFICE_EQUIPMENT // Thiết bị văn phòng (bàn làm việc, ghế, máy in)
KITCHEN // Bếp + thiết bị bếp
PREMISES // Mặt bằng kinh doanh
FULL_UNIT // Chuyển nhượng trọn bộ (nội thất + mặt bằng)
FURNITURE // Nội thất (sofa, bàn, tủ, giường)
APPLIANCE // Thiết bị gia dụng (máy lạnh, tủ lạnh, máy giặt)
OFFICE_EQUIPMENT // Thiết bị văn phòng (bàn làm việc, ghế, máy in)
KITCHEN // Bếp + thiết bị bếp
PREMISES // Mặt bằng kinh doanh
FULL_UNIT // Chuyển nhượng trọn bộ (nội thất + mặt bằng)
}
enum TransferCondition {
NEW // Mới (< 6 tháng)
LIKE_NEW // Như mới (6-12 tháng)
GOOD // Tốt (1-3 năm)
FAIR // Khá (3-5 năm)
WORN // Cũ (> 5 năm)
NEW // Mới (< 6 tháng)
LIKE_NEW // Như mới (6-12 tháng)
GOOD // Tốt (1-3 năm)
FAIR // Khá (3-5 năm)
WORN // Cũ (> 5 năm)
}
enum TransferListingStatus {
@@ -1203,54 +1223,54 @@ enum TransferListingStatus {
}
enum TransferPricingSource {
MANUAL // Người bán tự định giá
AI_ESTIMATED // AI ước tính dựa trên khấu hao + thương hiệu
NEGOTIABLE // Giá thương lượng
MANUAL // Người bán tự định giá
AI_ESTIMATED // AI ước tính dựa trên khấu hao + thương hiệu
NEGOTIABLE // Giá thương lượng
}
model TransferListing {
id String @id @default(cuid())
sellerId String
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
category TransferCategory
status TransferListingStatus @default(DRAFT)
title String
description String? @db.Text
id String @id @default(cuid())
sellerId String
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
category TransferCategory
status TransferListingStatus @default(DRAFT)
title String
description String? @db.Text
// Location
address String
ward String?
district String
city String
location Unsupported("geometry(Point, 4326)")
address String
ward String?
district String
city String
location Unsupported("geometry(Point, 4326)")
// Pricing
askingPriceVND BigInt
askingPriceVND BigInt
aiEstimatePriceVND BigInt?
aiConfidence Float?
pricingSource TransferPricingSource @default(MANUAL)
isNegotiable Boolean @default(true)
aiConfidence Float?
pricingSource TransferPricingSource @default(MANUAL)
isNegotiable Boolean @default(true)
// Premises-specific fields (for PREMISES / FULL_UNIT)
areaM2 Float?
monthlyRentVND BigInt?
depositMonths Int?
remainingLeaseMo Int?
businessType String? // Loại hình kinh doanh hiện tại
footTraffic String? // Mô tả lưu lượng khách
areaM2 Float?
monthlyRentVND BigInt?
depositMonths Int?
remainingLeaseMo Int?
businessType String? // Loại hình kinh doanh hiện tại
footTraffic String? // Mô tả lưu lượng khách
// Metadata
media Json? // [{ url, type, order, caption }]
moderationScore Float?
moderationNotes String?
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
contactPhone String?
contactName String?
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
media Json? // [{ url, type, order, caption }]
moderationScore Float?
moderationNotes String?
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
contactPhone String?
contactName String?
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items TransferItem[]
items TransferItem[]
@@index([sellerId])
@@index([category])
@@ -1268,25 +1288,25 @@ model TransferListing {
}
model TransferItem {
id String @id @default(cuid())
transferListingId String
transferListing TransferListing @relation(fields: [transferListingId], references: [id], onDelete: Cascade)
name String // Tên sản phẩm (e.g. "Sofa góc L 3m")
brand String? // Thương hiệu
modelName String? // Model / SKU
category TransferCategory
condition TransferCondition
purchaseYear Int? // Năm mua
originalPriceVND BigInt? // Giá mua ban đầu
askingPriceVND BigInt // Giá bán mong muốn
aiEstimatePriceVND BigInt? // AI ước tính
aiConfidence Float?
quantity Int @default(1)
dimensions Json? // { widthCm, heightCm, depthCm, weightKg }
media Json? // [{ url, type, order }]
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
transferListingId String
transferListing TransferListing @relation(fields: [transferListingId], references: [id], onDelete: Cascade)
name String // Tên sản phẩm (e.g. "Sofa góc L 3m")
brand String? // Thương hiệu
modelName String? // Model / SKU
category TransferCategory
condition TransferCondition
purchaseYear Int? // Năm mua
originalPriceVND BigInt? // Giá mua ban đầu
askingPriceVND BigInt // Giá bán mong muốn
aiEstimatePriceVND BigInt? // AI ước tính
aiConfidence Float?
quantity Int @default(1)
dimensions Json? // { widthCm, heightCm, depthCm, weightKg }
media Json? // [{ url, type, order }]
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([transferListingId])
@@index([category])
@@ -1322,9 +1342,9 @@ model Report {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type ReportType
title String
params Json // Input parameters (city, province, period, etc.)
content Json? // Structured report content (sections, charts data)
pdfUrl String? // MinIO URL to generated PDF
params Json // Input parameters (city, province, period, etc.)
content Json? // Structured report content (sections, charts data)
pdfUrl String? // MinIO URL to generated PDF
status ReportStatus @default(GENERATING)
errorMsg String?
createdAt DateTime @default(now())
@@ -1338,11 +1358,11 @@ model Report {
model MacroeconomicData {
id String @id @default(cuid())
province String
indicator String // gdp, fdi, population, urbanization, labor_force, avg_wage, industrial_output, cpi, mortgage_rate
indicator String // gdp, fdi, population, urbanization, labor_force, avg_wage, industrial_output, cpi, mortgage_rate
value Float
unit String // USD, VND, %, persons, etc.
period String // e.g. "2025", "2025-Q4"
source String // GSO, World Bank, SBV
unit String // USD, VND, %, persons, etc.
period String // e.g. "2025", "2025-Q4"
source String // GSO, World Bank, SBV
createdAt DateTime @default(now())
@@unique([province, indicator, period])
@@ -1354,13 +1374,13 @@ model InfrastructureProject {
id String @id @default(cuid())
name String
province String
category String // metro, highway, airport, port, bridge, industrial_zone
status String // planning, under_construction, completed
category String // metro, highway, airport, port, bridge, industrial_zone
status String // planning, under_construction, completed
investmentVND BigInt?
startDate DateTime?
completionDate DateTime?
description String? @db.Text
impactRadius Float? // km
impactRadius Float? // km
location Unsupported("geometry(Point, 4326)")?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

145
prisma/seed-b2b-accounts.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* 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 { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const DEMO_PASSWORD = 'Velik@2026';
// Matches how RegisterUserHandler (HashedPassword.fromPlain) bcrypts, cost 12.
async function hashPassword(raw: string): Promise<string> {
return bcrypt.hash(raw, 12);
}
function hash(value: string): string {
return createHash('sha256').update(value.toLowerCase().trim()).digest('hex');
}
async function main() {
const passwordHash = await hashPassword(DEMO_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: ' + DEMO_PASSWORD);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());