fix(api): resolve 500 on GET /projects — column name + shape mismatch
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>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 22:05:15 +07:00
parent 492bd0a043
commit 832e9a4eab
2 changed files with 35 additions and 6 deletions

View File

@@ -37,7 +37,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
ST_X(p.location::geometry) as lng,
COUNT(pr.id)::int as "propertyCount"
FROM "ProjectDevelopment" p
LEFT JOIN "Property" pr ON pr."projectId" = p.id
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
WHERE p.slug = ${slug}
GROUP BY p.id
LIMIT 1
@@ -52,7 +52,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
ST_X(p.location::geometry) as lng,
COUNT(pr.id)::int as "propertyCount"
FROM "ProjectDevelopment" p
LEFT JOIN "Property" pr ON pr."projectId" = p.id
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
WHERE p.id = ${id}
GROUP BY p.id
LIMIT 1
@@ -163,7 +163,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
`SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
COUNT(pr.id)::int as "propertyCount"
FROM "ProjectDevelopment" p
LEFT JOIN "Property" pr ON pr."projectId" = p.id
LEFT JOIN "Property" pr ON pr."projectDevelopmentId" = p.id
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)}
GROUP BY p.id
ORDER BY p."createdAt" DESC

View File

@@ -12,6 +12,29 @@ 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 {
@@ -26,7 +49,10 @@ export class ProjectsController {
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
@Get()
async listProjects(@Query() dto: SearchProjectsDto) {
return this.queryBus.execute(
const result = await this.queryBus.execute<
ListProjectsQuery,
{ data: RawProjectListItem[]; total: number; page: number; limit: number; totalPages: number }
>(
new ListProjectsQuery(
dto.q,
dto.status,
@@ -38,6 +64,7 @@ export class ProjectsController {
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' })
@@ -45,11 +72,13 @@ export class ProjectsController {
@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(new GetProjectQuery(slugOrId));
const result = await this.queryBus.execute<GetProjectQuery, RawProjectListItem | null>(
new GetProjectQuery(slugOrId),
);
if (!result) {
throw new NotFoundException('Dự án', slugOrId);
}
return result;
return shapeProject(result);
}
// ── Admin endpoints ───────────────────────────────────────────────