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

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

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

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

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

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

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

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

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

View File

@@ -8,6 +8,8 @@ import { ApproveKycHandler } from './application/commands/approve-kyc/approve-ky
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler'; import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler'; import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.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 { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler';
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler'; import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler'; import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler';
@@ -46,6 +48,8 @@ const CommandHandlers = [
RejectKycHandler, RejectKycHandler,
BulkModerateListingsHandler, BulkModerateListingsHandler,
UpdateAiSettingsHandler, UpdateAiSettingsHandler,
ProvisionDeveloperHandler,
ProvisionParkOperatorHandler,
]; ];
const QueryHandlers = [ const QueryHandlers = [

View File

@@ -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[],
) {}
}

View File

@@ -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<ProvisionDeveloperCommand, ProvisionDeveloperResult>
{
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly prisma: PrismaService,
) {}
async execute(cmd: ProvisionDeveloperCommand): Promise<ProvisionDeveloperResult> {
// 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,
};
}
}

View File

@@ -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[],
) {}
}

View File

@@ -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<ProvisionParkOperatorCommand, ProvisionParkOperatorResult>
{
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly prisma: PrismaService,
) {}
async execute(cmd: ProvisionParkOperatorCommand): Promise<ProvisionParkOperatorResult> {
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,
};
}
}

View File

@@ -15,6 +15,10 @@ import { AdjustSubscriptionCommand } from '../../application/commands/adjust-sub
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command'; import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler'; 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 { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command';
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.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'; 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 { BanUserDto } from '../dto/ban-user.dto';
import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto';
import { GetUsersQueryDto } from '../dto/get-users-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 { RevenueStatsDto } from '../dto/revenue-stats.dto';
import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto'; import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto';
import { UpdateUserStatusDto } from '../dto/update-user-status.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<ProvisionDeveloperResult> {
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<ProvisionParkOperatorResult> {
return this.commandBus.execute(
new ProvisionParkOperatorCommand(
dto.phone,
dto.password,
dto.fullName,
dto.email ?? null,
dto.parkIds ?? [],
),
);
}
// ── Audit Logs ── // ── Audit Logs ──
@Get('audit-logs') @Get('audit-logs')

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -30,5 +30,6 @@ export class CreateIndustrialParkCommand {
public readonly targetIndustries: string[], public readonly targetIndustries: string[],
public readonly description: string | null, public readonly description: string | null,
public readonly descriptionEn: string | null, public readonly descriptionEn: string | null,
public readonly ownerId: string | null = null,
) {} ) {}
} }

View File

@@ -59,6 +59,7 @@ export class CreateIndustrialParkHandler implements ICommandHandler<CreateIndust
description: cmd.description, description: cmd.description,
descriptionEn: cmd.descriptionEn, descriptionEn: cmd.descriptionEn,
isVerified: false, isVerified: false,
ownerId: cmd.ownerId,
}, },
now, now,
now, now,

View File

@@ -1,3 +1,9 @@
import type { UserRole } from '@prisma/client';
export class DeleteIndustrialParkCommand { export class DeleteIndustrialParkCommand {
constructor(public readonly id: string) {} constructor(
public readonly id: string,
public readonly requesterUserId: string,
public readonly requesterRole: UserRole,
) {}
} }

View File

@@ -1,4 +1,4 @@
import { Inject } from '@nestjs/common'; import { ForbiddenException, Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared'; import { NotFoundException } from '@modules/shared';
import { import {
@@ -20,6 +20,14 @@ export class DeleteIndustrialParkHandler implements ICommandHandler<DeleteIndust
throw new NotFoundException('Khu công nghiệp', cmd.id); throw new NotFoundException('Khu công nghiệp', cmd.id);
} }
// Authorisation: ADMIN may delete anything; PARK_OPERATOR may delete only
// parks they own.
if (cmd.requesterRole !== 'ADMIN') {
if (cmd.requesterRole !== 'PARK_OPERATOR' || entity.ownerId !== cmd.requesterUserId) {
throw new ForbiddenException('Bạn không có quyền xoá KCN này');
}
}
await this.repo.delete(cmd.id); await this.repo.delete(cmd.id);
} }
} }

View File

@@ -1,8 +1,11 @@
import type { IndustrialParkStatus } from '@prisma/client'; import type { IndustrialParkStatus, UserRole } from '@prisma/client';
export class UpdateIndustrialParkCommand { export class UpdateIndustrialParkCommand {
constructor( constructor(
public readonly id: string, public readonly id: string,
/** User performing the update. Used to enforce ownership for PARK_OPERATOR role. */
public readonly requesterUserId: string,
public readonly requesterRole: UserRole,
public readonly name?: string, public readonly name?: string,
public readonly nameEn?: string | null, public readonly nameEn?: string | null,
public readonly developer?: string, public readonly developer?: string,
@@ -22,5 +25,7 @@ export class UpdateIndustrialParkCommand {
public readonly description?: string | null, public readonly description?: string | null,
public readonly descriptionEn?: string | null, public readonly descriptionEn?: string | null,
public readonly isVerified?: boolean, public readonly isVerified?: boolean,
/** Admin-only: reassign the owning PARK_OPERATOR user. Null to unassign. */
public readonly ownerId?: string | null,
) {} ) {}
} }

View File

@@ -1,4 +1,4 @@
import { Inject } from '@nestjs/common'; import { ForbiddenException, Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared'; import { NotFoundException } from '@modules/shared';
import { import {
@@ -20,6 +20,17 @@ export class UpdateIndustrialParkHandler implements ICommandHandler<UpdateIndust
throw new NotFoundException('Industrial park', cmd.id); throw new NotFoundException('Industrial park', cmd.id);
} }
// Authorisation: ADMIN may edit anything. PARK_OPERATOR may edit only
// parks they own. Reassigning `ownerId` is admin-only.
if (cmd.requesterRole !== 'ADMIN') {
if (cmd.requesterRole !== 'PARK_OPERATOR' || entity.ownerId !== cmd.requesterUserId) {
throw new ForbiddenException('Bạn không có quyền chỉnh sửa KCN này');
}
if (cmd.ownerId !== undefined) {
throw new ForbiddenException('Chỉ admin có thể chuyển quyền sở hữu KCN');
}
}
entity.updateDetails({ entity.updateDetails({
name: cmd.name, name: cmd.name,
nameEn: cmd.nameEn, nameEn: cmd.nameEn,
@@ -40,6 +51,7 @@ export class UpdateIndustrialParkHandler implements ICommandHandler<UpdateIndust
description: cmd.description, description: cmd.description,
descriptionEn: cmd.descriptionEn, descriptionEn: cmd.descriptionEn,
isVerified: cmd.isVerified, isVerified: cmd.isVerified,
...(cmd.ownerId !== undefined && { ownerId: cmd.ownerId }),
}); });
await this.repo.update(entity); await this.repo.update(entity);

View File

@@ -24,6 +24,7 @@ export class ListIndustrialParksHandler implements IQueryHandler<ListIndustrialP
minAreaHa: query.minAreaHa, minAreaHa: query.minAreaHa,
maxRentUsdM2: query.maxRentUsdM2, maxRentUsdM2: query.maxRentUsdM2,
targetIndustry: query.targetIndustry, targetIndustry: query.targetIndustry,
ownerId: query.ownerId,
page: query.page, page: query.page,
limit: query.limit, limit: query.limit,
}); });

