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,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)',