feat: dashboard CRUD for Projects + Industrial Parks, listings delete, BĐS homepage card
Some checks failed
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m15s
Deploy / Build API Image (push) Failing after 20s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 16s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 35s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
Backup Verification / Backup Restore Verification (push) Failing after 14m37s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m4s
Security Scanning / Trivy Scan — Web Image (push) Failing after 36s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m6s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 8s
CI / E2E Tests (push) Has been skipped
Security Scanning / Security Gate (push) Has been cancelled

Backend — DELETE endpoints (hard delete, ADMIN or owner):
- DELETE /projects/:id (Admin) — new DeleteProjectCommand/Handler,
  repository.delete() adapter, module wiring.
- DELETE /industrial/parks/:id (Admin) — same pattern.
- DELETE /listings/:id (JWT + owner-or-Admin check in handler).

Frontend — API clients:
- lib/du-an-api.ts: add create/update/delete + CreateProjectPayload,
  UpdateProjectPayload types.
- lib/khu-cong-nghiep-api.ts: add createPark/updatePark/deletePark +
  Create/Update payload types.
- lib/listings-api.ts: add delete().

Dashboard pages — new:
- /projects (Quản lý dự án): list with filters + edit/delete actions,
  /projects/new form (sectioned Cards, zod-validated), /projects/[id]/edit
  with danger-zone delete.
- /industrial-parks (Quản lý KCN): same triad. Fix occupancy-rate display
  (percentage already 0-100, no need to *100).

Dashboard listings page:
- Add Edit/Delete row actions with confirm + useMutation; error banner
  on mutation failure. Table view gains a "Thao tác" column; list view
  gains a footer action bar below each card.

Dashboard nav:
- Catalog group: /du-an → /projects (Quản lý dự án), /khu-cong-nghiep
  → /industrial-parks (Quản lý KCN). Desktop primaryNav updated too.

Public homepage:
- Add "Bất động sản" as a 5th feature card/tab → /search, using
  listingsApi for the "Featured listings" section.
- Bump grid to lg:grid-cols-5, update features subtitle copy ("Năm/Five
  core services").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 10:37:33 +07:00
parent d2488b1cc1
commit ba0bf97426
32 changed files with 2843 additions and 22 deletions

View File

@@ -0,0 +1,3 @@
export class DeleteIndustrialParkCommand {
constructor(public readonly id: string) {}
}

View File

@@ -0,0 +1,25 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
INDUSTRIAL_PARK_REPOSITORY,
type IIndustrialParkRepository,
} from '../../../domain/repositories/industrial-park.repository';
import { DeleteIndustrialParkCommand } from './delete-industrial-park.command';
@CommandHandler(DeleteIndustrialParkCommand)
export class DeleteIndustrialParkHandler implements ICommandHandler<DeleteIndustrialParkCommand> {
constructor(
@Inject(INDUSTRIAL_PARK_REPOSITORY)
private readonly repo: IIndustrialParkRepository,
) {}
async execute(cmd: DeleteIndustrialParkCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Khu công nghiệp', cmd.id);
}
await this.repo.delete(cmd.id);
}
}

View File

