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
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:
@@ -37,7 +37,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
|
|||||||
ST_X(p.location::geometry) as lng,
|
ST_X(p.location::geometry) as lng,
|
||||||
COUNT(pr.id)::int as "propertyCount"
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
FROM "ProjectDevelopment" p
|
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}
|
WHERE p.slug = ${slug}
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -52,7 +52,7 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
|
|||||||
ST_X(p.location::geometry) as lng,
|
ST_X(p.location::geometry) as lng,
|
||||||
COUNT(pr.id)::int as "propertyCount"
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
FROM "ProjectDevelopment" p
|
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}
|
WHERE p.id = ${id}
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
LIMIT 1
|
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,
|
`SELECT p.*, ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng,
|
||||||
COUNT(pr.id)::int as "propertyCount"
|
COUNT(pr.id)::int as "propertyCount"
|
||||||
FROM "ProjectDevelopment" p
|
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)}
|
WHERE ${where.replace(/\b(\$\d+)/g, (_, m) => m)}
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY p."createdAt" DESC
|
ORDER BY p."createdAt" DESC
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ import { CreateProjectDto } from '../dto/create-project.dto';
|
|||||||
import { SearchProjectsDto } from '../dto/search-projects.dto';
|
import { SearchProjectsDto } from '../dto/search-projects.dto';
|
||||||
import { UpdateProjectDto } from '../dto/update-project.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')
|
@ApiTags('projects')
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
@@ -26,7 +49,10 @@ export class ProjectsController {
|
|||||||
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
@ApiResponse({ status: 200, description: 'Danh sách dự án phân trang' })
|
||||||
@Get()
|
@Get()
|
||||||
async listProjects(@Query() dto: SearchProjectsDto) {
|
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(
|
new ListProjectsQuery(
|
||||||
dto.q,
|
dto.q,
|
||||||
dto.status,
|
dto.status,
|
||||||
@@ -38,6 +64,7 @@ export class ProjectsController {
|
|||||||
dto.limit ?? 20,
|
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' })
|
@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' })
|
@ApiResponse({ status: 404, description: 'Không tìm thấy dự án' })
|
||||||
@Get(':slugOrId')
|
@Get(':slugOrId')
|
||||||
async getProject(@Param('slugOrId') slugOrId: string) {
|
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) {
|
if (!result) {
|
||||||
throw new NotFoundException('Dự án', slugOrId);
|
throw new NotFoundException('Dự án', slugOrId);
|
||||||
}
|
}
|
||||||
return result;
|
return shapeProject(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin endpoints ───────────────────────────────────────────────
|
// ── Admin endpoints ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user