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>
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
|
import { UserRole } from '@prisma/client';
|
|
import { CurrentUser, JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
|
import type { JwtPayload } from '@modules/auth';
|
|
import { NotFoundException } from '@modules/shared';
|
|
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
|
|
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
|
|
import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command';
|
|
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
|
|
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
|
|
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
|
|
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
|
|
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
|
|
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
|
|
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
|
|
import { AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
|
|
import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
|
|
import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
|
|
import { EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
|
|
import { SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
|
|
import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
|
|
|
|
@ApiTags('industrial-parks')
|
|
@Controller('industrial')
|
|
export class IndustrialParksController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
// ── Public endpoints ──────────────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Danh sách KCN', description: 'Tìm kiếm và lọc khu công nghiệp' })
|
|
@ApiResponse({ status: 200, description: 'Danh sách KCN phân trang' })
|
|
@Get('parks')
|
|
async listParks(@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,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Park Operator endpoints ───────────────────────────────────────
|
|
|
|
@ApiOperation({
|
|
summary: 'KCN của tôi (Park Operator)',
|
|
description: 'Danh sách KCN mà user hiện tại vận hành. ADMIN dùng endpoint này để xem KCN đã được gán cho mình.',
|
|
})
|
|
@ApiResponse({ status: 200, description: 'Danh sách KCN đã lọc theo owner' })
|
|
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.PARK_OPERATOR, UserRole.ADMIN)
|
|
@Get('parks/mine/list')
|
|
async listMyParks(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Query() dto: SearchIndustrialParksDto,
|
|
) {
|
|
return this.queryBus.execute(
|
|
new ListIndustrialParksQuery(
|
|
dto.q,
|
|
dto.province,
|
|
dto.region,
|
|
dto.status,
|
|
dto.minAreaHa,
|
|
dto.maxRentUsdM2,
|
|
dto.targetIndustry,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
user.sub,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Chi tiết KCN', description: 'Xem chi tiết KCN theo slug hoặc ID' })
|
|
@ApiResponse({ status: 200, description: 'Thông tin chi tiết KCN' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy KCN' })
|
|
@Get('parks/:slugOrId')
|
|
async getPark(@Param('slugOrId') slugOrId: string) {
|
|
const result = await this.queryBus.execute(new GetIndustrialParkQuery(slugOrId));
|
|
if (!result) {
|
|
throw new NotFoundException('Industrial park', slugOrId);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@ApiOperation({ summary: 'So sánh KCN', description: 'So sánh 2-5 KCN' })
|
|
@ApiResponse({ status: 200, description: 'Dữ liệu so sánh KCN' })
|
|
@Post('parks/compare')
|
|
async compareParks(@Body() dto: CompareIndustrialParksDto) {
|
|
return this.queryBus.execute(new CompareIndustrialParksQuery(dto.ids));
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Thống kê KCN', description: 'Tổng quan thống kê tất cả KCN' })
|
|
@ApiResponse({ status: 200, description: 'Dữ liệu thống kê' })
|
|
@Get('parks/stats')
|
|
async getStats() {
|
|
return this.queryBus.execute(new IndustrialParkStatsQuery());
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Thị trường KCN', description: 'Dữ liệu thị trường BĐS công nghiệp' })
|
|
@ApiResponse({ status: 200, description: 'Dữ liệu thị trường' })
|
|
@Get('market')
|
|
async getMarket() {
|
|
return this.queryBus.execute(new IndustrialMarketQuery());
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Phân tích vị trí KCN', description: 'Đánh giá vị trí dựa trên hạ tầng, kết nối, lao động' })
|
|
@ApiResponse({ status: 200, description: 'Kết quả phân tích vị trí' })
|
|
@Post('analyze-location')
|
|
async analyzeLocation(@Body() dto: AnalyzeIndustrialLocationDto) {
|
|
return this.queryBus.execute(
|
|
new AnalyzeIndustrialLocationQuery(
|
|
dto.latitude,
|
|
dto.longitude,
|
|
dto.park_name ?? null,
|
|
dto.target_industry ?? null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Ước tính giá thuê KCN', description: 'Tính giá thuê BĐS công nghiệp theo tỉnh, loại, diện tích' })
|
|
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá thuê' })
|
|
@Post('estimate-rent')
|
|
async estimateRent(@Body() dto: EstimateIndustrialRentDto) {
|
|
return this.queryBus.execute(
|
|
new EstimateIndustrialRentQuery(
|
|
dto.province,
|
|
dto.property_type,
|
|
dto.area_m2,
|
|
dto.lease_duration_years,
|
|
dto.park_name ?? null,
|
|
dto.requires_crane ?? false,
|
|
dto.required_power_kva ?? null,
|
|
dto.requires_wastewater ?? false,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Admin endpoints ───────────────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })
|
|
@ApiResponse({ status: 201, description: 'KCN đã tạo' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
|
|
@Post('parks')
|
|
async createPark(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Body() dto: CreateIndustrialParkDto,
|
|
) {
|
|
// PARK_OPERATOR callers own what they create; admin may leave unassigned.
|
|
const ownerId = user.role === UserRole.PARK_OPERATOR ? user.sub : null;
|
|
return this.commandBus.execute(
|
|
new CreateIndustrialParkCommand(
|
|
dto.name,
|
|
dto.nameEn ?? null,
|
|
dto.slug,
|
|
dto.developer,
|
|
dto.operator ?? null,
|
|
dto.status,
|
|
dto.latitude,
|
|
dto.longitude,
|
|
dto.address,
|
|
dto.district,
|
|
dto.province,
|
|
dto.region,
|
|
dto.totalAreaHa,
|
|
dto.leasableAreaHa,
|
|
dto.occupancyRate,
|
|
dto.remainingAreaHa,
|
|
dto.tenantCount ?? 0,
|
|
dto.establishedYear ?? null,
|
|
dto.landRentUsdM2Year ?? null,
|
|
dto.rbfRentUsdM2Month ?? null,
|
|
dto.rbwRentUsdM2Month ?? null,
|
|
dto.managementFeeUsd ?? null,
|
|
dto.infrastructure ?? null,
|
|
dto.connectivity ?? null,
|
|
dto.incentives ?? null,
|
|
dto.targetIndustries,
|
|
dto.description ?? null,
|
|
dto.descriptionEn ?? null,
|
|
ownerId,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Cập nhật KCN (admin)', description: 'Cập nhật thông tin KCN' })
|
|
@ApiResponse({ status: 200, description: 'KCN đã cập nhật' })
|
|
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
|
|
@Patch('parks/:id')
|
|
async updatePark(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateIndustrialParkDto,
|
|
) {
|
|
return this.commandBus.execute(
|
|
new UpdateIndustrialParkCommand(
|
|
id,
|
|
user.sub,
|
|
user.role as UserRole,
|
|
dto.name,
|
|
dto.nameEn,
|
|
dto.developer,
|
|
dto.operator,
|
|
dto.status,
|
|
dto.occupancyRate,
|
|
dto.remainingAreaHa,
|
|
dto.tenantCount,
|
|
dto.landRentUsdM2Year,
|
|
dto.rbfRentUsdM2Month,
|
|
dto.rbwRentUsdM2Month,
|
|
dto.managementFeeUsd,
|
|
dto.infrastructure,
|
|
dto.connectivity,
|
|
dto.incentives,
|
|
dto.targetIndustries,
|
|
dto.description,
|
|
dto.descriptionEn,
|
|
dto.isVerified,
|
|
dto.ownerId,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Xóa KCN (admin)', description: 'Xóa vĩnh viễn khu công nghiệp' })
|
|
@ApiResponse({ status: 200, description: 'KCN đã xóa' })
|
|
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.ADMIN, UserRole.PARK_OPERATOR)
|
|
@Delete('parks/:id')
|
|
async deletePark(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Param('id') id: string,
|
|
): Promise<{ success: true }> {
|
|
await this.commandBus.execute(
|
|
new DeleteIndustrialParkCommand(id, user.sub, user.role as UserRole),
|
|
);
|
|
return { success: true };
|
|
}
|
|
}
|