feat(listings): implement Listings module with CRUD, media upload, and moderation

Full DDD/CQRS implementation for the Listings module (TEC-1423):
- Domain: Property, Listing, PropertyMedia entities with status machine
- Value Objects: Address, GeoPoint, Price with validation
- Events: ListingCreated, ListingApproved, ListingSold
- Commands: CreateListing, UpdateListingStatus, UploadMedia, ModerateListing
- Queries: GetListing, SearchListings, GetPendingModeration
- Infrastructure: Prisma repositories with PostGIS support, MinIO media storage
- Presentation: REST controller with JWT auth, role-based moderation
- 21 domain unit tests (all passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:47:15 +07:00
parent 6741592cbe
commit 8a33aae026
50 changed files with 2108 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { ListingsController } from './listings.controller';

View File

@@ -0,0 +1,163 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe';
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command';
import { UploadMediaCommand } from '../../application/commands/upload-media/upload-media.command';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';
import { GetListingQuery } from '../../application/queries/get-listing/get-listing.query';
import { SearchListingsQuery } from '../../application/queries/search-listings/search-listings.query';
import { GetPendingModerationQuery } from '../../application/queries/get-pending-moderation/get-pending-moderation.query';
import { CreateListingDto } from '../dto/create-listing.dto';
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import { ModerateListingDto } from '../dto/moderate-listing.dto';
import { SearchListingsDto } from '../dto/search-listings.dto';
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
import { type ListingDetailDto } from '../../application/queries/get-listing/get-listing.handler';
import { type PaginatedResult } from '../../domain/repositories/listing.repository';
@Controller('listings')
export class ListingsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@UseGuards(JwtAuthGuard)
@Post()
async createListing(
@Body() dto: CreateListingDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateListingResult> {
return this.commandBus.execute(
new CreateListingCommand(
user.sub,
dto.transactionType,
dto.priceVND,
dto.propertyType,
dto.title,
dto.description,
dto.address,
dto.ward,
dto.district,
dto.city,
dto.latitude,
dto.longitude,
dto.areaM2,
dto.usableAreaM2,
dto.bedrooms,
dto.bathrooms,
dto.floors,
dto.floor,
dto.totalFloors,
dto.direction,
dto.yearBuilt,
dto.legalStatus,
dto.amenities,
dto.nearbyPOIs,
dto.metroDistanceM,
dto.projectName,
dto.agentId,
dto.rentPriceMonthly,
dto.commissionPct,
),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Get('pending')
async getPendingModeration(
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<any>> {
return this.queryBus.execute(
new GetPendingModerationQuery(page ?? 1, limit ?? 20),
);
}
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailDto> {
return this.queryBus.execute(new GetListingQuery(id));
}
@Get()
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<any>> {
return this.queryBus.execute(
new SearchListingsQuery(
dto.status,
dto.transactionType,
dto.propertyType,
dto.city,
dto.district,
dto.minPrice,
dto.maxPrice,
dto.minArea,
dto.maxArea,
dto.bedrooms,
dto.page,
dto.limit,
),
);
}
@UseGuards(JwtAuthGuard)
@Patch(':id/status')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateListingStatusDto,
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
);
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file'))
@Post(':id/media')
async uploadMedia(
@Param('id') id: string,
@UploadedFile(new FileValidationPipe({
maxSizeBytes: 10 * 1024 * 1024, // 10 MB
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'],
}))
file: ValidatedFile,
@CurrentUser() user: JwtPayload,
@Body('caption') caption?: string,
): Promise<{ mediaId: string; url: string }> {
return this.commandBus.execute(
new UploadMediaCommand(id, user.sub, file, caption),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Patch(':id/moderate')
async moderateListing(
@Param('id') id: string,
@Body() dto: ModerateListingDto,
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new ModerateListingCommand(id, user.sub, dto.action, dto.moderationScore, dto.notes),
);
}
}

View File

@@ -0,0 +1,132 @@
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
MinLength,
Min,
Max,
IsArray,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { PropertyType, TransactionType, Direction } from '@prisma/client';
export class CreateListingDto {
@IsEnum(TransactionType)
transactionType!: TransactionType;
@Transform(({ value }) => BigInt(value))
priceVND!: bigint;
@IsEnum(PropertyType)
propertyType!: PropertyType;
@IsString()
@MinLength(5)
title!: string;
@IsString()
@MinLength(10)
description!: string;
@IsString()
address!: string;
@IsString()
ward!: string;
@IsString()
district!: string;
@IsString()
city!: string;
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@IsNumber()
@Type(() => Number)
@Min(1)
areaM2!: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
usableAreaM2?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bathrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
floors?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
floor?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
totalFloors?: number;
@IsOptional()
@IsEnum(Direction)
direction?: Direction;
@IsOptional()
@IsNumber()
@Type(() => Number)
yearBuilt?: number;
@IsOptional()
@IsString()
legalStatus?: string;
@IsOptional()
@IsArray()
amenities?: string[];
@IsOptional()
nearbyPOIs?: unknown;
@IsOptional()
@IsNumber()
@Type(() => Number)
metroDistanceM?: number;
@IsOptional()
@IsString()
projectName?: string;
@IsOptional()
@IsString()
agentId?: string;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
rentPriceMonthly?: bigint;
@IsOptional()
@IsNumber()
@Type(() => Number)
commissionPct?: number;
}

View File

@@ -0,0 +1,4 @@
export { CreateListingDto } from './create-listing.dto';
export { UpdateListingStatusDto } from './update-listing-status.dto';
export { ModerateListingDto } from './moderate-listing.dto';
export { SearchListingsDto } from './search-listings.dto';

View File

@@ -0,0 +1,18 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class ModerateListingDto {
@IsEnum(['approve', 'reject'] as const)
action!: 'approve' | 'reject';
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(0)
@Max(100)
moderationScore?: number;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,61 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
export class SearchListingsDto {
@IsOptional()
@IsEnum(ListingStatus)
status?: ListingStatus;
@IsOptional()
@IsEnum(TransactionType)
transactionType?: TransactionType;
@IsOptional()
@IsEnum(PropertyType)
propertyType?: PropertyType;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
minPrice?: bigint;
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
maxPrice?: bigint;
@IsOptional()
@IsNumber()
@Type(() => Number)
minArea?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
maxArea?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ListingStatus } from '@prisma/client';
export class UpdateListingStatusDto {
@IsEnum(ListingStatus)
status!: ListingStatus;
@IsOptional()
@IsString()
moderationNotes?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './controllers';
export * from './dto';