Files
goodgo-platform/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts
Ho Ngoc Hai 832e9a4eab
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
fix(api): resolve 500 on GET /projects — column name + shape mismatch
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>
2026-04-18 22:05:15 +07:00

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