@@ -110,6 +110,7 @@ export interface IIndustrialParkRepository {
findDetailById(id: string): Promise<IndustrialParkDetailData | null>;
save(entity: IndustrialParkEntity): Promise<void>;
update(entity: IndustrialParkEntity): Promise<void>;
delete(id: string): Promise<void>;
search(params: IndustrialParkSearchParams): Promise<PaginatedResult<IndustrialParkListItem>>;
compareParks(ids: string[]): Promise<IndustrialParkDetailData[]>;
getStats(): Promise<IndustrialParkStatsData>;

View File

@@ -4,6 +4,7 @@ import { SearchModule } from '@modules/search';
import { CreateIndustrialListingHandler } from './application/commands/create-industrial-listing/create-industrial-listing.handler';
import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler';
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { DeleteIndustrialParkHandler } from './application/commands/delete-industrial-park/delete-industrial-park.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
@@ -26,6 +27,7 @@ import { IndustrialParksController } from './presentation/controllers/industrial
const CommandHandlers = [
CreateIndustrialParkHandler,
UpdateIndustrialParkHandler,
DeleteIndustrialParkHandler,
CreateIndustrialListingHandler,
UpdateIndustrialListingHandler,
DeleteIndustrialListingHandler,

View File

@@ -95,6 +95,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
`;
}
async delete(id: string): Promise<void> {
await this.prisma.industrialPark.delete({ where: { id } });
}
async update(entity: IndustrialParkEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "IndustrialPark" SET

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, 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';
@@ -6,6 +6,7 @@ import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command';
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
@@ -195,4 +196,18 @@ export class IndustrialParksController {
),
);
}
@ApiOperation({ summary: 'Xóa KCN (admin)', description: 'Xóa vĩnh viễn khu công nghiệp' })
@ApiResponse({ status: 200, description: 'KCN đã xóa' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete('parks/:id')
async deletePark(@Param('id') id: string): Promise<{ success: true }> {
await this.commandBus.execute(new DeleteIndustrialParkCommand(id));
return { success: true };
}
}

View File

@@ -0,0 +1,7 @@
export class DeleteListingCommand {
constructor(
public readonly listingId: string,
public readonly userId: string,
public readonly userRole?: string,
) {}
}

View File

@@ -0,0 +1,29 @@
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { DeleteListingCommand } from './delete-listing.command';
@CommandHandler(DeleteListingCommand)
export class DeleteListingHandler implements ICommandHandler<DeleteListingCommand> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
) {}
async execute(command: DeleteListingCommand): Promise<void> {
const listing = await this.listingRepo.findById(command.listingId);
if (!listing) {
throw new NotFoundException('Listing', command.listingId);
}
const isOwner = listing.sellerId === command.userId;
const isAdmin = command.userRole === 'ADMIN';
if (!isOwner && !isAdmin) {
throw new ForbiddenException(
'Chỉ người bán hoặc quản trị viên mới có thể xóa tin đăng',
);
}
await this.listingRepo.delete(command.listingId);
}
}

View File

@@ -32,6 +32,7 @@ export interface IListingRepository {
findByIdWithProperty(id: string): Promise<ListingDetailData | null>;
save(listing: ListingEntity): Promise<void>;
update(listing: ListingEntity): Promise<void>;
delete(id: string): Promise<void>;
search(params: ListingSearchParams): Promise<PaginatedResult<ListingSearchItem>>;
findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<ListingSearchItem>>;
findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<ListingSellerItem>>;

View File

@@ -47,6 +47,10 @@ export class PrismaListingRepository implements IListingRepository {
});
}
async delete(id: string): Promise<void> {
await this.prisma.listing.delete({ where: { id } });
}
async update(entity: ListingEntity): Promise<void> {
await this.prisma.listing.update({
where: { id: entity.id },

View File

@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express';
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
import { DeleteListingHandler } from './application/commands/delete-listing/delete-listing.handler';
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
@@ -36,6 +37,7 @@ const CommandHandlers = [
UpdateListingStatusHandler,
UploadMediaHandler,
ModerateListingHandler,
DeleteListingHandler,
];
const QueryHandlers = [

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Ip,
Param,
@@ -31,6 +32,7 @@ import { NotFoundException, EndpointRateLimit, EndpointRateLimitGuard, FileValid
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import type { CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { DeleteListingCommand } from '../../application/commands/delete-listing/delete-listing.command';
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
@@ -352,6 +354,23 @@ export class ListingsController {
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Delete a listing (owner or admin)' })
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 200, description: 'Listing deleted successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not the seller or admin' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@UseGuards(JwtAuthGuard)
@Delete(':id')
async deleteListing(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ success: true }> {
await this.commandBus.execute(new DeleteListingCommand(id, user.sub, user.role));
return { success: true };
}
@ApiBearerAuth('JWT')
@ApiOperation({
summary: 'Promote a listing via subscription entitlement (no payment)',

View File

@@ -0,0 +1,3 @@
export class DeleteProjectCommand {
constructor(public readonly id: string) {}
}

View File

@@ -0,0 +1,25 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project-development.repository';
import { DeleteProjectCommand } from './delete-project.command';
@CommandHandler(DeleteProjectCommand)
export class DeleteProjectHandler implements ICommandHandler<DeleteProjectCommand> {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly repo: IProjectRepository,
) {}
async execute(cmd: DeleteProjectCommand): Promise<void> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Dự án', cmd.id);
}
await this.repo.delete(cmd.id);
}
}

View File

@@ -68,5 +68,6 @@ export interface IProjectRepository {
findDetailById(id: string): Promise<ProjectDetailData | null>;
save(entity: ProjectDevelopmentEntity): Promise<void>;
update(entity: ProjectDevelopmentEntity): Promise<void>;
delete(id: string): Promise<void>;
search(params: ProjectSearchParams): Promise<PaginatedResult<ProjectListItem>>;
}

View File

@@ -91,6 +91,10 @@ export class PrismaProjectDevelopmentRepository implements IProjectRepository {
`;
}
async delete(id: string): Promise<void> {
await this.prisma.projectDevelopment.delete({ where: { id } });
}
async update(entity: ProjectDevelopmentEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "ProjectDevelopment" SET

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, 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 { DeleteProjectCommand } from '../../application/commands/delete-project/delete-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';
@@ -172,4 +173,18 @@ export class ProjectsController {
),
);
}
@ApiOperation({ summary: 'Xóa dự án (admin)', description: 'Xóa vĩnh viễn dự án bất động sản' })
@ApiResponse({ status: 200, description: 'Dự án đã xóa' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tài nguyên' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Delete(':id')
async deleteProject(@Param('id') id: string): Promise<{ success: true }> {
await this.commandBus.execute(new DeleteProjectCommand(id));
return { success: true };
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CreateProjectHandler } from './application/commands/create-project/create-project.handler';
import { DeleteProjectHandler } from './application/commands/delete-project/delete-project.handler';
import { UpdateProjectHandler } from './application/commands/update-project/update-project.handler';
import { GetProjectHandler } from './application/queries/get-project/get-project.handler';
import { ListProjectsHandler } from './application/queries/list-projects/list-projects.handler';
@@ -8,7 +9,7 @@ import { PROJECT_REPOSITORY } from './domain/repositories/project-development.re
import { PrismaProjectDevelopmentRepository } from './infrastructure/repositories/prisma-project-development.repository';
import { ProjectsController } from './presentation/controllers/projects.controller';
const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler];
const CommandHandlers = [CreateProjectHandler, UpdateProjectHandler, DeleteProjectHandler];
const QueryHandlers = [GetProjectHandler, ListProjectsHandler];
@Module({