fix(du-an): stop detail page crash from thin backend payload + client/server flag boundary
Some checks failed
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m28s
Deploy / Build API Image (push) Failing after 26s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 23s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — API Image (push) Has been cancelled

- Split `isResidentialProjectsEnabledServer` out of the `'use client'`
  hook file into `lib/feature-flags/residential-projects.ts` so Server
  Components can import it without Next.js treating it as a client ref.
- Detail endpoint preserves `media` via new `shapeProjectDetail`
  instead of stripping it in `shapeProject`.
- `fetchProjectBySlug` now normalizes the response: fills missing
  arrays (media, blocks, amenities, priceRanges, priceHistory,
  neighborhoodScores, pois, documents) with `[]`, remaps
  `developer.logo` → `logoUrl`, defaults `totalProjects` to 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 10:11:06 +07:00
parent 2f07b374d9
commit 6ff039db1e
5 changed files with 115 additions and 7 deletions

View File

@@ -35,6 +35,16 @@ function shapeProject<T extends RawProjectListItem>(row: T) {
};
}
function shapeProjectDetail<T extends RawProjectListItem>(row: T) {
// Detail endpoint: preserve `media` so the gallery on the frontend can
// render, and keep the same shape as the list response for everything else.
const shaped = shapeProject(row);
return {
...shaped,
media: Array.isArray(row.media) ? row.media : [],
};
}
@ApiTags('projects')
@Controller('projects')
export class ProjectsController {
@@ -78,7 +88,7 @@ export class ProjectsController {
if (!result) {
throw new NotFoundException('Dự án', slugOrId);
}
return shapeProject(result);
return shapeProjectDetail(result);
}
// ── Admin endpoints ───────────────────────────────────────────────

View File

@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { DuAnDetailClient } from '@/components/du-an/du-an-detail-client';
import { fetchProjectBySlug } from '@/lib/du-an-server';
import { isResidentialProjectsEnabledServer } from '@/lib/hooks/use-residential-projects-flag';
import { isResidentialProjectsEnabledServer } from '@/lib/feature-flags/residential-projects';
interface PageProps {
params: Promise<{ slug: string; locale: string }>;

View File

@@ -5,10 +5,96 @@
* inside `generateMetadata`, `generateStaticParams`, `sitemap()`, etc.
*/
import type { ProjectDetail, ProjectSummary, PaginatedResult } from './du-an-api';
import type {
ProjectDetail,
ProjectMedia,
ProjectSummary,
PaginatedResult,
} from './du-an-api';
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
/**
* The backend detail endpoint currently returns a thin projection: the
* repository's raw row run through `shapeProject`, which strips `media` and
* omits the richer fields (`blocks`, `amenities`, `priceRanges`, …) that the
* frontend detail page consumes.
*
* Until the backend detail endpoint is enriched, normalize the payload here
* so the UI never crashes on missing arrays or mis-keyed developer fields.
*/
function normalizeProjectDetail(raw: unknown): ProjectDetail | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
// Developer: backend currently returns `{ id, name, logo }`; frontend expects
// `{ id, name, logoUrl, totalProjects }`.
const rawDeveloper = (r['developer'] ?? {}) as Record<string, unknown>;
const developer = {
id: typeof rawDeveloper['id'] === 'string' ? rawDeveloper['id'] : '',
name: typeof rawDeveloper['name'] === 'string' ? rawDeveloper['name'] : '',
logoUrl:
(rawDeveloper['logoUrl'] as string | null | undefined) ??
(rawDeveloper['logo'] as string | null | undefined) ??
null,
totalProjects:
typeof rawDeveloper['totalProjects'] === 'number' ? rawDeveloper['totalProjects'] : 0,
};
// Media: may be absent (stripped by backend), or present as a raw JSON array.
const rawMedia = Array.isArray(r['media']) ? (r['media'] as Record<string, unknown>[]) : [];
const media: ProjectMedia[] = rawMedia.map((m, idx) => ({
id: typeof m['id'] === 'string' ? m['id'] : `media-${idx}`,
url: typeof m['url'] === 'string' ? m['url'] : '',
type:
m['type'] === 'video' ||
m['type'] === 'master_plan' ||
m['type'] === 'document' ||
m['type'] === 'image'
? (m['type'] as ProjectMedia['type'])
: 'image',
order: typeof m['order'] === 'number' ? m['order'] : idx,
caption: typeof m['caption'] === 'string' ? m['caption'] : null,
}));
const asArray = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
return {
id: String(r['id'] ?? ''),
slug: String(r['slug'] ?? ''),
name: String(r['name'] ?? ''),
status: (r['status'] as ProjectDetail['status']) ?? 'SELLING',
developer,
city: String(r['city'] ?? ''),
district: String(r['district'] ?? ''),
address: String(r['address'] ?? ''),
latitude: typeof r['latitude'] === 'number' ? (r['latitude'] as number) : null,
longitude: typeof r['longitude'] === 'number' ? (r['longitude'] as number) : null,
thumbnailUrl:
(r['thumbnailUrl'] as string | null | undefined) ??
media[0]?.url ??
null,
totalArea: typeof r['totalArea'] === 'number' ? (r['totalArea'] as number) : 0,
totalUnits: typeof r['totalUnits'] === 'number' ? (r['totalUnits'] as number) : 0,
propertyTypes: asArray<ProjectDetail['propertyTypes'][number]>(r['propertyTypes']),
minPrice: (r['minPrice'] as string | null | undefined) ?? null,
maxPrice: (r['maxPrice'] as string | null | undefined) ?? null,
completionDate: (r['completionDate'] as string | null | undefined) ?? null,
createdAt: String(r['createdAt'] ?? new Date().toISOString()),
description: typeof r['description'] === 'string' ? (r['description'] as string) : '',
media,
blocks: asArray(r['blocks']),
amenities: asArray(r['amenities']),
priceRanges: asArray(r['priceRanges']),
priceHistory: asArray(r['priceHistory']),
neighborhoodScores: asArray(r['neighborhoodScores']),
pois: asArray(r['pois']),
documents: asArray(r['documents']),
linkedListingCount:
typeof r['linkedListingCount'] === 'number' ? (r['linkedListingCount'] as number) : 0,
};
}
/**
* Fetch a single project by slug — server-only.
* Returns `null` when the project is not found (404) so callers can `notFound()`.
@@ -20,7 +106,8 @@ export async function fetchProjectBySlug(slug: string): Promise<ProjectDetail |
});
if (!res.ok) return null;
return (await res.json()) as ProjectDetail;
const raw = (await res.json()) as unknown;
return normalizeProjectDetail(raw);
} catch {
return null;
}

View File

@@ -0,0 +1,12 @@
/**
* Server-side feature flag for the residential projects (Dự án) module.
*
* Kept in its own module (no `'use client'`) so that Server Components can
* import it without Next.js treating it as a client boundary export. The
* client-side hook lives in `@/lib/hooks/use-residential-projects-flag`.
*/
export function isResidentialProjectsEnabledServer(): boolean {
const raw = process.env['NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS'];
if (raw == null || raw === '') return true;
return !(raw === '0' || raw.toLowerCase() === 'false');
}

View File

@@ -54,6 +54,5 @@ export function useResidentialProjectsFlag(): boolean {
return enabled;
}
export function isResidentialProjectsEnabledServer(): boolean {
return readEnvDefault();
}
// NOTE: The server-side helper lives in `@/lib/feature-flags/residential-projects`
// to avoid making it a client-only export (this file has `'use client'`).