View File

@@ -11,5 +11,6 @@ export class ListIndustrialParksQuery {
public readonly targetIndustry?: string, public readonly targetIndustry?: string,
public readonly page: number = 1, public readonly page: number = 1,
public readonly limit: number = 20, public readonly limit: number = 20,
public readonly ownerId?: string,
) {} ) {}
} }

View File

@@ -35,6 +35,7 @@ export interface IndustrialParkProps {
description: string | null; description: string | null;
descriptionEn: string | null; descriptionEn: string | null;
isVerified: boolean; isVerified: boolean;
ownerId: string | null;
} }
export class IndustrialParkEntity extends AggregateRoot<string> { export class IndustrialParkEntity extends AggregateRoot<string> {
@@ -71,6 +72,7 @@ export class IndustrialParkEntity extends AggregateRoot<string> {
private _description: string | null; private _description: string | null;
private _descriptionEn: string | null; private _descriptionEn: string | null;
private _isVerified: boolean; private _isVerified: boolean;
private _ownerId: string | null;
constructor(id: string, props: IndustrialParkProps, createdAt: Date, updatedAt: Date) { constructor(id: string, props: IndustrialParkProps, createdAt: Date, updatedAt: Date) {
super(id, createdAt, updatedAt); super(id, createdAt, updatedAt);
@@ -107,6 +109,7 @@ export class IndustrialParkEntity extends AggregateRoot<string> {
this._description = props.description; this._description = props.description;
this._descriptionEn = props.descriptionEn; this._descriptionEn = props.descriptionEn;
this._isVerified = props.isVerified; this._isVerified = props.isVerified;
this._ownerId = props.ownerId;
} }
get name() { return this._name; } get name() { return this._name; }
@@ -142,6 +145,7 @@ export class IndustrialParkEntity extends AggregateRoot<string> {
get description() { return this._description; } get description() { return this._description; }
get descriptionEn() { return this._descriptionEn; } get descriptionEn() { return this._descriptionEn; }
get isVerified() { return this._isVerified; } get isVerified() { return this._isVerified; }
get ownerId() { return this._ownerId; }
updateDetails(props: Partial<IndustrialParkProps>): void { updateDetails(props: Partial<IndustrialParkProps>): void {
if (props.name !== undefined) this._name = props.name; if (props.name !== undefined) this._name = props.name;
@@ -167,6 +171,7 @@ export class IndustrialParkEntity extends AggregateRoot<string> {
if (props.description !== undefined) this._description = props.description; if (props.description !== undefined) this._description = props.description;
if (props.descriptionEn !== undefined) this._descriptionEn = props.descriptionEn; if (props.descriptionEn !== undefined) this._descriptionEn = props.descriptionEn;
if (props.isVerified !== undefined) this._isVerified = props.isVerified; if (props.isVerified !== undefined) this._isVerified = props.isVerified;
if (props.ownerId !== undefined) this._ownerId = props.ownerId;
this.updatedAt = new Date(); this.updatedAt = new Date();
} }
} }

View File

@@ -11,6 +11,7 @@ export interface IndustrialParkSearchParams {
minAreaHa?: number; minAreaHa?: number;
maxRentUsdM2?: number; maxRentUsdM2?: number;
targetIndustry?: string; targetIndustry?: string;
ownerId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -42,6 +43,7 @@ export interface IndustrialParkListItem {
targetIndustries: string[]; targetIndustries: string[];
latitude: number; latitude: number;
longitude: number; longitude: number;
ownerId: string | null;
} }
export interface IndustrialParkDetailData { export interface IndustrialParkDetailData {
@@ -79,6 +81,7 @@ export interface IndustrialParkDetailData {
description: string | null; description: string | null;
descriptionEn: string | null; descriptionEn: string | null;
isVerified: boolean; isVerified: boolean;
ownerId: string | null;
listingCount: number; listingCount: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -71,7 +71,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
"landRentUsdM2Year", "rbfRentUsdM2Month", "rbwRentUsdM2Month", "landRentUsdM2Year", "rbfRentUsdM2Month", "rbwRentUsdM2Month",
"managementFeeUsd", infrastructure, connectivity, incentives, "managementFeeUsd", infrastructure, connectivity, incentives,
"targetIndustries", "existingTenants", certifications, media, documents, "targetIndustries", "existingTenants", certifications, media, documents,
description, "descriptionEn", "isVerified", "createdAt", "updatedAt" description, "descriptionEn", "isVerified", "ownerId", "createdAt", "updatedAt"
) VALUES ( ) VALUES (
${entity.id}, ${entity.name}, ${entity.nameEn}, ${entity.slug}, ${entity.id}, ${entity.name}, ${entity.nameEn}, ${entity.slug},
${entity.developer}, ${entity.operator}, ${entity.status}::"IndustrialParkStatus", ${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.media ? JSON.stringify(entity.media) : null}::jsonb,
${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb, ${entity.documents ? JSON.stringify(entity.documents) : null}::jsonb,
${entity.description}, ${entity.descriptionEn}, ${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[], "targetIndustries" = ${entity.targetIndustries}::text[],
description = ${entity.description}, "descriptionEn" = ${entity.descriptionEn}, description = ${entity.description}, "descriptionEn" = ${entity.descriptionEn},
"isVerified" = ${entity.isVerified}, "isVerified" = ${entity.isVerified},
"ownerId" = ${entity.ownerId},
"updatedAt" = ${entity.updatedAt} "updatedAt" = ${entity.updatedAt}
WHERE id = ${entity.id} WHERE id = ${entity.id}
`; `;
@@ -156,6 +157,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
conditions.push(`$${paramIndex++} = ANY("targetIndustries")`); conditions.push(`$${paramIndex++} = ANY("targetIndustries")`);
values.push(params.targetIndustry); values.push(params.targetIndustry);
} }
if (params.ownerId) {
conditions.push(`"ownerId" = $${paramIndex++}`);
values.push(params.ownerId);
}
if (params.query) { if (params.query) {
conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`); conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`);
values.push(`%${params.query}%`); values.push(`%${params.query}%`);
@@ -306,6 +311,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
description: row.description, description: row.description,
descriptionEn: row.descriptionEn, descriptionEn: row.descriptionEn,
isVerified: row.isVerified, isVerified: row.isVerified,
ownerId: row.ownerId,
}, },
row.createdAt, row.createdAt,
row.updatedAt, row.updatedAt,
@@ -332,6 +338,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
targetIndustries: row.targetIndustries ?? [], targetIndustries: row.targetIndustries ?? [],
latitude: Number(row.lat), latitude: Number(row.lat),
longitude: Number(row.lng), longitude: Number(row.lng),
ownerId: row.ownerId,
}; };
} }
@@ -371,6 +378,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
description: row.description, description: row.description,
descriptionEn: row.descriptionEn, descriptionEn: row.descriptionEn,
isVerified: row.isVerified, isVerified: row.isVerified,
ownerId: row.ownerId,
listingCount: row.listingCount ?? 0, listingCount: row.listingCount ?? 0,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
@@ -414,6 +422,7 @@ interface RawPark {
description: string | null; description: string | null;
descriptionEn: string | null; descriptionEn: string | null;
isVerified: boolean; isVerified: boolean;
ownerId: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

View File

@@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserRole } from '@prisma/client'; 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 { NotFoundException } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query'; import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command'; 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' }) @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: 200, description: 'Thông tin chi tiết KCN' })
@ApiResponse({ status: 404, description: 'Không tìm thấy 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' }) @ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
@Post('parks') @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( return this.commandBus.execute(
new CreateIndustrialParkCommand( new CreateIndustrialParkCommand(
dto.name, dto.name,
@@ -157,6 +196,7 @@ export class IndustrialParksController {
dto.targetIndustries, dto.targetIndustries,
dto.description ?? null, dto.description ?? null,
dto.descriptionEn ?? 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' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
@Patch('parks/:id') @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( return this.commandBus.execute(
new UpdateIndustrialParkCommand( new UpdateIndustrialParkCommand(
id, id,
user.sub,
user.role as UserRole,
dto.name, dto.name,
dto.nameEn, dto.nameEn,
dto.developer, dto.developer,
@@ -193,6 +239,7 @@ export class IndustrialParksController {
dto.description, dto.description,
dto.descriptionEn, dto.descriptionEn,
dto.isVerified, 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' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
@Delete('parks/:id') @Delete('parks/:id')
async deletePark(@Param('id') id: string): Promise<{ success: true }> { async deletePark(
await this.commandBus.execute(new DeleteIndustrialParkCommand(id)); @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 }; return { success: true };
} }
} }

View File

@@ -120,4 +120,9 @@ export class UpdateIndustrialParkDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isVerified?: boolean; isVerified?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
ownerId?: string | null;
} }

View File

@@ -29,5 +29,11 @@ export class CreateProjectCommand {
public readonly completionDate: Date | null, public readonly completionDate: Date | null,
public readonly suitableFor: string[] = [], public readonly suitableFor: string[] = [],
public readonly whyThisLocation: string | null = null, 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,
) {} ) {}
} }

View File

@@ -57,6 +57,7 @@ export class CreateProjectHandler implements ICommandHandler<CreateProjectComman
suitableFor: cmd.suitableFor ?? [], suitableFor: cmd.suitableFor ?? [],
whyThisLocation: cmd.whyThisLocation ?? null, whyThisLocation: cmd.whyThisLocation ?? null,
isVerified: false, isVerified: false,
ownerId: cmd.ownerId ?? null,
}, },
now, now,
now, now,

View File

@@ -1,3 +1,9 @@
import type { UserRole } from '@prisma/client';
export class DeleteProjectCommand { export class DeleteProjectCommand {
constructor(public readonly id: string) {} constructor(
public readonly id: string,
public readonly requesterUserId: string,
public readonly requesterRole: UserRole,
) {}
} }

View File

@@ -1,4 +1,4 @@
import { Inject } from '@nestjs/common'; import { ForbiddenException, Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared'; import { NotFoundException } from '@modules/shared';
import { import {
@@ -20,6 +20,14 @@ export class DeleteProjectHandler implements ICommandHandler<DeleteProjectComman
throw new NotFoundException('Dự án', cmd.id); throw new NotFoundException('Dự án', cmd.id);
} }
// Authorisation: ADMIN may delete anything; DEVELOPER may delete only
// projects they own.
if (cmd.requesterRole !== 'ADMIN') {
if (cmd.requesterRole !== 'DEVELOPER' || entity.ownerId !== cmd.requesterUserId) {
throw new ForbiddenException('Bạn không có quyền xoá dự án này');
}
}
await this.repo.delete(cmd.id); await this.repo.delete(cmd.id);
} }
} }

