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
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:
@@ -0,0 +1,3 @@
|
||||
export class DeleteIndustrialParkCommand {
|
||||
constructor(public readonly id: string) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class DeleteListingCommand {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly userId: string,
|
||||
public readonly userRole?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export class DeleteProjectCommand {
|
||||
constructor(public readonly id: string) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user