From 33a5ff407b89ce5c7b6eb0ec95debb6e10fe9678 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Mon, 20 Apr 2026 22:12:16 +0700 Subject: [PATCH] feat(auth): add DEVELOPER + PARK_OPERATOR roles with owner scoping (B2B accounts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/admin/admin.module.ts | 4 + .../provision-developer.command.ts | 18 + .../provision-developer.handler.ts | 103 ++++++ .../provision-park-operator.command.ts | 13 + .../provision-park-operator.handler.ts | 95 +++++ .../controllers/admin.controller.ts | 58 +++ .../dto/provision-developer.dto.ts | 41 +++ .../dto/provision-park-operator.dto.ts | 41 +++ .../create-industrial-park.command.ts | 1 + .../create-industrial-park.handler.ts | 1 + .../delete-industrial-park.command.ts | 8 +- .../delete-industrial-park.handler.ts | 10 +- .../update-industrial-park.command.ts | 7 +- .../update-industrial-park.handler.ts | 14 +- .../list-industrial-parks.handler.ts | 1 + .../list-industrial-parks.query.ts | 1 + .../domain/entities/industrial-park.entity.ts | 5 + .../industrial-park.repository.ts | 3 + .../prisma-industrial-park.repository.ts | 13 +- .../industrial-parks.controller.ts | 68 +++- .../dto/update-industrial-park.dto.ts | 5 + .../create-project/create-project.command.ts | 6 + .../create-project/create-project.handler.ts | 1 + .../delete-project/delete-project.command.ts | 8 +- .../delete-project/delete-project.handler.ts | 10 +- .../update-project/update-project.command.ts | 7 +- .../update-project/update-project.handler.ts | 14 +- .../get-project-stats.handler.ts | 89 +++++ .../get-project-stats.query.ts | 9 + .../list-projects/list-projects.handler.ts | 1 + .../list-projects/list-projects.query.ts | 2 + .../entities/project-development.entity.ts | 6 + .../project-development.repository.ts | 3 + .../prisma-project-development.repository.ts | 14 +- .../controllers/projects.controller.ts | 104 +++++- .../presentation/dto/update-project.dto.ts | 6 + .../src/modules/projects/projects.module.ts | 3 +- .../guards/user-rate-limit.guard.ts | 2 + .../admin/accounts/developers/page.tsx | 225 ++++++++++++ .../admin/accounts/park-operators/page.tsx | 215 +++++++++++ apps/web/app/[locale]/(admin)/layout.tsx | 4 + apps/web/app/[locale]/(auth)/login/page.tsx | 9 +- .../(dashboard)/industrial-parks/page.tsx | 8 +- apps/web/app/[locale]/(dashboard)/layout.tsx | 101 +++-- .../[locale]/(dashboard)/projects/page.tsx | 8 +- apps/web/lib/admin-api.ts | 32 ++ apps/web/lib/du-an-api.ts | 23 ++ apps/web/lib/khu-cong-nghiep-api.ts | 12 + .../migration.sql | 27 ++ prisma/schema.prisma | 344 +++++++++--------- prisma/seed-b2b-accounts.ts | 145 ++++++++ 51 files changed, 1727 insertions(+), 221 deletions(-) create mode 100644 apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.handler.ts create mode 100644 apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.command.ts create mode 100644 apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.handler.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/provision-developer.dto.ts create mode 100644 apps/api/src/modules/admin/presentation/dto/provision-park-operator.dto.ts create mode 100644 apps/api/src/modules/projects/application/queries/get-project-stats/get-project-stats.handler.ts create mode 100644 apps/api/src/modules/projects/application/queries/get-project-stats/get-project-stats.query.ts create mode 100644 apps/web/app/[locale]/(admin)/admin/accounts/developers/page.tsx create mode 100644 apps/web/app/[locale]/(admin)/admin/accounts/park-operators/page.tsx create mode 100644 prisma/migrations/20260420030000_add_developer_park_operator_roles_and_ownership/migration.sql create mode 100644 prisma/seed-b2b-accounts.ts diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 62de9e1..12bbd28 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -8,6 +8,8 @@ import { ApproveKycHandler } from './application/commands/approve-kyc/approve-ky import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler'; import { BanUserHandler } from './application/commands/ban-user/ban-user.handler'; import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.handler'; +import { ProvisionDeveloperHandler } from './application/commands/provision-developer/provision-developer.handler'; +import { ProvisionParkOperatorHandler } from './application/commands/provision-park-operator/provision-park-operator.handler'; import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler'; import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler'; import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler'; @@ -46,6 +48,8 @@ const CommandHandlers = [ RejectKycHandler, BulkModerateListingsHandler, UpdateAiSettingsHandler, + ProvisionDeveloperHandler, + ProvisionParkOperatorHandler, ]; const QueryHandlers = [ diff --git a/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.command.ts b/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.command.ts new file mode 100644 index 0000000..f057959 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.command.ts @@ -0,0 +1,18 @@ +/** + * Admin command: create a DEVELOPER (CĐT) user account and optionally link + * existing `ProjectDevelopment` records to that user as owner. + * + * Flow: admin picks phone/email/fullName/password, optionally an array of + * projectIds. Handler creates the user, then batch-assigns those projects' + * `ownerId`. Projects already owned by someone else are rejected. + */ +export class ProvisionDeveloperCommand { + constructor( + public readonly phone: string, + public readonly password: string, + public readonly fullName: string, + public readonly email: string | null, + /** Project ids to assign as owned by the new developer (optional). */ + public readonly projectIds: string[], + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.handler.ts b/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.handler.ts new file mode 100644 index 0000000..787d7f3 --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/provision-developer/provision-developer.handler.ts @@ -0,0 +1,103 @@ +import { ConflictException, Inject } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { PrismaService, ValidationException } from '@modules/shared'; +import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository'; +import { UserEntity } from '../../../../auth/domain/entities/user.entity'; +import { Email } from '../../../../auth/domain/value-objects/email.vo'; +import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo'; +import { Phone } from '../../../../auth/domain/value-objects/phone.vo'; +import { ProvisionDeveloperCommand } from './provision-developer.command'; + +export interface ProvisionDeveloperResult { + userId: string; + phone: string; + email: string | null; + fullName: string; + linkedProjectIds: string[]; +} + +@CommandHandler(ProvisionDeveloperCommand) +export class ProvisionDeveloperHandler + implements ICommandHandler +{ + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(cmd: ProvisionDeveloperCommand): Promise { + // Validate + hash auth fields. + const phoneResult = Phone.create(cmd.phone); + if (phoneResult.isErr) throw new ValidationException(phoneResult.unwrapErr()); + const phone = phoneResult.unwrap(); + + let email: Email | undefined; + if (cmd.email) { + const emailResult = Email.create(cmd.email); + if (emailResult.isErr) throw new ValidationException(emailResult.unwrapErr()); + email = emailResult.unwrap(); + } + + const passwordResult = await HashedPassword.fromPlain(cmd.password); + if (passwordResult.isErr) throw new ValidationException(passwordResult.unwrapErr()); + const passwordHash = passwordResult.unwrap(); + + // Uniqueness. + if (await this.userRepo.findByPhone(phone.value)) { + throw new ConflictException('Số điện thoại đã được đăng ký'); + } + if (email && (await this.userRepo.findByEmail(email.value))) { + throw new ConflictException('Email đã được đăng ký'); + } + + // Pre-validate project ownership before creating the user — avoids + // orphaning a user if any target project is already owned by someone else. + if (cmd.projectIds.length > 0) { + const rows = await this.prisma.projectDevelopment.findMany({ + where: { id: { in: cmd.projectIds } }, + select: { id: true, ownerId: true, name: true }, + }); + const byId = new Map(rows.map((r) => [r.id, r])); + const missing = cmd.projectIds.filter((id) => !byId.has(id)); + if (missing.length > 0) { + throw new ValidationException( + `Không tìm thấy dự án: ${missing.join(', ')}`, + ); + } + const occupied = rows.filter((r) => r.ownerId && r.ownerId !== null); + if (occupied.length > 0) { + throw new ConflictException( + `Các dự án đã có CĐT khác quản lý: ${occupied.map((r) => r.name).join(', ')}`, + ); + } + } + + // Create the user (role=DEVELOPER). + const user = UserEntity.createNew( + createId(), + phone, + cmd.fullName, + passwordHash, + email, + 'DEVELOPER', + ); + await this.userRepo.save(user); + + // Link projects. + if (cmd.projectIds.length > 0) { + await this.prisma.projectDevelopment.updateMany({ + where: { id: { in: cmd.projectIds } }, + data: { ownerId: user.id }, + }); + } + + return { + userId: user.id, + phone: user.phone.value, + email: user.email?.value ?? null, + fullName: user.fullName, + linkedProjectIds: cmd.projectIds, + }; + } +} diff --git a/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.command.ts b/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.command.ts new file mode 100644 index 0000000..374585a --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.command.ts @@ -0,0 +1,13 @@ +/** + * Admin command: create a PARK_OPERATOR user account and optionally link + * existing `IndustrialPark` records to that user as owner. + */ +export class ProvisionParkOperatorCommand { + constructor( + public readonly phone: string, + public readonly password: string, + public readonly fullName: string, + public readonly email: string | null, + public readonly parkIds: string[], + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.handler.ts b/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.handler.ts new file mode 100644 index 0000000..0e828ca --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/provision-park-operator/provision-park-operator.handler.ts @@ -0,0 +1,95 @@ +import { ConflictException, Inject } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { PrismaService, ValidationException } from '@modules/shared'; +import { USER_REPOSITORY, type IUserRepository } from '../../../../auth/domain/repositories/user.repository'; +import { UserEntity } from '../../../../auth/domain/entities/user.entity'; +import { Email } from '../../../../auth/domain/value-objects/email.vo'; +import { HashedPassword } from '../../../../auth/domain/value-objects/hashed-password.vo'; +import { Phone } from '../../../../auth/domain/value-objects/phone.vo'; +import { ProvisionParkOperatorCommand } from './provision-park-operator.command'; + +export interface ProvisionParkOperatorResult { + userId: string; + phone: string; + email: string | null; + fullName: string; + linkedParkIds: string[]; +} + +@CommandHandler(ProvisionParkOperatorCommand) +export class ProvisionParkOperatorHandler + implements ICommandHandler +{ + constructor( + @Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(cmd: ProvisionParkOperatorCommand): Promise { + const phoneResult = Phone.create(cmd.phone); + if (phoneResult.isErr) throw new ValidationException(phoneResult.unwrapErr()); + const phone = phoneResult.unwrap(); + + let email: Email | undefined; + if (cmd.email) { + const emailResult = Email.create(cmd.email); + if (emailResult.isErr) throw new ValidationException(emailResult.unwrapErr()); + email = emailResult.unwrap(); + } + + const passwordResult = await HashedPassword.fromPlain(cmd.password); + if (passwordResult.isErr) throw new ValidationException(passwordResult.unwrapErr()); + const passwordHash = passwordResult.unwrap(); + + if (await this.userRepo.findByPhone(phone.value)) { + throw new ConflictException('Số điện thoại đã được đăng ký'); + } + if (email && (await this.userRepo.findByEmail(email.value))) { + throw new ConflictException('Email đã được đăng ký'); + } + + if (cmd.parkIds.length > 0) { + const rows = await this.prisma.industrialPark.findMany({ + where: { id: { in: cmd.parkIds } }, + select: { id: true, ownerId: true, name: true }, + }); + const byId = new Map(rows.map((r) => [r.id, r])); + const missing = cmd.parkIds.filter((id) => !byId.has(id)); + if (missing.length > 0) { + throw new ValidationException(`Không tìm thấy KCN: ${missing.join(', ')}`); + } + const occupied = rows.filter((r) => r.ownerId && r.ownerId !== null); + if (occupied.length > 0) { + throw new ConflictException( + `Các KCN đã có đơn vị vận hành khác: ${occupied.map((r) => r.name).join(', ')}`, + ); + } + } + + const user = UserEntity.createNew( + createId(), + phone, + cmd.fullName, + passwordHash, + email, + 'PARK_OPERATOR', + ); + await this.userRepo.save(user); + + if (cmd.parkIds.length > 0) { + await this.prisma.industrialPark.updateMany({ + where: { id: { in: cmd.parkIds } }, + data: { ownerId: user.id }, + }); + } + + return { + userId: user.id, + phone: user.phone.value, + email: user.email?.value ?? null, + fullName: user.fullName, + linkedParkIds: cmd.parkIds, + }; + } +} diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index b8da743..948b81e 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -15,6 +15,10 @@ import { AdjustSubscriptionCommand } from '../../application/commands/adjust-sub import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command'; import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler'; +import { ProvisionDeveloperCommand } from '../../application/commands/provision-developer/provision-developer.command'; +import { type ProvisionDeveloperResult } from '../../application/commands/provision-developer/provision-developer.handler'; +import { ProvisionParkOperatorCommand } from '../../application/commands/provision-park-operator/provision-park-operator.command'; +import { type ProvisionParkOperatorResult } from '../../application/commands/provision-park-operator/provision-park-operator.handler'; import { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command'; import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command'; import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler'; @@ -36,6 +40,8 @@ import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto'; import { BanUserDto } from '../dto/ban-user.dto'; import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; import { GetUsersQueryDto } from '../dto/get-users-query.dto'; +import { ProvisionDeveloperDto } from '../dto/provision-developer.dto'; +import { ProvisionParkOperatorDto } from '../dto/provision-park-operator.dto'; import { RevenueStatsDto } from '../dto/revenue-stats.dto'; import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto'; import { UpdateUserStatusDto } from '../dto/update-user-status.dto'; @@ -200,6 +206,58 @@ export class AdminController { ); } + // ── B2B Account Provisioning ────────────────────────────────────── + + @Post('accounts/developers') + @ApiOperation({ + summary: 'Tạo tài khoản CĐT (DEVELOPER) — admin only', + description: + 'Tạo mới một user với role=DEVELOPER và tuỳ chọn gán quyền sở hữu các ProjectDevelopment hiện có. Dự án đã có owner khác sẽ bị từ chối.', + }) + @ApiResponse({ status: 201, description: 'Tạo tài khoản CĐT thành công' }) + @ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' }) + @ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc dự án đã có CĐT khác' }) + async provisionDeveloper( + @Body() dto: ProvisionDeveloperDto, + ): Promise { + return this.commandBus.execute( + new ProvisionDeveloperCommand( + dto.phone, + dto.password, + dto.fullName, + dto.email ?? null, + dto.projectIds ?? [], + ), + ); + } + + @Post('accounts/park-operators') + @ApiOperation({ + summary: 'Tạo tài khoản vận hành KCN (PARK_OPERATOR) — admin only', + description: + 'Tạo mới một user với role=PARK_OPERATOR và tuỳ chọn gán quyền vận hành các IndustrialPark hiện có.', + }) + @ApiResponse({ status: 201, description: 'Tạo tài khoản PARK_OPERATOR thành công' }) + @ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Yêu cầu role ADMIN' }) + @ApiResponse({ status: 409, description: 'Số điện thoại/email đã tồn tại hoặc KCN đã có đơn vị khác' }) + async provisionParkOperator( + @Body() dto: ProvisionParkOperatorDto, + ): Promise { + return this.commandBus.execute( + new ProvisionParkOperatorCommand( + dto.phone, + dto.password, + dto.fullName, + dto.email ?? null, + dto.parkIds ?? [], + ), + ); + } + // ── Audit Logs ── @Get('audit-logs') diff --git a/apps/api/src/modules/admin/presentation/dto/provision-developer.dto.ts b/apps/api/src/modules/admin/presentation/dto/provision-developer.dto.ts new file mode 100644 index 0000000..2588030 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/provision-developer.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayUnique, + IsArray, + IsEmail, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class ProvisionDeveloperDto { + @ApiProperty({ example: '+84912000001' }) + @IsString() + phone!: string; + + @ApiProperty({ example: 'Velik@2026', minLength: 8 }) + @IsString() + @MinLength(8) + password!: string; + + @ApiProperty({ example: 'CĐT Vinhomes' }) + @IsString() + fullName!: string; + + @ApiPropertyOptional({ example: 'cdt-vinhomes@goodgo.vn' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ + type: [String], + description: + 'ID các dự án sẽ gán quyền sở hữu cho CĐT này (dự án phải chưa có owner).', + example: ['seed-project-001', 'seed-project-005'], + }) + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + projectIds?: string[]; +} diff --git a/apps/api/src/modules/admin/presentation/dto/provision-park-operator.dto.ts b/apps/api/src/modules/admin/presentation/dto/provision-park-operator.dto.ts new file mode 100644 index 0000000..e0a62fd --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/provision-park-operator.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayUnique, + IsArray, + IsEmail, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class ProvisionParkOperatorDto { + @ApiProperty({ example: '+84912000002' }) + @IsString() + phone!: string; + + @ApiProperty({ example: 'Velik@2026', minLength: 8 }) + @IsString() + @MinLength(8) + password!: string; + + @ApiProperty({ example: 'Vận hành KCN VSIP' }) + @IsString() + fullName!: string; + + @ApiPropertyOptional({ example: 'kcn-vsip@goodgo.vn' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ + type: [String], + description: + 'ID các KCN sẽ gán quyền vận hành cho user này (KCN phải chưa có owner).', + example: ['seed-park-001'], + }) + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + parkIds?: string[]; +} diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts index 57e1b18..1e60dd7 100644 --- a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.command.ts @@ -30,5 +30,6 @@ export class CreateIndustrialParkCommand { public readonly targetIndustries: string[], public readonly description: string | null, public readonly descriptionEn: string | null, + public readonly ownerId: string | null = null, ) {} } diff --git a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts index 9bca462..2012c89 100644 --- a/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts +++ b/apps/api/src/modules/industrial/application/commands/create-industrial-park/create-industrial-park.handler.ts @@ -59,6 +59,7 @@ export class CreateIndustrialParkHandler implements ICommandHandler { @@ -71,6 +72,7 @@ export class IndustrialParkEntity extends AggregateRoot { private _description: string | null; private _descriptionEn: string | null; private _isVerified: boolean; + private _ownerId: string | null; constructor(id: string, props: IndustrialParkProps, createdAt: Date, updatedAt: Date) { super(id, createdAt, updatedAt); @@ -107,6 +109,7 @@ export class IndustrialParkEntity extends AggregateRoot { this._description = props.description; this._descriptionEn = props.descriptionEn; this._isVerified = props.isVerified; + this._ownerId = props.ownerId; } get name() { return this._name; } @@ -142,6 +145,7 @@ export class IndustrialParkEntity extends AggregateRoot { get description() { return this._description; } get descriptionEn() { return this._descriptionEn; } get isVerified() { return this._isVerified; } + get ownerId() { return this._ownerId; } updateDetails(props: Partial): void { if (props.name !== undefined) this._name = props.name; @@ -167,6 +171,7 @@ export class IndustrialParkEntity extends AggregateRoot { if (props.description !== undefined) this._description = props.description; if (props.descriptionEn !== undefined) this._descriptionEn = props.descriptionEn; if (props.isVerified !== undefined) this._isVerified = props.isVerified; + if (props.ownerId !== undefined) this._ownerId = props.ownerId; this.updatedAt = new Date(); } } diff --git a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts index 6c71ea3..1ebb57e 100644 --- a/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/domain/repositories/industrial-park.repository.ts @@ -11,6 +11,7 @@ export interface IndustrialParkSearchParams { minAreaHa?: number; maxRentUsdM2?: number; targetIndustry?: string; + ownerId?: string; page?: number; limit?: number; } @@ -42,6 +43,7 @@ export interface IndustrialParkListItem { targetIndustries: string[]; latitude: number; longitude: number; + ownerId: string | null; } export interface IndustrialParkDetailData { @@ -79,6 +81,7 @@ export interface IndustrialParkDetailData { description: string | null; descriptionEn: string | null; isVerified: boolean; + ownerId: string | null; listingCount: number; createdAt: Date; updatedAt: Date; diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts index 2adbc60..f2856c0 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -71,7 +71,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository "landRentUsdM2Year", "rbfRentUsdM2Month", "rbwRentUsdM2Month", "managementFeeUsd", infrastructure, connectivity, incentives, "targetIndustries", "existingTenants", certifications, media, documents, - description, "descriptionEn", "isVerified", "createdAt", "updatedAt" + description, "descriptionEn", "isVerified", "ownerId", "createdAt", "updatedAt" ) VALUES ( ${entity.id}, ${entity.name}, ${entity.nameEn}, ${entity.slug}, ${entity.developer}, ${entity.operator}, ${entity.status}::"IndustrialParkStatus", @@ -90,7 +90,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository ${entity.media ? JSON.stringify(entity.media) : null}::jsonb, ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb, ${entity.description}, ${entity.descriptionEn}, - ${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt} + ${entity.isVerified}, ${entity.ownerId}, ${entity.createdAt}, ${entity.updatedAt} ) `; } @@ -118,6 +118,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository "targetIndustries" = ${entity.targetIndustries}::text[], description = ${entity.description}, "descriptionEn" = ${entity.descriptionEn}, "isVerified" = ${entity.isVerified}, + "ownerId" = ${entity.ownerId}, "updatedAt" = ${entity.updatedAt} WHERE id = ${entity.id} `; @@ -156,6 +157,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository conditions.push(`$${paramIndex++} = ANY("targetIndustries")`); values.push(params.targetIndustry); } + if (params.ownerId) { + conditions.push(`"ownerId" = $${paramIndex++}`); + values.push(params.ownerId); + } if (params.query) { conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`); values.push(`%${params.query}%`); @@ -306,6 +311,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository description: row.description, descriptionEn: row.descriptionEn, isVerified: row.isVerified, + ownerId: row.ownerId, }, row.createdAt, row.updatedAt, @@ -332,6 +338,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository targetIndustries: row.targetIndustries ?? [], latitude: Number(row.lat), longitude: Number(row.lng), + ownerId: row.ownerId, }; } @@ -371,6 +378,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository description: row.description, descriptionEn: row.descriptionEn, isVerified: row.isVerified, + ownerId: row.ownerId, listingCount: row.listingCount ?? 0, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -414,6 +422,7 @@ interface RawPark { description: string | null; descriptionEn: string | null; isVerified: boolean; + ownerId: string | null; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts index 8a97d11..91b1229 100644 --- a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; -import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import { CurrentUser, JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import type { JwtPayload } from '@modules/auth'; import { NotFoundException } from '@modules/shared'; import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query'; import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command'; @@ -50,6 +51,39 @@ export class IndustrialParksController { ); } + // ── Park Operator endpoints ─────────────────────────────────────── + + @ApiOperation({ + summary: 'KCN của tôi (Park Operator)', + description: 'Danh sách KCN mà user hiện tại vận hành. ADMIN dùng endpoint này để xem KCN đã được gán cho mình.', + }) + @ApiResponse({ status: 200, description: 'Danh sách KCN đã lọc theo owner' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.PARK_OPERATOR, UserRole.ADMIN) + @Get('parks/mine/list') + async listMyParks( + @CurrentUser() user: JwtPayload, + @Query() dto: SearchIndustrialParksDto, + ) { + return this.queryBus.execute( + new ListIndustrialParksQuery( + dto.q, + dto.province, + dto.region, + dto.status, + dto.minAreaHa, + dto.maxRentUsdM2, + dto.targetIndustry, + dto.page ?? 1, + dto.limit ?? 20, + user.sub, + ), + ); + } + @ApiOperation({ summary: 'Chi tiết KCN', description: 'Xem chi tiết KCN theo slug hoặc ID' }) @ApiResponse({ status: 200, description: 'Thông tin chi tiết KCN' }) @ApiResponse({ status: 404, description: 'Không tìm thấy KCN' }) @@ -124,9 +158,14 @@ export class IndustrialParksController { @ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR) @Post('parks') - async createPark(@Body() dto: CreateIndustrialParkDto) { + async createPark( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateIndustrialParkDto, + ) { + // PARK_OPERATOR callers own what they create; admin may leave unassigned. + const ownerId = user.role === UserRole.PARK_OPERATOR ? user.sub : null; return this.commandBus.execute( new CreateIndustrialParkCommand( dto.name, @@ -157,6 +196,7 @@ export class IndustrialParksController { dto.targetIndustries, dto.description ?? null, dto.descriptionEn ?? null, + ownerId, ), ); } @@ -168,12 +208,18 @@ export class IndustrialParksController { @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR) @Patch('parks/:id') - async updatePark(@Param('id') id: string, @Body() dto: UpdateIndustrialParkDto) { + async updatePark( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateIndustrialParkDto, + ) { return this.commandBus.execute( new UpdateIndustrialParkCommand( id, + user.sub, + user.role as UserRole, dto.name, dto.nameEn, dto.developer, @@ -193,6 +239,7 @@ export class IndustrialParksController { dto.description, dto.descriptionEn, dto.isVerified, + dto.ownerId, ), ); } @@ -204,10 +251,15 @@ export class IndustrialParksController { @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR) @Delete('parks/:id') - async deletePark(@Param('id') id: string): Promise<{ success: true }> { - await this.commandBus.execute(new DeleteIndustrialParkCommand(id)); + async deletePark( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + ): Promise<{ success: true }> { + await this.commandBus.execute( + new DeleteIndustrialParkCommand(id, user.sub, user.role as UserRole), + ); return { success: true }; } } diff --git a/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts b/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts index be29ee8..5c78a4e 100644 --- a/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts +++ b/apps/api/src/modules/industrial/presentation/dto/update-industrial-park.dto.ts @@ -120,4 +120,9 @@ export class UpdateIndustrialParkDto { @IsOptional() @IsBoolean() isVerified?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + ownerId?: string | null; } diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts index c8bb6b9..885ff71 100644 --- a/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.command.ts @@ -29,5 +29,11 @@ export class CreateProjectCommand { public readonly completionDate: Date | null, public readonly suitableFor: string[] = [], public readonly whyThisLocation: string | null = null, + /** + * Owner of the new project. Admin can pass any user id (e.g. when + * provisioning a project on behalf of a CĐT). DEVELOPER callers are + * forced to their own id. Null = unassigned (admin-managed). + */ + public readonly ownerId: string | null = null, ) {} } diff --git a/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts index 150ff96..f860992 100644 --- a/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts +++ b/apps/api/src/modules/projects/application/commands/create-project/create-project.handler.ts @@ -57,6 +57,7 @@ export class CreateProjectHandler implements ICommandHandler +{ + constructor( + @Inject(PROJECT_REPOSITORY) + private readonly projectRepo: IProjectRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetProjectStatsQuery): Promise { + const project = await this.projectRepo.findById(query.projectId); + if (!project) { + throw new NotFoundException('Dự án', query.projectId); + } + + if (query.requesterRole !== 'ADMIN') { + if ( + query.requesterRole !== 'DEVELOPER' || + project.ownerId !== query.requesterUserId + ) { + throw new ForbiddenException('Bạn không có quyền xem thống kê dự án này'); + } + } + + // Single-round-trip aggregate pulling listing + inquiry + savedListing + // counts via the Property link. + const rows = await this.prisma.$queryRaw` + SELECT + COUNT(DISTINCT l.id) FILTER (WHERE l.id IS NOT NULL) AS linked, + COUNT(DISTINCT l.id) FILTER (WHERE l.status = 'APPROVED') AS active, + COUNT(DISTINCT i.id) FILTER (WHERE i.id IS NOT NULL) AS inquiries, + COUNT(DISTINCT i.id) FILTER (WHERE i.id IS NOT NULL AND i."isRead" = FALSE) AS unread, + COUNT(DISTINCT sl."userId") FILTER (WHERE sl."userId" IS NOT NULL) AS saves + FROM "Property" p + LEFT JOIN "Listing" l ON l."propertyId" = p.id + LEFT JOIN "Inquiry" i ON i."listingId" = l.id + LEFT JOIN "SavedListing" sl ON sl."listingId" = l.id + WHERE p."projectDevelopmentId" = ${query.projectId} + `; + + const row = rows[0] ?? { + linked: BigInt(0), + active: BigInt(0), + inquiries: BigInt(0), + unread: BigInt(0), + saves: BigInt(0), + }; + + return { + projectId: query.projectId, + linkedListingCount: Number(row.linked), + activeListingCount: Number(row.active), + totalInquiries: Number(row.inquiries), + unreadInquiries: Number(row.unread), + savedByUsers: Number(row.saves), + }; + } +} diff --git a/apps/api/src/modules/projects/application/queries/get-project-stats/get-project-stats.query.ts b/apps/api/src/modules/projects/application/queries/get-project-stats/get-project-stats.query.ts new file mode 100644 index 0000000..b38e782 --- /dev/null +++ b/apps/api/src/modules/projects/application/queries/get-project-stats/get-project-stats.query.ts @@ -0,0 +1,9 @@ +import type { UserRole } from '@prisma/client'; + +export class GetProjectStatsQuery { + constructor( + public readonly projectId: string, + public readonly requesterUserId: string, + public readonly requesterRole: UserRole, + ) {} +} diff --git a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts index c79773a..b86af8d 100644 --- a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts +++ b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.handler.ts @@ -23,6 +23,7 @@ export class ListProjectsHandler implements IQueryHandler { district: query.district, developer: query.developer, isVerified: query.isVerified, + ownerId: query.ownerId, page: query.page, limit: query.limit, }); diff --git a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts index 4d98bf4..67ed3fd 100644 --- a/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts +++ b/apps/api/src/modules/projects/application/queries/list-projects/list-projects.query.ts @@ -10,5 +10,7 @@ export class ListProjectsQuery { public readonly isVerified: boolean | undefined, public readonly page: number, public readonly limit: number, + /** When set, restrict results to projects owned by this user id. */ + public readonly ownerId?: string, ) {} } diff --git a/apps/api/src/modules/projects/domain/entities/project-development.entity.ts b/apps/api/src/modules/projects/domain/entities/project-development.entity.ts index 1107749..cddbc93 100644 --- a/apps/api/src/modules/projects/domain/entities/project-development.entity.ts +++ b/apps/api/src/modules/projects/domain/entities/project-development.entity.ts @@ -33,6 +33,8 @@ export interface ProjectDevelopmentProps { suitableFor: string[]; whyThisLocation: string | null; isVerified: boolean; + /** Owning DEVELOPER user id; null when not yet assigned (admin-managed). */ + ownerId: string | null; } export class ProjectDevelopmentEntity extends AggregateRoot { @@ -67,6 +69,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot { private _suitableFor: string[]; private _whyThisLocation: string | null; private _isVerified: boolean; + private _ownerId: string | null; constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) { super(id, createdAt, updatedAt); @@ -101,6 +104,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot { this._suitableFor = props.suitableFor; this._whyThisLocation = props.whyThisLocation; this._isVerified = props.isVerified; + this._ownerId = props.ownerId; } get name() { return this._name; } @@ -134,6 +138,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot { get suitableFor() { return this._suitableFor; } get whyThisLocation() { return this._whyThisLocation; } get isVerified() { return this._isVerified; } + get ownerId() { return this._ownerId; } updateDetails(props: Partial): void { if (props.name !== undefined) this._name = props.name; @@ -160,6 +165,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot { if (props.suitableFor !== undefined) this._suitableFor = props.suitableFor; if (props.whyThisLocation !== undefined) this._whyThisLocation = props.whyThisLocation; if (props.isVerified !== undefined) this._isVerified = props.isVerified; + if (props.ownerId !== undefined) this._ownerId = props.ownerId; this.updatedAt = new Date(); } } diff --git a/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts index 780f675..0873a0e 100644 --- a/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts +++ b/apps/api/src/modules/projects/domain/repositories/project-development.repository.ts @@ -10,6 +10,8 @@ export interface ProjectSearchParams { district?: string; developer?: string; isVerified?: boolean; + /** When set, restrict results to projects owned by this user id. */ + ownerId?: string; page?: number; limit?: number; } @@ -42,6 +44,7 @@ export interface ProjectListItem { suitableFor: string[]; whyThisLocation: string | null; isVerified: boolean; + ownerId: string | null; latitude: number; longitude: number; propertyCount: number; diff --git a/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts index af43b6d..c9e591a 100644 --- a/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts +++ b/apps/api/src/modules/projects/infrastructure/repositories/prisma-project-development.repository.ts @@ -68,7 +68,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { location, address, ward, district, city, "minPrice", "maxPrice", "pricePerM2Range", "totalArea", "buildingCount", "floorCount", "unitTypes", media, documents, - tags, "suitableFor", "whyThisLocation", "isVerified", "createdAt", "updatedAt" + tags, "suitableFor", "whyThisLocation", "isVerified", "ownerId", + "createdAt", "updatedAt" ) VALUES ( ${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer}, ${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits}, @@ -88,7 +89,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { ${entity.tags}::text[], ${entity.suitableFor}::text[], ${entity.whyThisLocation}, - ${entity.isVerified}, ${entity.createdAt}, ${entity.updatedAt} + ${entity.isVerified}, ${entity.ownerId}, + ${entity.createdAt}, ${entity.updatedAt} ) `; } @@ -119,6 +121,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { "suitableFor" = ${entity.suitableFor}::text[], "whyThisLocation" = ${entity.whyThisLocation}, "isVerified" = ${entity.isVerified}, + "ownerId" = ${entity.ownerId}, "updatedAt" = ${entity.updatedAt} WHERE id = ${entity.id} `; @@ -153,6 +156,10 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { conditions.push(`"isVerified" = $${paramIndex++}`); values.push(params.isVerified); } + if (params.ownerId) { + conditions.push(`"ownerId" = $${paramIndex++}`); + values.push(params.ownerId); + } if (params.query) { conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city ILIKE $${paramIndex})`); values.push(`%${params.query}%`); @@ -223,6 +230,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { suitableFor: row.suitableFor ?? [], whyThisLocation: row.whyThisLocation, isVerified: row.isVerified, + ownerId: row.ownerId, }, row.createdAt, row.updatedAt, @@ -250,6 +258,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository { suitableFor: row.suitableFor ?? [], whyThisLocation: row.whyThisLocation, isVerified: row.isVerified, + ownerId: row.ownerId, latitude: Number(row.lat), longitude: Number(row.lng), propertyCount: row.propertyCount ?? 0, @@ -309,6 +318,7 @@ interface RawProject { suitableFor: string[] | null; whyThisLocation: string | null; isVerified: boolean; + ownerId: string | null; createdAt: Date; updatedAt: Date; } diff --git a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts index 7bb24f6..916944d 100644 --- a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts +++ b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts @@ -2,12 +2,15 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UserRole } from '@prisma/client'; -import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import { CurrentUser, JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import type { JwtPayload } from '@modules/auth'; import { NotFoundException } from '@modules/shared'; import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command'; import { DeleteProjectCommand } from '../../application/commands/delete-project/delete-project.command'; import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command'; import { GetProjectQuery } from '../../application/queries/get-project/get-project.query'; +import { type ProjectStats } from '../../application/queries/get-project-stats/get-project-stats.handler'; +import { GetProjectStatsQuery } from '../../application/queries/get-project-stats/get-project-stats.query'; import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query'; import { CreateProjectDto } from '../dto/create-project.dto'; import { SearchProjectsDto } from '../dto/search-projects.dto'; @@ -78,6 +81,64 @@ export class ProjectsController { return { ...result, data: result.data.map(shapeProject) }; } + // ── Developer (CĐT) endpoints ───────────────────────────────────── + + @ApiOperation({ + summary: 'Dự án của tôi (CĐT)', + description: 'Danh sách dự án mà user hiện tại là chủ đầu tư. ADMIN dùng endpoint này để xem tất cả dự án mình đã được gán.', + }) + @ApiResponse({ status: 200, description: 'Danh sách dự án đã lọc theo owner' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.DEVELOPER, UserRole.ADMIN) + @Get('mine/list') + async listMyProjects( + @CurrentUser() user: JwtPayload, + @Query() dto: SearchProjectsDto, + ) { + const result = await this.queryBus.execute< + ListProjectsQuery, + { data: RawProjectListItem[]; total: number; page: number; limit: number; totalPages: number } + >( + new ListProjectsQuery( + dto.q, + dto.status, + dto.city, + dto.district, + dto.developer, + dto.isVerified, + dto.page ?? 1, + dto.limit ?? 20, + user.sub, + ), + ); + return { ...result, data: result.data.map(shapeProject) }; + } + + @ApiOperation({ + summary: 'Thống kê dự án (CĐT / admin)', + description: + 'Trả về số listings, inquiries (chưa đọc), users đã lưu. Admin xem được mọi dự án; DEVELOPER chỉ xem được dự án của mình.', + }) + @ApiResponse({ status: 200, description: 'Thống kê dự án' }) + @ApiResponse({ status: 401, description: 'Chưa xác thực' }) + @ApiResponse({ status: 403, description: 'Không có quyền' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy dự án' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.DEVELOPER, UserRole.ADMIN) + @Get(':id/stats') + async getProjectStats( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + ): Promise { + return this.queryBus.execute( + new GetProjectStatsQuery(id, user.sub, user.role as UserRole), + ); + } + @ApiOperation({ summary: 'Chi tiết dự án', description: 'Xem chi tiết dự án theo slug hoặc ID' }) @ApiResponse({ status: 200, description: 'Thông tin chi tiết dự án' }) @ApiResponse({ status: 404, description: 'Không tìm thấy dự án' }) @@ -94,16 +155,22 @@ export class ProjectsController { // ── Admin endpoints ─────────────────────────────────────────────── - @ApiOperation({ summary: 'Tạo dự án (admin)', description: 'Tạo mới dự án bất động sản' }) + @ApiOperation({ summary: 'Tạo dự án', description: 'Admin tạo dự án tuỳ ý; CĐT (DEVELOPER) tạo dự án của mình (tự động gán ownerId).' }) @ApiResponse({ status: 201, description: 'Dự án đã tạo' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.DEVELOPER) @Post() - async createProject(@Body() dto: CreateProjectDto) { + async createProject( + @CurrentUser() user: JwtPayload, + @Body() dto: CreateProjectDto, + ) { + // DEVELOPER callers own what they create; admin may pass ownerId in DTO + // (not yet exposed — use PATCH to reassign) or leave unassigned. + const ownerId = user.role === UserRole.DEVELOPER ? user.sub : null; return this.commandBus.execute( new CreateProjectCommand( dto.name, @@ -133,23 +200,30 @@ export class ProjectsController { dto.completionDate ? new Date(dto.completionDate) : null, dto.suitableFor ?? [], dto.whyThisLocation ?? null, + ownerId, ), ); } - @ApiOperation({ summary: 'Cập nhật dự án (admin)', description: 'Cập nhật thông tin dự án' }) + @ApiOperation({ summary: 'Cập nhật dự án', description: 'Admin cập nhật bất kỳ dự án nào; CĐT (DEVELOPER) chỉ cập nhật dự án mình làm chủ.' }) @ApiResponse({ status: 200, description: 'Dự án đã cập nhật' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.DEVELOPER) @Patch(':id') - async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) { + async updateProject( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateProjectDto, + ) { return this.commandBus.execute( new UpdateProjectCommand( id, + user.sub, + user.role as UserRole, dto.name, dto.developer, dto.developerLogo, @@ -174,21 +248,27 @@ export class ProjectsController { dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined, dto.suitableFor, dto.whyThisLocation, + dto.ownerId, ), ); } - @ApiOperation({ summary: 'Xóa dự án (admin)', description: 'Xóa vĩnh viễn dự án bất động sản' }) - @ApiResponse({ status: 200, description: 'Dự án đã xóa' }) + @ApiOperation({ summary: 'Xoá dự án', description: 'Admin xoá bất kỳ dự án nào; CĐT (DEVELOPER) chỉ xoá dự án của mình.' }) + @ApiResponse({ status: 200, description: 'Dự án đã xoá' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.ADMIN) + @Roles(UserRole.ADMIN, UserRole.DEVELOPER) @Delete(':id') - async deleteProject(@Param('id') id: string): Promise<{ success: true }> { - await this.commandBus.execute(new DeleteProjectCommand(id)); + async deleteProject( + @CurrentUser() user: JwtPayload, + @Param('id') id: string, + ): Promise<{ success: true }> { + await this.commandBus.execute( + new DeleteProjectCommand(id, user.sub, user.role as UserRole), + ); return { success: true }; } } diff --git a/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts index 0b089bc..d5e9dc5 100644 --- a/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts +++ b/apps/api/src/modules/projects/presentation/dto/update-project.dto.ts @@ -55,4 +55,10 @@ export class UpdateProjectDto { @ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean; @ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null; @ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: string | null; + + @ApiPropertyOptional({ + description: + 'Gán / đổi / gỡ CĐT (DEVELOPER user id). Chỉ admin được đổi; null để gỡ.', + }) + @IsOptional() @IsString() ownerId?: string | null; } diff --git a/apps/api/src/modules/projects/projects.module.ts b/apps/api/src/modules/projects/projects.module.ts index c15d9a2..17812c3 100644 --- a/apps/api/src/modules/projects/projects.module.ts +++ b/apps/api/src/modules/projects/projects.module.ts @@ -4,13 +4,14 @@ import { CreateProjectHandler } from './application/commands/create-project/crea import { DeleteProjectHandler } from './application/commands/delete-project/delete-project.handler'; import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler'; import { GetProjectHandler } from './application/queries/get-project/get-project.handler'; +import { GetProjectStatsHandler } from './application/queries/get-project-stats/get-project-stats.handler'; import { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler'; import { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository'; import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository'; import { ProjectsController } from './presentation/controllers/projects.controller'; const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler, DeleteProjectHandler]; -const QueryHandlers = [GetProjectHandler, ListProjectsHandler]; +const QueryHandlers = [GetProjectHandler, ListProjectsHandler, GetProjectStatsHandler]; @Module({ imports: [CqrsModule], diff --git a/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts index d028dd0..210f45a 100644 --- a/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts +++ b/apps/api/src/modules/shared/infrastructure/guards/user-rate-limit.guard.ts @@ -18,6 +18,8 @@ export const DEFAULT_ROLE_LIMITS: Record = { BUYER: 100, SELLER: 150, AGENT: 200, + DEVELOPER: 300, + PARK_OPERATOR: 300, ADMIN: 500, }; diff --git a/apps/web/app/[locale]/(admin)/admin/accounts/developers/page.tsx b/apps/web/app/[locale]/(admin)/admin/accounts/developers/page.tsx new file mode 100644 index 0000000..c50f854 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/accounts/developers/page.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Building2, Check, Loader2 } from 'lucide-react'; +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + adminApi, + type ProvisionAccountResult, + type ProvisionDeveloperPayload, +} from '@/lib/admin-api'; +import { ApiError } from '@/lib/api-client'; +import { + duAnApi, + PROJECT_STATUS_LABELS, + type ProjectSummary, +} from '@/lib/du-an-api'; + +/** + * Admin → Provision a CĐT (DEVELOPER) account and link existing projects + * whose `ownerId` is currently unassigned. + */ +export default function AdminProvisionDeveloperPage() { + const [form, setForm] = React.useState({ + phone: '', + password: '', + fullName: '', + email: '', + projectIds: [], + }); + const [success, setSuccess] = React.useState(null); + const [error, setError] = React.useState(null); + + // List unassigned projects (status PLANNING or UNDER_CONSTRUCTION or any — admin + // picks). We show all projects here; the backend rejects ones already owned. + const { data: projectsResp } = useQuery({ + queryKey: ['admin-projects-all'], + queryFn: () => duAnApi.search({ limit: 100 }), + staleTime: 60_000, + }); + + const provision = useMutation({ + mutationFn: (payload: ProvisionDeveloperPayload) => adminApi.provisionDeveloper(payload), + onSuccess: (res) => { + setSuccess(res); + setError(null); + setForm({ phone: '', password: '', fullName: '', email: '', projectIds: [] }); + }, + onError: (err) => { + setError(err instanceof ApiError ? err.message : 'Có lỗi xảy ra'); + setSuccess(null); + }, + }); + + const toggleProject = (id: string) => { + setForm((f) => ({ + ...f, + projectIds: f.projectIds?.includes(id) + ? f.projectIds.filter((x) => x !== id) + : [...(f.projectIds ?? []), id], + })); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + provision.mutate({ + phone: form.phone.trim(), + password: form.password, + fullName: form.fullName.trim(), + email: form.email?.trim() || undefined, + projectIds: form.projectIds && form.projectIds.length > 0 ? form.projectIds : undefined, + }); + }; + + const projects: ProjectSummary[] = projectsResp?.data ?? []; + + return ( +
+
+

Tạo tài khoản CĐT (Chủ đầu tư)

+

+ Cấp quyền truy cập dashboard cho một chủ đầu tư. Có thể gán ngay các dự án họ + sở hữu (các dự án đã có CĐT khác sẽ bị từ chối). +

+
+ + {success && ( +
+

+ + + Đã tạo tài khoản {success.fullName} ({success.phone}). + {success.linkedProjectIds && success.linkedProjectIds.length > 0 && ( + <> Gán cho {success.linkedProjectIds.length} dự án. + )} + +

+
+ )} + + {error && ( +
+ {error} + +
+ )} + +
+ + + Thông tin tài khoản + + +
+ + setForm({ ...form, fullName: e.target.value })} + required + /> +
+
+ + setForm({ ...form, phone: e.target.value })} + required + /> +
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + minLength={8} + /> +

+ CĐT đăng nhập lần đầu với mật khẩu này; khuyến nghị đổi sau khi nhận + tài khoản. +

+
+
+
+ + + + Gán dự án (tuỳ chọn) + + +

+ Chọn các dự án sẽ giao cho CĐT này quản lý. Dự án đã có owner khác sẽ bị + backend từ chối khi submit. +

+
+ {projects.length === 0 && ( +

Chưa có dự án nào.

+ )} + {projects.map((p) => { + const checked = form.projectIds?.includes(p.id) ?? false; + return ( + + ); + })} +
+ {form.projectIds && form.projectIds.length > 0 && ( +

+ Đã chọn {form.projectIds.length} dự án. +

+ )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/admin/accounts/park-operators/page.tsx b/apps/web/app/[locale]/(admin)/admin/accounts/park-operators/page.tsx new file mode 100644 index 0000000..8d8ff1b --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/accounts/park-operators/page.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Check, Factory, Loader2 } from 'lucide-react'; +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + adminApi, + type ProvisionAccountResult, + type ProvisionParkOperatorPayload, +} from '@/lib/admin-api'; +import { ApiError } from '@/lib/api-client'; +import { + industrialApi, + PARK_STATUS_LABELS, + type IndustrialParkListItem, +} from '@/lib/khu-cong-nghiep-api'; + +/** + * Admin → Provision a PARK_OPERATOR account and link existing KCNs to it. + */ +export default function AdminProvisionParkOperatorPage() { + const [form, setForm] = React.useState({ + phone: '', + password: '', + fullName: '', + email: '', + parkIds: [], + }); + const [success, setSuccess] = React.useState(null); + const [error, setError] = React.useState(null); + + const { data: parksResp } = useQuery({ + queryKey: ['admin-parks-all'], + queryFn: () => industrialApi.search({ limit: 100 }), + staleTime: 60_000, + }); + + const provision = useMutation({ + mutationFn: (payload: ProvisionParkOperatorPayload) => + adminApi.provisionParkOperator(payload), + onSuccess: (res) => { + setSuccess(res); + setError(null); + setForm({ phone: '', password: '', fullName: '', email: '', parkIds: [] }); + }, + onError: (err) => { + setError(err instanceof ApiError ? err.message : 'Có lỗi xảy ra'); + setSuccess(null); + }, + }); + + const togglePark = (id: string) => { + setForm((f) => ({ + ...f, + parkIds: f.parkIds?.includes(id) + ? f.parkIds.filter((x) => x !== id) + : [...(f.parkIds ?? []), id], + })); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + provision.mutate({ + phone: form.phone.trim(), + password: form.password, + fullName: form.fullName.trim(), + email: form.email?.trim() || undefined, + parkIds: form.parkIds && form.parkIds.length > 0 ? form.parkIds : undefined, + }); + }; + + const parks: IndustrialParkListItem[] = parksResp?.data ?? []; + + return ( +
+
+

Tạo tài khoản vận hành KCN

+

+ Cấp quyền truy cập dashboard cho đơn vị vận hành KCN. Có thể gán ngay các KCN + họ quản lý (KCN đã có owner khác sẽ bị từ chối). +

+
+ + {success && ( +
+

+ + + Đã tạo tài khoản {success.fullName} ({success.phone}). + {success.linkedParkIds && success.linkedParkIds.length > 0 && ( + <> Gán cho {success.linkedParkIds.length} KCN. + )} + +

+
+ )} + + {error && ( +
+ {error} + +
+ )} + +
+ + + Thông tin tài khoản + + +
+ + setForm({ ...form, fullName: e.target.value })} + required + /> +
+
+ + setForm({ ...form, phone: e.target.value })} + required + /> +
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + minLength={8} + /> +
+
+
+ + + + Gán KCN (tuỳ chọn) + + +
+ {parks.length === 0 && ( +

Chưa có KCN nào.

+ )} + {parks.map((p) => { + const checked = form.parkIds?.includes(p.id) ?? false; + return ( + + ); + })} +
+ {form.parkIds && form.parkIds.length > 0 && ( +

+ Đã chọn {form.parkIds.length} KCN. +

+ )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index 838c918..493c928 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -5,6 +5,8 @@ import { Users, ClipboardList, ShieldCheck, + Building2, + Factory, LogOut, Menu, Sparkles, @@ -31,6 +33,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/users' as const, label: t('adminNav.users'), icon: Users }, { href: '/admin/moderation' as const, label: t('adminNav.moderation'), icon: ClipboardList }, { href: '/admin/kyc' as const, label: t('adminNav.kyc'), icon: ShieldCheck }, + { href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 }, + { href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory }, { href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles }, ]; diff --git a/apps/web/app/[locale]/(auth)/login/page.tsx b/apps/web/app/[locale]/(auth)/login/page.tsx index 3e503e7..6ba3e82 100644 --- a/apps/web/app/[locale]/(auth)/login/page.tsx +++ b/apps/web/app/[locale]/(auth)/login/page.tsx @@ -18,11 +18,18 @@ import { loginSchema, type LoginFormData } from '@/lib/validations/auth'; const DEMO_PASSWORD = 'Velik@2026'; -const DEMO_ACCOUNTS: { phone: string; name: string; role: 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER'; badgeClass: string }[] = [ +const DEMO_ACCOUNTS: { + phone: string; + name: string; + role: 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER' | 'DEVELOPER' | 'PARK_OPERATOR'; + badgeClass: string; +}[] = [ { phone: '+84876677771', name: 'Hồ Ngọc Hải', role: 'ADMIN', badgeClass: 'bg-red-500/10 text-red-600 border-red-500/20' }, { phone: '+84900000002', name: 'Nguyễn Văn An', role: 'AGENT', badgeClass: 'bg-blue-500/10 text-blue-600 border-blue-500/20' }, { phone: '+84900000005', name: 'Phạm Đức Dũng', role: 'SELLER', badgeClass: 'bg-amber-500/10 text-amber-600 border-amber-500/20' }, { phone: '+84900000004', name: 'Lê Minh Cường', role: 'BUYER', badgeClass: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' }, + { phone: '+84912000001', name: 'CĐT Vingroup', role: 'DEVELOPER', badgeClass: 'bg-violet-500/10 text-violet-600 border-violet-500/20' }, + { phone: '+84912000002', name: 'KCN VSIP', role: 'PARK_OPERATOR', badgeClass: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' }, ]; export default function LoginPage() { diff --git a/apps/web/app/[locale]/(dashboard)/industrial-parks/page.tsx b/apps/web/app/[locale]/(dashboard)/industrial-parks/page.tsx index 82ec8cb..4ff9f7f 100644 --- a/apps/web/app/[locale]/(dashboard)/industrial-parks/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/industrial-parks/page.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Select } from '@/components/ui/select'; +import { useAuthStore } from '@/lib/auth-store'; import { industrialApi, PARK_STATUS_COLORS, @@ -46,6 +47,8 @@ const INITIAL_FILTERS: FiltersState = { export default function IndustrialParksListPage() { const [filters, setFilters] = React.useState(INITIAL_FILTERS); + const role = useAuthStore((s) => s.user?.role); + const isOperator = role === 'PARK_OPERATOR'; const queryParams = React.useMemo(() => { const p: SearchIndustrialParksParams = { page: filters.page, limit: 12 }; @@ -57,8 +60,9 @@ export default function IndustrialParksListPage() { }, [filters]); const { data: result, isLoading } = useQuery({ - queryKey: ['admin-industrial-parks', queryParams], - queryFn: () => industrialApi.search(queryParams), + queryKey: ['admin-industrial-parks', { mine: isOperator, ...queryParams }], + queryFn: () => + isOperator ? industrialApi.searchMine(queryParams) : industrialApi.search(queryParams), staleTime: 30_000, }); diff --git a/apps/web/app/[locale]/(dashboard)/layout.tsx b/apps/web/app/[locale]/(dashboard)/layout.tsx index c5a3f4e..f0a9f7a 100644 --- a/apps/web/app/[locale]/(dashboard)/layout.tsx +++ b/apps/web/app/[locale]/(dashboard)/layout.tsx @@ -75,56 +75,115 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ); } + const role = user?.role; + const isDeveloper = role === 'DEVELOPER'; + const isParkOperator = role === 'PARK_OPERATOR'; + // B2B roles get a focused nav: dashboard + their owned catalog + CRM + profile. + // ADMIN / AGENT / SELLER / BUYER keep the full nav. + const showListings = !isDeveloper && !isParkOperator; + const showProjects = !isParkOperator; + const showParks = !isDeveloper; + const navGroups: NavGroup[] = [ { label: t('dashboard.title'), items: [ { href: '/dashboard', label: t('dashboard.title'), icon: Home }, - { href: '/listings', label: t('dashboard.listings'), icon: List }, - { href: '/listings/new', label: t('dashboard.createListing'), icon: Plus }, + ...(showListings + ? [ + { href: '/listings', label: t('dashboard.listings'), icon: List }, + { href: '/listings/new', label: t('dashboard.createListing'), icon: Plus }, + ] + : []), ], }, { label: t('dashboard.catalogs'), items: [ - { href: '/projects', label: t('dashboard.manageProjects'), icon: Building2 }, - { href: '/industrial-parks', label: t('dashboard.manageIndustrialParks'), icon: Factory }, + ...(showProjects + ? [ + { + href: '/projects', + label: isDeveloper ? 'Dự án của tôi' : t('dashboard.manageProjects'), + icon: Building2, + }, + ] + : []), + ...(showParks + ? [ + { + href: '/industrial-parks', + label: isParkOperator + ? 'KCN của tôi' + : t('dashboard.manageIndustrialParks'), + icon: Factory, + }, + ] + : []), ], }, { label: 'CRM', items: [ { href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare }, - { href: '/leads', label: t('dashboard.leads'), icon: Target }, - ], - }, - { - label: t('dashboard.analytics'), - items: [ - { href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 }, - { href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText }, - { href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark }, - { href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot }, + ...(showListings + ? [{ href: '/leads', label: t('dashboard.leads'), icon: Target }] + : []), ], }, + ...(showListings + ? [ + { + label: t('dashboard.analytics'), + items: [ + { href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 }, + { href: '/dashboard/reports', label: t('dashboard.reports'), icon: FileText }, + { href: '/dashboard/saved-searches', label: t('dashboard.savedSearches'), icon: Bookmark }, + { href: '/dashboard/valuation', label: t('dashboard.aiValuation'), icon: Bot }, + ], + }, + ] + : []), { label: t('dashboard.profile'), items: [ { href: '/dashboard/profile', label: t('dashboard.profile'), icon: User }, - { href: '/dashboard/subscription', label: t('dashboard.subscription'), icon: Gem }, - { href: '/dashboard/payments', label: t('dashboard.payments'), icon: CreditCard }, + ...(showListings + ? [ + { href: '/dashboard/subscription', label: t('dashboard.subscription'), icon: Gem }, + { href: '/dashboard/payments', label: t('dashboard.payments'), icon: CreditCard }, + ] + : []), ], }, - ]; + ].filter((g) => g.items.length > 0); // Flat list for desktop nav (only primary items shown inline) const primaryNav: NavItem[] = [ { href: '/dashboard', label: t('dashboard.title'), icon: Home }, - { href: '/listings', label: t('dashboard.listings'), icon: List }, - { href: '/projects', label: t('dashboard.manageProjects'), icon: Building2 }, - { href: '/industrial-parks', label: t('dashboard.manageIndustrialParks'), icon: Factory }, + ...(showListings ? [{ href: '/listings', label: t('dashboard.listings'), icon: List }] : []), + ...(showProjects + ? [ + { + href: '/projects', + label: isDeveloper ? 'Dự án của tôi' : t('dashboard.manageProjects'), + icon: Building2, + }, + ] + : []), + ...(showParks + ? [ + { + href: '/industrial-parks', + label: isParkOperator ? 'KCN của tôi' : t('dashboard.manageIndustrialParks'), + icon: Factory, + }, + ] + : []), { href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare }, - { href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 }, + ...(showListings + ? [{ href: '/analytics', label: t('dashboard.analytics'), icon: BarChart3 }] + : []), ]; const secondaryNav: NavItem[] = [ diff --git a/apps/web/app/[locale]/(dashboard)/projects/page.tsx b/apps/web/app/[locale]/(dashboard)/projects/page.tsx index 0d376b8..a45c8df 100644 --- a/apps/web/app/[locale]/(dashboard)/projects/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/projects/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Select } from '@/components/ui/select'; +import { useAuthStore } from '@/lib/auth-store'; import { formatPrice } from '@/lib/currency'; import { duAnApi, @@ -36,6 +37,8 @@ const INITIAL_FILTERS = { export default function ProjectsAdminPage() { const [filters, setFilters] = React.useState(INITIAL_FILTERS); + const role = useAuthStore((s) => s.user?.role); + const isDeveloper = role === 'DEVELOPER'; const queryParams = React.useMemo(() => { const params: SearchProjectsParams = { page: filters.page, limit: 12 }; @@ -46,8 +49,9 @@ export default function ProjectsAdminPage() { }, [filters]); const { data: result, isLoading } = useQuery({ - queryKey: ['admin-projects', queryParams], - queryFn: () => duAnApi.search(queryParams), + queryKey: ['admin-projects', { mine: isDeveloper, ...queryParams }], + // DEVELOPER sees only their own projects; ADMIN sees all. + queryFn: () => (isDeveloper ? duAnApi.searchMine(queryParams) : duAnApi.search(queryParams)), staleTime: 30_000, }); diff --git a/apps/web/lib/admin-api.ts b/apps/web/lib/admin-api.ts index 2b3c908..0c3ef4f 100644 --- a/apps/web/lib/admin-api.ts +++ b/apps/web/lib/admin-api.ts @@ -196,4 +196,36 @@ export const adminApi = { updateAiSettings: (body: UpdateAiSettingsPayload) => apiClient.patch('/admin/settings/ai', body), + + // B2B account provisioning + provisionDeveloper: (body: ProvisionDeveloperPayload) => + apiClient.post('/admin/accounts/developers', body), + + provisionParkOperator: (body: ProvisionParkOperatorPayload) => + apiClient.post('/admin/accounts/park-operators', body), }; + +export interface ProvisionDeveloperPayload { + phone: string; + password: string; + fullName: string; + email?: string; + projectIds?: string[]; +} + +export interface ProvisionParkOperatorPayload { + phone: string; + password: string; + fullName: string; + email?: string; + parkIds?: string[]; +} + +export interface ProvisionAccountResult { + userId: string; + phone: string; + email: string | null; + fullName: string; + linkedProjectIds?: string[]; + linkedParkIds?: string[]; +} diff --git a/apps/web/lib/du-an-api.ts b/apps/web/lib/du-an-api.ts index 7c8f8c9..cebc248 100644 --- a/apps/web/lib/du-an-api.ts +++ b/apps/web/lib/du-an-api.ts @@ -242,6 +242,29 @@ export const duAnApi = { ); }, + /** DEVELOPER / ADMIN only — returns projects owned by the current user. */ + searchMine: (params: SearchProjectsParams = {}) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.append(key, String(value)); + }); + const qs = query.toString(); + return apiClient.get>( + `/projects/mine/list${qs ? `?${qs}` : ''}`, + ); + }, + + /** Stats for the "Dự án của tôi" dashboard card — admin + owner only. */ + getStats: (projectId: string) => + apiClient.get<{ + projectId: string; + linkedListingCount: number; + activeListingCount: number; + totalInquiries: number; + unreadInquiries: number; + savedByUsers: number; + }>(`/projects/${projectId}/stats`), + getBySlug: (slug: string) => apiClient.get(`/projects/${slug}`), diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts index 860a39e..929d57b 100644 --- a/apps/web/lib/khu-cong-nghiep-api.ts +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -274,6 +274,18 @@ export const industrialApi = { ); }, + /** PARK_OPERATOR / ADMIN only — returns KCN owned by the current user. */ + searchMine: (params: SearchIndustrialParksParams = {}) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.append(key, String(value)); + }); + const qs = query.toString(); + return apiClient.get>( + `/industrial/parks/mine/list${qs ? `?${qs}` : ''}`, + ); + }, + getBySlug: (slug: string) => apiClient.get(`/industrial/parks/${slug}`), diff --git a/prisma/migrations/20260420030000_add_developer_park_operator_roles_and_ownership/migration.sql b/prisma/migrations/20260420030000_add_developer_park_operator_roles_and_ownership/migration.sql new file mode 100644 index 0000000..c6989c1 --- /dev/null +++ b/prisma/migrations/20260420030000_add_developer_park_operator_roles_and_ownership/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e98e7b..f5bb6dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/prisma/seed-b2b-accounts.ts b/prisma/seed-b2b-accounts.ts new file mode 100644 index 0000000..632db5b --- /dev/null +++ b/prisma/seed-b2b-accounts.ts @@ -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 { + 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());