View File

@@ -1,8 +1,11 @@
import type { ProjectDevelopmentStatus } from '@prisma/client'; import type { ProjectDevelopmentStatus, UserRole } from '@prisma/client';
export class UpdateProjectCommand { export class UpdateProjectCommand {
constructor( constructor(
public readonly id: string, public readonly id: string,
/** User performing the update. Used to enforce ownership for DEVELOPER role. */
public readonly requesterUserId: string,
public readonly requesterRole: UserRole,
public readonly name?: string, public readonly name?: string,
public readonly developer?: string, public readonly developer?: string,
public readonly developerLogo?: string | null, public readonly developerLogo?: string | null,
@@ -27,5 +30,7 @@ export class UpdateProjectCommand {
public readonly completionDate?: Date | null, public readonly completionDate?: Date | null,
public readonly suitableFor?: string[], public readonly suitableFor?: string[],
public readonly whyThisLocation?: string | null, public readonly whyThisLocation?: string | null,
/** Admin-only: reassign the owning DEVELOPER user. Null to unassign. */
public readonly ownerId?: string | null,
) {} ) {}
} }

View File

@@ -1,4 +1,4 @@
import { Inject } from '@nestjs/common'; import { ForbiddenException, Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared'; import { NotFoundException } from '@modules/shared';
import { import {
@@ -20,6 +20,17 @@ export class UpdateProjectHandler implements ICommandHandler<UpdateProjectComman
throw new NotFoundException('Dự án', cmd.id); throw new NotFoundException('Dự án', cmd.id);
} }
// Authorisation: ADMIN may edit anything. DEVELOPER may edit only projects
// they own. Reassigning `ownerId` is admin-only.
if (cmd.requesterRole !== 'ADMIN') {
if (cmd.requesterRole !== 'DEVELOPER' || entity.ownerId !== cmd.requesterUserId) {
throw new ForbiddenException('Bạn không có quyền chỉnh sửa dự án này');
}
if (cmd.ownerId !== undefined) {
throw new ForbiddenException('Chỉ admin có thể chuyển quyền sở hữu dự án');
}
}
entity.updateDetails({ entity.updateDetails({
...(cmd.name !== undefined && { name: cmd.name }), ...(cmd.name !== undefined && { name: cmd.name }),
...(cmd.developer !== undefined && { developer: cmd.developer }), ...(cmd.developer !== undefined && { developer: cmd.developer }),
@@ -45,6 +56,7 @@ export class UpdateProjectHandler implements ICommandHandler<UpdateProjectComman
...(cmd.isVerified !== undefined && { isVerified: cmd.isVerified }), ...(cmd.isVerified !== undefined && { isVerified: cmd.isVerified }),
...(cmd.startDate !== undefined && { startDate: cmd.startDate }), ...(cmd.startDate !== undefined && { startDate: cmd.startDate }),
...(cmd.completionDate !== undefined && { completionDate: cmd.completionDate }), ...(cmd.completionDate !== undefined && { completionDate: cmd.completionDate }),
...(cmd.ownerId !== undefined && { ownerId: cmd.ownerId }),
}); });
await this.repo.update(entity); await this.repo.update(entity);

View File

@@ -0,0 +1,89 @@
import { ForbiddenException, Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException, PrismaService } from '@modules/shared';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project-development.repository';
import { GetProjectStatsQuery } from './get-project-stats.query';
export interface ProjectStats {
projectId: string;
linkedListingCount: number;
activeListingCount: number;
totalInquiries: number;
unreadInquiries: number;
savedByUsers: number;
}
interface StatsRow {
linked: bigint;
active: bigint;
inquiries: bigint;
unread: bigint;
saves: bigint;
}
/**
* Aggregates project-level metrics for the "Dự án của tôi" dashboard.
* Visible to ADMIN (any project) and DEVELOPER (only projects they own).
*/
@QueryHandler(GetProjectStatsQuery)
export class GetProjectStatsHandler
implements IQueryHandler<GetProjectStatsQuery, ProjectStats>
{
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepo: IProjectRepository,
private readonly prisma: PrismaService,
) {}
async execute(query: GetProjectStatsQuery): Promise<ProjectStats> {
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<StatsRow[]>`
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),
};
}
}

View File

@@ -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,
) {}
}

View File

@@ -23,6 +23,7 @@ export class ListProjectsHandler implements IQueryHandler<ListProjectsQuery> {
district: query.district, district: query.district,
developer: query.developer, developer: query.developer,
isVerified: query.isVerified, isVerified: query.isVerified,
ownerId: query.ownerId,
page: query.page, page: query.page,
limit: query.limit, limit: query.limit,
}); });

View File

@@ -10,5 +10,7 @@ export class ListProjectsQuery {
public readonly isVerified: boolean | undefined, public readonly isVerified: boolean | undefined,
public readonly page: number, public readonly page: number,
public readonly limit: number, public readonly limit: number,
/** When set, restrict results to projects owned by this user id. */
public readonly ownerId?: string,
) {} ) {}
} }

View File

@@ -33,6 +33,8 @@ export interface ProjectDevelopmentProps {
suitableFor: string[]; suitableFor: string[];
whyThisLocation: string | null; whyThisLocation: string | null;
isVerified: boolean; isVerified: boolean;
/** Owning DEVELOPER user id; null when not yet assigned (admin-managed). */
ownerId: string | null;
} }
export class ProjectDevelopmentEntity extends AggregateRoot<string> { export class ProjectDevelopmentEntity extends AggregateRoot<string> {
@@ -67,6 +69,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot<string> {
private _suitableFor: string[]; private _suitableFor: string[];
private _whyThisLocation: string | null; private _whyThisLocation: string | null;
private _isVerified: boolean; private _isVerified: boolean;
private _ownerId: string | null;
constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) { constructor(id: string, props: ProjectDevelopmentProps, createdAt: Date, updatedAt: Date) {
super(id, createdAt, updatedAt); super(id, createdAt, updatedAt);
@@ -101,6 +104,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot<string> {
this._suitableFor = props.suitableFor; this._suitableFor = props.suitableFor;
this._whyThisLocation = props.whyThisLocation; this._whyThisLocation = props.whyThisLocation;
this._isVerified = props.isVerified; this._isVerified = props.isVerified;
this._ownerId = props.ownerId;
} }
get name() { return this._name; } get name() { return this._name; }
@@ -134,6 +138,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot<string> {
get suitableFor() { return this._suitableFor; } get suitableFor() { return this._suitableFor; }
get whyThisLocation() { return this._whyThisLocation; } get whyThisLocation() { return this._whyThisLocation; }
get isVerified() { return this._isVerified; } get isVerified() { return this._isVerified; }
get ownerId() { return this._ownerId; }
updateDetails(props: Partial<ProjectDevelopmentProps>): void { updateDetails(props: Partial<ProjectDevelopmentProps>): void {
if (props.name !== undefined) this._name = props.name; if (props.name !== undefined) this._name = props.name;
@@ -160,6 +165,7 @@ export class ProjectDevelopmentEntity extends AggregateRoot<string> {
if (props.suitableFor !== undefined) this._suitableFor = props.suitableFor; if (props.suitableFor !== undefined) this._suitableFor = props.suitableFor;
if (props.whyThisLocation !== undefined) this._whyThisLocation = props.whyThisLocation; if (props.whyThisLocation !== undefined) this._whyThisLocation = props.whyThisLocation;
if (props.isVerified !== undefined) this._isVerified = props.isVerified; if (props.isVerified !== undefined) this._isVerified = props.isVerified;
if (props.ownerId !== undefined) this._ownerId = props.ownerId;
this.updatedAt = new Date(); this.updatedAt = new Date();
} }
} }

View File

@@ -10,6 +10,8 @@ export interface ProjectSearchParams {
district?: string; district?: string;
developer?: string; developer?: string;
isVerified?: boolean; isVerified?: boolean;
/** When set, restrict results to projects owned by this user id. */
ownerId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }
@@ -42,6 +44,7 @@ export interface ProjectListItem {
suitableFor: string[]; suitableFor: string[];
whyThisLocation: string | null; whyThisLocation: string | null;
isVerified: boolean; isVerified: boolean;
ownerId: string | null;
latitude: number; latitude: number;
longitude: number; longitude: number;
propertyCount: number; propertyCount: number;

View File

@@ -68,7 +68,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
location, address, ward, district, city, location, address, ward, district, city,
"minPrice", "maxPrice", "pricePerM2Range", "totalArea", "minPrice", "maxPrice", "pricePerM2Range", "totalArea",
"buildingCount", "floorCount", "unitTypes", media, documents, "buildingCount", "floorCount", "unitTypes", media, documents,
tags, "suitableFor", "whyThisLocation", "isVerified", "createdAt", "updatedAt" tags, "suitableFor", "whyThisLocation", "isVerified", "ownerId",
"createdAt", "updatedAt"
) VALUES ( ) VALUES (
${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer}, ${entity.id}, ${entity.name}, ${entity.slug}, ${entity.developer},
${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits}, ${entity.developerLogo}, ${entity.totalUnits}, ${entity.completedUnits},
@@ -88,7 +89,8 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
${entity.tags}::text[], ${entity.tags}::text[],
${entity.suitableFor}::text[], ${entity.suitableFor}::text[],
${entity.whyThisLocation}, ${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[], "suitableFor" = ${entity.suitableFor}::text[],
"whyThisLocation" = ${entity.whyThisLocation}, "whyThisLocation" = ${entity.whyThisLocation},
"isVerified" = ${entity.isVerified}, "isVerified" = ${entity.isVerified},
"ownerId" = ${entity.ownerId},
"updatedAt" = ${entity.updatedAt} "updatedAt" = ${entity.updatedAt}
WHERE id = ${entity.id} WHERE id = ${entity.id}
`; `;
@@ -153,6 +156,10 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
conditions.push(`"isVerified" = $${paramIndex++}`); conditions.push(`"isVerified" = $${paramIndex++}`);
values.push(params.isVerified); values.push(params.isVerified);
} }
if (params.ownerId) {
conditions.push(`"ownerId" = $${paramIndex++}`);
values.push(params.ownerId);
}
if (params.query) { if (params.query) {
conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city ILIKE $${paramIndex})`); conditions.push(`(name ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR district ILIKE $${paramIndex} OR city ILIKE $${paramIndex})`);
values.push(`%${params.query}%`); values.push(`%${params.query}%`);
@@ -223,6 +230,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
suitableFor: row.suitableFor ?? [], suitableFor: row.suitableFor ?? [],
whyThisLocation: row.whyThisLocation, whyThisLocation: row.whyThisLocation,
isVerified: row.isVerified, isVerified: row.isVerified,
ownerId: row.ownerId,
}, },
row.createdAt, row.createdAt,
row.updatedAt, row.updatedAt,
@@ -250,6 +258,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
suitableFor: row.suitableFor ?? [], suitableFor: row.suitableFor ?? [],
whyThisLocation: row.whyThisLocation, whyThisLocation: row.whyThisLocation,
isVerified: row.isVerified, isVerified: row.isVerified,
ownerId: row.ownerId,
latitude: Number(row.lat), latitude: Number(row.lat),
longitude: Number(row.lng), longitude: Number(row.lng),
propertyCount: row.propertyCount ?? 0, propertyCount: row.propertyCount ?? 0,
@@ -309,6 +318,7 @@ interface RawProject {
suitableFor: string[] | null; suitableFor: string[] | null;
whyThisLocation: string | null; whyThisLocation: string | null;
isVerified: boolean; isVerified: boolean;
ownerId: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

View File

@@ -2,12 +2,15 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } f
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserRole } from '@prisma/client'; 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 { NotFoundException } from '@modules/shared';
import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command'; import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command';
import { DeleteProjectCommand } from '../../application/commands/delete-project/delete-project.command'; import { DeleteProjectCommand } from '../../application/commands/delete-project/delete-project.command';
import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command'; import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command';
import { GetProjectQuery } from '../../application/queries/get-project/get-project.query'; 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 { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query';
import { CreateProjectDto } from '../dto/create-project.dto'; import { CreateProjectDto } from '../dto/create-project.dto';
import { SearchProjectsDto } from '../dto/search-projects.dto'; import { SearchProjectsDto } from '../dto/search-projects.dto';
@@ -78,6 +81,64 @@ export class ProjectsController {
return { ...result, data: result.data.map(shapeProject) }; 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<ProjectStats> {
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' }) @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: 200, description: 'Thông tin chi tiết dự án' })
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' }) @ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
@@ -94,16 +155,22 @@ export class ProjectsController {
// ── Admin endpoints ─────────────────────────────────────────────── // ── 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: 201, description: 'Dự án đã tạo' })
@ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.DEVELOPER)
@Post() @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( return this.commandBus.execute(
new CreateProjectCommand( new CreateProjectCommand(
dto.name, dto.name,
@@ -133,23 +200,30 @@ export class ProjectsController {
dto.completionDate ? new Date(dto.completionDate) : null, dto.completionDate ? new Date(dto.completionDate) : null,
dto.suitableFor ?? [], dto.suitableFor ?? [],
dto.whyThisLocation ?? null, 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: 200, description: 'Dự án đã cập nhật' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.DEVELOPER)
@Patch(':id') @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( return this.commandBus.execute(
new UpdateProjectCommand( new UpdateProjectCommand(
id, id,
user.sub,
user.role as UserRole,
dto.name, dto.name,
dto.developer, dto.developer,
dto.developerLogo, dto.developerLogo,
@@ -174,21 +248,27 @@ export class ProjectsController {
dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined, dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined,
dto.suitableFor, dto.suitableFor,
dto.whyThisLocation, 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' }) @ApiOperation({ summary: 'X 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 đã xóa' }) @ApiResponse({ status: 200, description: 'Dự án đã x' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' }) @ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' }) @ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' }) @ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT') @ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN) @Roles(UserRole.ADMIN, UserRole.DEVELOPER)
@Delete(':id') @Delete(':id')
async deleteProject(@Param('id') id: string): Promise<{ success: true }> { async deleteProject(
await this.commandBus.execute(new DeleteProjectCommand(id)); @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 }; return { success: true };
} }
} }

View File

@@ -55,4 +55,10 @@ export class UpdateProjectDto {
@ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean; @ApiPropertyOptional() @IsOptional() @IsBoolean() isVerified?: boolean;
@ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null; @ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string | null;
@ApiPropertyOptional() @IsOptional() @IsDateString() completionDate?: 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;
} }

View File

@@ -4,13 +4,14 @@ import { CreateProjectHandler } from './application/commands/create-project/crea
import { DeleteProjectHandler } from './application/commands/delete-project/delete-project.handler'; import { DeleteProjectHandler } from './application/commands/delete-project/delete-project.handler';
import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler'; import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler';
import { GetProjectHandler } from './application/queries/get-project/get-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 { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler';
import { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository'; import { PROJECT_REPOSITORY } from './domain/repositories/project-development.repository';
import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository'; import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository';
import { ProjectsController } from './presentation/controllers/projects.controller'; import { ProjectsController } from './presentation/controllers/projects.controller';
const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler, DeleteProjectHandler]; const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler, DeleteProjectHandler];
const QueryHandlers = [GetProjectHandler, ListProjectsHandler]; const QueryHandlers = [GetProjectHandler, ListProjectsHandler, GetProjectStatsHandler];
@Module({ @Module({
imports: [CqrsModule], imports: [CqrsModule],

View File

@@ -18,6 +18,8 @@ export const DEFAULT_ROLE_LIMITS: Record<UserRole, number> = {
BUYER: 100, BUYER: 100,
SELLER: 150, SELLER: 150,
AGENT: 200, AGENT: 200,
DEVELOPER: 300,
PARK_OPERATOR: 300,
ADMIN: 500, ADMIN: 500,
}; };

View File

@@ -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<ProvisionDeveloperPayload>({
phone: '',
password: '',
fullName: '',
email: '',
projectIds: [],
});
const [success, setSuccess] = React.useState<ProvisionAccountResult | null>(null);
const [error, setError] = React.useState<string | null>(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 (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-bold">Tạo tài khoản CĐT (Chủ đu )</h1>
<p className="text-sm text-muted-foreground">
Cấp quyền truy cập dashboard cho một chủ đu . thể gán ngay các dự án họ
sở hữu (các dự án đã CĐT khác sẽ bị từ chối).
</p>
</div>
{success && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 p-4">
<p className="flex items-start gap-2 text-sm text-green-700 dark:text-green-400">
<Check className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Đã tạo tài khoản <strong>{success.fullName}</strong> ({success.phone}).
{success.linkedProjectIds && success.linkedProjectIds.length > 0 && (
<> Gán cho {success.linkedProjectIds.length} dự án.</>
)}
</span>
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
<button type="button" className="ml-2 underline" onClick={() => setError(null)}>
Đóng
</button>
</div>
)}
<form onSubmit={onSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Thông tin tài khoản</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fullName">Họ tên *</Label>
<Input
id="fullName"
value={form.fullName}
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Số điện thoại *</Label>
<Input
id="phone"
placeholder="+84912345678"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email (tuỳ chọn)</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Mật khẩu tạm *</Label>
<Input
id="password"
type="text"
placeholder="Tối thiểu 8 ký tự"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Gán dự án (tuỳ chọn)</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
Chọn các dự án sẽ giao cho CĐT này quản . Dự án đã owner khác sẽ bị
backend từ chối khi submit.
</p>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-md border p-2">
{projects.length === 0 && (
<p className="text-sm text-muted-foreground">Chưa dự án nào.</p>
)}
{projects.map((p) => {
const checked = form.projectIds?.includes(p.id) ?? false;
return (
<label
key={p.id}
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-accent"
>
<input
type="checkbox"
className="h-4 w-4"
checked={checked}
onChange={() => toggleProject(p.id)}
/>
<Building2 className="h-4 w-4 text-primary" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{p.name}</p>
<p className="truncate text-xs text-muted-foreground">
{p.developer.name} · {p.district}, {p.city}
</p>
</div>
<Badge variant="outline" className="text-xs">
{PROJECT_STATUS_LABELS[p.status]}
</Badge>
</label>
);
})}
</div>
{form.projectIds && form.projectIds.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Đã chọn <strong>{form.projectIds.length}</strong> dự án.
</p>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button type="submit" disabled={provision.isPending}>
{provision.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Tạo tài khoản CĐT
</Button>
</div>
</form>
</div>
);
}

View File

@@ -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<ProvisionParkOperatorPayload>({
phone: '',
password: '',
fullName: '',
email: '',
parkIds: [],
});
const [success, setSuccess] = React.useState<ProvisionAccountResult | null>(null);
const [error, setError] = React.useState<string | null>(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 (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-bold">Tạo tài khoản vận hành KCN</h1>
<p className="text-sm text-muted-foreground">
Cấp quyền truy cập dashboard cho đơn vị vận hành KCN. thể gán ngay các KCN
họ quản (KCN đã owner khác sẽ bị từ chối).
</p>
</div>
{success && (
<div className="rounded-md border border-green-500/40 bg-green-500/10 p-4">
<p className="flex items-start gap-2 text-sm text-green-700 dark:text-green-400">
<Check className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Đã tạo tài khoản <strong>{success.fullName}</strong> ({success.phone}).
{success.linkedParkIds && success.linkedParkIds.length > 0 && (
<> Gán cho {success.linkedParkIds.length} KCN.</>
)}
</span>
</p>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
<button type="button" className="ml-2 underline" onClick={() => setError(null)}>
Đóng
</button>
</div>
)}
<form onSubmit={onSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Thông tin tài khoản</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fullName">Họ tên / Đơn vị *</Label>
<Input
id="fullName"
value={form.fullName}
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Số điện thoại *</Label>
<Input
id="phone"
placeholder="+84912345678"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email (tuỳ chọn)</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Mật khẩu tạm *</Label>
<Input
id="password"
type="text"
placeholder="Tối thiểu 8 ký tự"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
minLength={8}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Gán KCN (tuỳ chọn)</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-md border p-2">
{parks.length === 0 && (
<p className="text-sm text-muted-foreground">Chưa KCN nào.</p>
)}
{parks.map((p) => {
const checked = form.parkIds?.includes(p.id) ?? false;
return (
<label
key={p.id}
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-accent"
>
<input
type="checkbox"
className="h-4 w-4"
checked={checked}
onChange={() => togglePark(p.id)}
/>
<Factory className="h-4 w-4 text-primary" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{p.name}</p>
<p className="truncate text-xs text-muted-foreground">
{p.province} · {p.totalAreaHa} ha
</p>
</div>
<Badge variant="outline" className="text-xs">
{PARK_STATUS_LABELS[p.status]}
</Badge>
</label>
);
})}
</div>
{form.parkIds && form.parkIds.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
Đã chọn <strong>{form.parkIds.length}</strong> KCN.
</p>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button type="submit" disabled={provision.isPending}>
{provision.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Tạo tài khoản KCN
</Button>
</div>
</form>
</div>
);
}

View File

@@ -5,6 +5,8 @@ import {
Users, Users,
ClipboardList, ClipboardList,
ShieldCheck, ShieldCheck,
Building2,
Factory,
LogOut, LogOut,
Menu, Menu,
Sparkles, 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/users' as const, label: t('adminNav.users'), icon: Users },
{ href: '/admin/moderation' as const, label: t('adminNav.moderation'), icon: ClipboardList }, { href: '/admin/moderation' as const, label: t('adminNav.moderation'), icon: ClipboardList },
{ href: '/admin/kyc' as const, label: t('adminNav.kyc'), icon: ShieldCheck }, { 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 }, { href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
]; ];

View File

@@ -18,11 +18,18 @@ import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
const DEMO_PASSWORD = 'Velik@2026'; 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: '+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: '+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: '+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: '+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() { export default function LoginPage() {

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { useAuthStore } from '@/lib/auth-store';
import { import {
industrialApi, industrialApi,
PARK_STATUS_COLORS, PARK_STATUS_COLORS,
@@ -46,6 +47,8 @@ const INITIAL_FILTERS: FiltersState = {
export default function IndustrialParksListPage() { export default function IndustrialParksListPage() {
const [filters, setFilters] = React.useState<FiltersState>(INITIAL_FILTERS); const [filters, setFilters] = React.useState<FiltersState>(INITIAL_FILTERS);
const role = useAuthStore((s) => s.user?.role);
const isOperator = role === 'PARK_OPERATOR';
const queryParams = React.useMemo<SearchIndustrialParksParams>(() => { const queryParams = React.useMemo<SearchIndustrialParksParams>(() => {
const p: SearchIndustrialParksParams = { page: filters.page, limit: 12 }; const p: SearchIndustrialParksParams = { page: filters.page, limit: 12 };
@@ -57,8 +60,9 @@ export default function IndustrialParksListPage() {
}, [filters]); }, [filters]);
const { data: result, isLoading } = useQuery({ const { data: result, isLoading } = useQuery({
queryKey: ['admin-industrial-parks', queryParams], queryKey: ['admin-industrial-parks', { mine: isOperator, ...queryParams }],
queryFn: () => industrialApi.search(queryParams), queryFn: () =>
isOperator ? industrialApi.searchMine(queryParams) : industrialApi.search(queryParams),
staleTime: 30_000, staleTime: 30_000,
}); });

View File

@@ -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[] = [ const navGroups: NavGroup[] = [
{ {
label: t('dashboard.title'), label: t('dashboard.title'),
items: [ items: [
{ href: '/dashboard', label: t('dashboard.title'), icon: Home }, { href: '/dashboard', label: t('dashboard.title'), icon: Home },
{ href: '/listings', label: t('dashboard.listings'), icon: List }, ...(showListings
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus }, ? [
{ href: '/listings', label: t('dashboard.listings'), icon: List },
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
]
: []),
], ],
}, },
{ {
label: t('dashboard.catalogs'), label: t('dashboard.catalogs'),
items: [ items: [
{ href: '/projects', label: t('dashboard.manageProjects'), icon: Building2 }, ...(showProjects
{ href: '/industrial-parks', label: t('dashboard.manageIndustrialParks'), icon: Factory }, ? [
{
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', label: 'CRM',
items: [ items: [
{ href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare }, { href: '/inquiries', label: t('dashboard.inquiries'), icon: MessageSquare },
{ href: '/leads', label: t('dashboard.leads'), icon: Target }, ...(showListings
], ? [{ 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
? [
{
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'), label: t('dashboard.profile'),
items: [ items: [
{ href: '/dashboard/profile', label: t('dashboard.profile'), icon: User }, { href: '/dashboard/profile', label: t('dashboard.profile'), icon: User },
{ href: '/dashboard/subscription', label: t('dashboard.subscription'), icon: Gem }, ...(showListings
{ href: '/dashboard/payments', label: t('dashboard.payments'), icon: CreditCard }, ? [
{ 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) // Flat list for desktop nav (only primary items shown inline)
const primaryNav: NavItem[] = [ const primaryNav: NavItem[] = [
{ href: '/dashboard', label: t('dashboard.title'), icon: Home }, { href: '/dashboard', label: t('dashboard.title'), icon: Home },
{ href: '/listings', label: t('dashboard.listings'), icon: List }, ...(showListings ? [{ href: '/listings', label: t('dashboard.listings'), icon: List }] : []),
{ href: '/projects', label: t('dashboard.manageProjects'), icon: Building2 }, ...(showProjects
{ href: '/industrial-parks', label: t('dashboard.manageIndustrialParks'), icon: Factory }, ? [
{
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: '/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[] = [ const secondaryNav: NavItem[] = [

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { useAuthStore } from '@/lib/auth-store';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { import {
duAnApi, duAnApi,
@@ -36,6 +37,8 @@ const INITIAL_FILTERS = {
export default function ProjectsAdminPage() { export default function ProjectsAdminPage() {
const [filters, setFilters] = React.useState(INITIAL_FILTERS); const [filters, setFilters] = React.useState(INITIAL_FILTERS);
const role = useAuthStore((s) => s.user?.role);
const isDeveloper = role === 'DEVELOPER';
const queryParams = React.useMemo<SearchProjectsParams>(() => { const queryParams = React.useMemo<SearchProjectsParams>(() => {
const params: SearchProjectsParams = { page: filters.page, limit: 12 }; const params: SearchProjectsParams = { page: filters.page, limit: 12 };
@@ -46,8 +49,9 @@ export default function ProjectsAdminPage() {
}, [filters]); }, [filters]);
const { data: result, isLoading } = useQuery({ const { data: result, isLoading } = useQuery({
queryKey: ['admin-projects', queryParams], queryKey: ['admin-projects', { mine: isDeveloper, ...queryParams }],
queryFn: () => duAnApi.search(queryParams), // DEVELOPER sees only their own projects; ADMIN sees all.
queryFn: () => (isDeveloper ? duAnApi.searchMine(queryParams) : duAnApi.search(queryParams)),
staleTime: 30_000, staleTime: 30_000,
}); });

View File

@@ -196,4 +196,36 @@ export const adminApi = {
updateAiSettings: (body: UpdateAiSettingsPayload) => updateAiSettings: (body: UpdateAiSettingsPayload) =>
apiClient.patch<AiSettings>('/admin/settings/ai', body), apiClient.patch<AiSettings>('/admin/settings/ai', body),
// B2B account provisioning
provisionDeveloper: (body: ProvisionDeveloperPayload) =>
apiClient.post<ProvisionAccountResult>('/admin/accounts/developers', body),
provisionParkOperator: (body: ProvisionParkOperatorPayload) =>
apiClient.post<ProvisionAccountResult>('/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[];
}

View File

@@ -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<PaginatedResult<ProjectSummary>>(
`/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) => getBySlug: (slug: string) =>
apiClient.get<ProjectDetail>(`/projects/${slug}`), apiClient.get<ProjectDetail>(`/projects/${slug}`),

View File

@@ -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<PaginatedResult<IndustrialParkListItem>>(
`/industrial/parks/mine/list${qs ? `?${qs}` : ''}`,
);
},
getBySlug: (slug: string) => getBySlug: (slug: string) =>
apiClient.get<IndustrialParkDetail>(`/industrial/parks/${slug}`), apiClient.get<IndustrialParkDetail>(`/industrial/parks/${slug}`),

View File

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

View File

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

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

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