Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 48s
Deploy / Build API Image (push) Failing after 16s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 28s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 27s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Security Scanning / Trivy Filesystem Scan (push) Failing after 26s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Two bugs masking each other:
1. Raw SQL in PrismaProjectDevelopmentRepository.search() and the
related slug/ID queries joined Property on pr."projectId", but the
actual FK column is "projectDevelopmentId". Postgres raised
"column pr.projectId does not exist", bubbling up as a 500.
2. Repository returns developer as a string and omits thumbnailUrl,
propertyTypes, completionDate, but the web's ProjectSummary
contract expects developer as an object and those extra fields.
After the SQL was fixed, the frontend crashed on
`project.developer.name` with a runtime error screen.
Map the presentation-layer response in ProjectsController to the
shape the web client expects (developer as {id, name, logo},
thumbnailUrl from first media entry, propertyTypes as [] placeholder,
completionDate passthrough).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
5.9 KiB
TypeScript
160 lines
5.9 KiB
TypeScript
import { Body, Controller, 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 { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
|
|
import { NotFoundException } from '@modules/shared';
|
|
import { CreateProjectCommand } from '../../application/commands/create-project/create-project.command';
|
|
import { UpdateProjectCommand } from '../../application/commands/update-project/update-project.command';
|
|
import { GetProjectQuery } from '../../application/queries/get-project/get-project.query';
|
|
import { ListProjectsQuery } from '../../application/queries/list-projects/list-projects.query';
|
|
import { CreateProjectDto } from '../dto/create-project.dto';
|
|
import { SearchProjectsDto } from '../dto/search-projects.dto';
|
|
import { UpdateProjectDto } from '../dto/update-project.dto';
|
|
|
|
interface RawProjectListItem {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
developer: string;
|
|
developerLogo: string | null;
|
|
media?: { url: string; type?: string; order?: number }[] | null;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
function shapeProject<T extends RawProjectListItem>(row: T) {
|
|
const { developer, developerLogo, media, ...rest } = row;
|
|
const thumbnailUrl =
|
|
Array.isArray(media) && media.length > 0 ? (media[0]?.url ?? null) : null;
|
|
return {
|
|
...rest,
|
|
developer: { id: developer, name: developer, logo: developerLogo ?? null },
|
|
thumbnailUrl,
|
|
propertyTypes: [],
|
|
completionDate: (rest as { completionDate?: Date | null }).completionDate ?? null,
|
|
};
|
|
}
|
|
|
|
@ApiTags('projects')
|
|
@Controller('projects')
|
|
export class ProjectsController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
// ── Public endpoints ──────────────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Danh sách dự án', description: 'Tìm kiếm và lọc dự án bất động sản' })
|
|
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
|
@Get()
|
|
async listProjects(@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,
|
|
),
|
|
);
|
|
return { ...result, data: result.data.map(shapeProject) };
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Chi tiết dự án', description: 'Xem chi tiết dự án theo slug hoặc ID' })
|
|
@ApiResponse({ status: 200, description: 'Thông tin chi tiết dự án' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
|
|
@Get(':slugOrId')
|
|
async getProject(@Param('slugOrId') slugOrId: string) {
|
|
const result = await this.queryBus.execute<GetProjectQuery, RawProjectListItem | null>(
|
|
new GetProjectQuery(slugOrId),
|
|
);
|
|
if (!result) {
|
|
throw new NotFoundException('Dự án', slugOrId);
|
|
}
|
|
return shapeProject(result);
|
|
}
|
|
|
|
// ── Admin endpoints ───────────────────────────────────────────────
|
|
|
|
@ApiOperation({ summary: 'Tạo dự án (admin)', description: 'Tạo mới dự án bất động sản' })
|
|
@ApiResponse({ status: 201, description: 'Dự án đã tạo' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.ADMIN)
|
|
@Post()
|
|
async createProject(@Body() dto: CreateProjectDto) {
|
|
return this.commandBus.execute(
|
|
new CreateProjectCommand(
|
|
dto.name,
|
|
dto.slug,
|
|
dto.developer,
|
|
dto.developerLogo ?? null,
|
|
dto.totalUnits,
|
|
dto.status,
|
|
dto.latitude,
|
|
dto.longitude,
|
|
dto.address,
|
|
dto.ward,
|
|
dto.district,
|
|
dto.city,
|
|
dto.description ?? null,
|
|
dto.amenities ?? null,
|
|
dto.masterPlanUrl ?? null,
|
|
dto.minPrice ? BigInt(dto.minPrice) : null,
|
|
dto.maxPrice ? BigInt(dto.maxPrice) : null,
|
|
dto.pricePerM2Range ?? null,
|
|
dto.totalArea ?? null,
|
|
dto.buildingCount ?? null,
|
|
dto.floorCount ?? null,
|
|
dto.unitTypes ?? null,
|
|
dto.tags ?? [],
|
|
dto.startDate ? new Date(dto.startDate) : null,
|
|
dto.completionDate ? new Date(dto.completionDate) : null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Cập nhật dự án (admin)', description: 'Cập nhật thông tin dự án' })
|
|
@ApiResponse({ status: 200, description: 'Dự án đã cập nhật' })
|
|
@ApiBearerAuth('JWT')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(UserRole.ADMIN)
|
|
@Patch(':id')
|
|
async updateProject(@Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
|
return this.commandBus.execute(
|
|
new UpdateProjectCommand(
|
|
id,
|
|
dto.name,
|
|
dto.developer,
|
|
dto.developerLogo,
|
|
dto.totalUnits,
|
|
dto.completedUnits,
|
|
dto.status,
|
|
dto.description,
|
|
dto.amenities,
|
|
dto.masterPlanUrl,
|
|
dto.minPrice !== undefined ? (dto.minPrice ? BigInt(dto.minPrice) : null) : undefined,
|
|
dto.maxPrice !== undefined ? (dto.maxPrice ? BigInt(dto.maxPrice) : null) : undefined,
|
|
dto.pricePerM2Range,
|
|
dto.totalArea,
|
|
dto.buildingCount,
|
|
dto.floorCount,
|
|
dto.unitTypes,
|
|
dto.media,
|
|
dto.documents,
|
|
dto.tags,
|
|
dto.isVerified,
|
|
dto.startDate !== undefined ? (dto.startDate ? new Date(dto.startDate) : null) : undefined,
|
|
dto.completionDate !== undefined ? (dto.completionDate ? new Date(dto.completionDate) : null) : undefined,
|
|
),
|
|
);
|
|
}
|
|
}
|