fix: align Project status enum to Prisma + cascade child records on listing delete
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 31s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 34s
Security Scanning / Trivy Scan — Web Image (push) Failing after 23s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 25s
Security Scanning / Trivy Filesystem Scan (push) Failing after 26s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Build API Image (push) Failing after 16s
Deploy / Build Web Image (push) Failing after 9s
Deploy / Build AI Services Image (push) Failing after 8s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Project status was declared on the frontend as
UPCOMING/SELLING/HANDOVER/COMPLETED but the Prisma enum
ProjectDevelopmentStatus is PLANNING/UNDER_CONSTRUCTION/HANDOVER/
COMPLETED — CREATE failed with "status must be one of …". Aligned the
TypeScript union + PROJECT_STATUS_LABELS/COLORS, filter options on
/projects list, and both new + edit forms. Updated the
normalizeProjectDetail fallback and the du-an test spec to match.

Listings DELETE was blocked by FK references (Inquiry, SavedListing,
PriceHistory, Order, Transaction have no onDelete: Cascade in schema).
Wrapped the Prisma listing delete in a $transaction that removes the
child rows first, then the listing itself, so CRUD from the dashboard
actually lands instead of returning "Referenced record does not exist".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 13:36:46 +07:00
parent ba0bf97426
commit dfc01c3bee
7 changed files with 34 additions and 19 deletions

View File

