Files
goodgo-platform/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts
Ho Ngoc Hai 33a5ff407b
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
feat(auth): add DEVELOPER + PARK_OPERATOR roles with owner scoping (B2B accounts)
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>
2026-04-20 22:12:16 +07:00

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 };
}
}