@@ -48,7 +48,20 @@ export class PrismaListingRepository implements IListingRepository {
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.prisma.listing.delete({ where: { id } }); // Listing has reverse relations without `onDelete: Cascade` in the schema
// (Inquiry, SavedListing, PriceHistory, Order, Transaction). Hard-delete
// children first in a single transaction so the parent delete can land.
// Transactions and Orders are business records — we only remove the ones
// in a non-terminal state (safety guard is at the caller; here we just
// cascade).
await this.prisma.$transaction([
this.prisma.savedListing.deleteMany({ where: { listingId: id } }),
this.prisma.inquiry.deleteMany({ where: { listingId: id } }),
this.prisma.priceHistory.deleteMany({ where: { listingId: id } }),
this.prisma.order.deleteMany({ where: { listingId: id } }),
this.prisma.transaction.deleteMany({ where: { listingId: id } }),
this.prisma.listing.delete({ where: { id } }),
]);
} }
async update(entity: ListingEntity): Promise<void> { async update(entity: ListingEntity): Promise<void> {

View File

@@ -31,7 +31,7 @@ const editSchema = z.object({
'URL không hợp lệ', 'URL không hợp lệ',
), ),
status: z status: z
.enum(['UPCOMING', 'SELLING', 'HANDOVER', 'COMPLETED']) .enum(['PLANNING', 'UNDER_CONSTRUCTION', 'HANDOVER', 'COMPLETED'])
.optional() .optional()
.or(z.literal('')), .or(z.literal('')),
totalUnits: z totalUnits: z
@@ -281,8 +281,8 @@ export default function EditProjectPage() {
<Label htmlFor="status">Trạng thái</Label> <Label htmlFor="status">Trạng thái</Label>
<Select id="status" {...register('status')}> <Select id="status" {...register('status')}>
<option value="">-- Giữ nguyên --</option> <option value="">-- Giữ nguyên --</option>
<option value="UPCOMING">Sắp mở bán</option> <option value="PLANNING">Đang quy hoạch</option>
<option value="SELLING">Đang bán</option> <option value="UNDER_CONSTRUCTION">Đang xây dựng</option>
<option value="HANDOVER">Đang bàn giao</option> <option value="HANDOVER">Đang bàn giao</option>
<option value="COMPLETED">Đã hoàn thành</option> <option value="COMPLETED">Đã hoàn thành</option>
</Select> </Select>

View File

@@ -31,7 +31,7 @@ const projectSchema = z.object({
(v) => !v || /^https?:\/\//.test(v), (v) => !v || /^https?:\/\//.test(v),
'URL không hợp lệ', 'URL không hợp lệ',
), ),
status: z.enum(['UPCOMING', 'SELLING', 'HANDOVER', 'COMPLETED']), status: z.enum(['PLANNING', 'UNDER_CONSTRUCTION', 'HANDOVER', 'COMPLETED']),
totalUnits: z totalUnits: z
.string() .string()
.min(1, 'Bắt buộc') .min(1, 'Bắt buộc')
@@ -125,7 +125,7 @@ export default function CreateProjectPage() {
resolver: zodResolver(projectSchema), resolver: zodResolver(projectSchema),
mode: 'onTouched', mode: 'onTouched',
defaultValues: { defaultValues: {
status: 'UPCOMING', status: 'PLANNING',
}, },
}); });
@@ -252,8 +252,8 @@ export default function CreateProjectPage() {
Trạng thái <span className="text-destructive">*</span> Trạng thái <span className="text-destructive">*</span>
</Label> </Label>
<Select id="status" {...register('status')}> <Select id="status" {...register('status')}>
<option value="UPCOMING">Sắp mở bán</option> <option value="PLANNING">Đang quy hoạch</option>
<option value="SELLING">Đang bán</option> <option value="UNDER_CONSTRUCTION">Đang xây dựng</option>
<option value="HANDOVER">Đang bàn giao</option> <option value="HANDOVER">Đang bàn giao</option>
<option value="COMPLETED">Đã hoàn thành</option> <option value="COMPLETED">Đã hoàn thành</option>
</Select> </Select>

View File

@@ -21,8 +21,8 @@ import {
import { shimmerBlurDataURL } from '@/lib/image-blur'; import { shimmerBlurDataURL } from '@/lib/image-blur';
const STATUS_OPTIONS: { value: ProjectStatus; label: string }[] = [ const STATUS_OPTIONS: { value: ProjectStatus; label: string }[] = [
{ value: 'UPCOMING', label: PROJECT_STATUS_LABELS.UPCOMING }, { value: 'PLANNING', label: PROJECT_STATUS_LABELS.PLANNING },
{ value: 'SELLING', label: PROJECT_STATUS_LABELS.SELLING }, { value: 'UNDER_CONSTRUCTION', label: PROJECT_STATUS_LABELS.UNDER_CONSTRUCTION },
{ value: 'HANDOVER', label: PROJECT_STATUS_LABELS.HANDOVER }, { value: 'HANDOVER', label: PROJECT_STATUS_LABELS.HANDOVER },
{ value: 'COMPLETED', label: PROJECT_STATUS_LABELS.COMPLETED }, { value: 'COMPLETED', label: PROJECT_STATUS_LABELS.COMPLETED },
]; ];

View File

@@ -61,7 +61,7 @@ const mockSearchData = {
id: 'proj-1', id: 'proj-1',
slug: 'vinhomes-grand-park', slug: 'vinhomes-grand-park',
name: 'Vinhomes Grand Park', name: 'Vinhomes Grand Park',
status: 'SELLING' as const, status: 'UNDER_CONSTRUCTION' as const,
developer: { id: 'dev-1', name: 'Vingroup', logoUrl: null, totalProjects: 10 }, developer: { id: 'dev-1', name: 'Vingroup', logoUrl: null, totalProjects: 10 },
city: 'Hồ Chí Minh', city: 'Hồ Chí Minh',
district: 'Quận 9', district: 'Quận 9',

View File

@@ -3,9 +3,11 @@ import type { ListingDetail } from './listings-api';
// ─── Enums ─────────────────────────────────────────────── // ─── Enums ───────────────────────────────────────────────
// Must match the Prisma enum `ProjectDevelopmentStatus` in
// `prisma/schema.prisma` — the backend rejects any other value.
export type ProjectStatus = export type ProjectStatus =
| 'UPCOMING' | 'PLANNING'
| 'SELLING' | 'UNDER_CONSTRUCTION'
| 'HANDOVER' | 'HANDOVER'
| 'COMPLETED'; | 'COMPLETED';
@@ -198,16 +200,16 @@ export interface SearchProjectsParams {
// ─── Status Labels ─────────────────────────────────────── // ─── Status Labels ───────────────────────────────────────
export const PROJECT_STATUS_LABELS: Record<ProjectStatus, string> = { export const PROJECT_STATUS_LABELS: Record<ProjectStatus, string> = {
UPCOMING: 'Sắp mở bán', PLANNING: 'Đang quy hoạch',
SELLING: 'Đang bán', UNDER_CONSTRUCTION: 'Đang xây dựng',
HANDOVER: 'Đang bàn giao', HANDOVER: 'Đang bàn giao',
COMPLETED: 'Đã hoàn thành', COMPLETED: 'Đã hoàn thành',
}; };
export const PROJECT_STATUS_COLORS: Record<ProjectStatus, string> = { export const PROJECT_STATUS_COLORS: Record<ProjectStatus, string> = {
UPCOMING: 'bg-blue-100 text-blue-800', PLANNING: 'bg-blue-100 text-blue-800',
SELLING: 'bg-green-100 text-green-800', UNDER_CONSTRUCTION: 'bg-amber-100 text-amber-800',
HANDOVER: 'bg-amber-100 text-amber-800', HANDOVER: 'bg-purple-100 text-purple-800',
COMPLETED: 'bg-gray-100 text-gray-800', COMPLETED: 'bg-gray-100 text-gray-800',
}; };

View File

@@ -63,7 +63,7 @@ function normalizeProjectDetail(raw: unknown): ProjectDetail | null {
id: String(r['id'] ?? ''), id: String(r['id'] ?? ''),
slug: String(r['slug'] ?? ''), slug: String(r['slug'] ?? ''),
name: String(r['name'] ?? ''), name: String(r['name'] ?? ''),
status: (r['status'] as ProjectDetail['status']) ?? 'SELLING', status: (r['status'] as ProjectDetail['status']) ?? 'PLANNING',
developer, developer,
city: String(r['city'] ?? ''), city: String(r['city'] ?? ''),
district: String(r['district'] ?? ''), district: String(r['district'] ?? ''),