feat(api): add OpenAPI/Swagger documentation for all API endpoints

Install @nestjs/swagger, configure Swagger UI at /api/docs with JWT bearer
auth, and add ApiTags/ApiOperation/ApiResponse/ApiProperty decorators to
all 8 controllers (50+ endpoints) and 31 DTOs across auth, listings,
search, payments, subscriptions, admin, notifications, and analytics modules.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:08:11 +07:00
parent 325cd4c421
commit 8e7672694b
42 changed files with 531 additions and 3 deletions

View File

@@ -12,6 +12,15 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiConsumes,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
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';
@@ -33,6 +42,7 @@ import { type CreateListingResult } from '../../application/commands/create-list
import { type ListingDetailDto } from '../../application/queries/get-listing/get-listing.handler';
import { type PaginatedResult } from '../../domain/repositories/listing.repository';
@ApiTags('listings')
@Controller('listings')
export class ListingsController {
constructor(
@@ -40,6 +50,11 @@ export class ListingsController {
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create a new property listing' })
@ApiResponse({ status: 201, description: 'Listing created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Post()
async createListing(
@@ -81,6 +96,13 @@ export class ListingsController {
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get listings pending moderation (admin only)' })
@ApiResponse({ status: 200, description: 'Paginated list of pending listings' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@ApiQuery({ name: 'page', required: false, type: Number, example: 1, description: 'Page number' })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 20, description: 'Items per page' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Get('pending')
@@ -93,11 +115,17 @@ export class ListingsController {
);
}
@ApiOperation({ summary: 'Get listing details by ID' })
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
@ApiResponse({ status: 200, description: 'Listing details returned' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@Get(':id')
async getListing(@Param('id') id: string): Promise<ListingDetailDto> {
return this.queryBus.execute(new GetListingQuery(id));
}
@ApiOperation({ summary: 'Search and filter property listings' })
@ApiResponse({ status: 200, description: 'Paginated search results' })
@Get()
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<any>> {
return this.queryBus.execute(
@@ -118,6 +146,12 @@ export class ListingsController {
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Update listing status' })
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 200, description: 'Status updated successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@UseGuards(JwtAuthGuard)
@Patch(':id/status')
async updateStatus(
@@ -130,6 +164,12 @@ export class ListingsController {
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Upload media (photo/video) for a listing' })
@ApiConsumes('multipart/form-data')
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 201, description: 'Media uploaded successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file'))
@Post(':id/media')
@@ -148,6 +188,12 @@ export class ListingsController {
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Moderate a listing (admin only)' })
@ApiParam({ name: 'id', description: 'Listing UUID' })
@ApiResponse({ status: 200, description: 'Listing moderated successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Patch(':id/moderate')

View File

@@ -10,121 +10,150 @@ import {
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { PropertyType, TransactionType, Direction } from '@prisma/client';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateListingDto {
@ApiProperty({ enum: TransactionType, example: 'SALE', description: 'Transaction type (SALE or RENT)' })
@IsEnum(TransactionType)
transactionType!: TransactionType;
@ApiProperty({ type: String, example: '5500000000', description: 'Price in VND (as string to support bigint)' })
@Transform(({ value }) => BigInt(value))
priceVND!: bigint;
@ApiProperty({ enum: PropertyType, example: 'APARTMENT', description: 'Type of property' })
@IsEnum(PropertyType)
propertyType!: PropertyType;
@ApiProperty({ example: 'Căn hộ 3PN view sông Sài Gòn - Vinhomes Central Park', description: 'Listing title (min 5 chars)' })
@IsString()
@MinLength(5)
title!: string;
@ApiProperty({ example: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ, view trực diện sông Sài Gòn. Tiện ích hồ bơi, gym, công viên nội khu.', description: 'Detailed description (min 10 chars)' })
@IsString()
@MinLength(10)
description!: string;
@ApiProperty({ example: '208 Nguyễn Hữu Cảnh', description: 'Street address' })
@IsString()
address!: string;
@ApiProperty({ example: 'Phường 22', description: 'Ward name' })
@IsString()
ward!: string;
@ApiProperty({ example: 'Bình Thạnh', description: 'District name' })
@IsString()
district!: string;
@ApiProperty({ example: 'Hồ Chí Minh', description: 'City / province' })
@IsString()
city!: string;
@ApiProperty({ example: 10.7942, description: 'Latitude (-90 to 90)' })
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.7219, description: 'Longitude (-180 to 180)' })
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@ApiProperty({ example: 85.5, description: 'Total area in square metres' })
@IsNumber()
@Type(() => Number)
@Min(1)
areaM2!: number;
@ApiPropertyOptional({ example: 78.0, description: 'Usable area in square metres' })
@IsOptional()
@IsNumber()
@Type(() => Number)
usableAreaM2?: number;
@ApiPropertyOptional({ example: 3, description: 'Number of bedrooms' })
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@ApiPropertyOptional({ example: 2, description: 'Number of bathrooms' })
@IsOptional()
@IsNumber()
@Type(() => Number)
bathrooms?: number;
@ApiPropertyOptional({ example: 1, description: 'Number of floors (for houses)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
floors?: number;
@ApiPropertyOptional({ example: 15, description: 'Floor number (for apartments)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
floor?: number;
@ApiPropertyOptional({ example: 30, description: 'Total floors in the building' })
@IsOptional()
@IsNumber()
@Type(() => Number)
totalFloors?: number;
@ApiPropertyOptional({ enum: Direction, example: 'EAST', description: 'Primary facing direction' })
@IsOptional()
@IsEnum(Direction)
direction?: Direction;
@ApiPropertyOptional({ example: 2020, description: 'Year the property was built' })
@IsOptional()
@IsNumber()
@Type(() => Number)
yearBuilt?: number;
@ApiPropertyOptional({ example: 'Sổ hồng', description: 'Legal document status (e.g. Sổ hồng, Sổ đỏ, Hợp đồng mua bán)' })
@IsOptional()
@IsString()
legalStatus?: string;
@ApiPropertyOptional({ example: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'], description: 'List of amenities' })
@IsOptional()
@IsArray()
amenities?: string[];
@ApiPropertyOptional({ example: { schools: ['Trường Quốc tế ISHCMC'], hospitals: ['Bệnh viện FV'] }, description: 'Nearby points of interest' })
@IsOptional()
nearbyPOIs?: unknown;
@ApiPropertyOptional({ example: 500, description: 'Distance to nearest metro station in metres' })
@IsOptional()
@IsNumber()
@Type(() => Number)
metroDistanceM?: number;
@ApiPropertyOptional({ example: 'Vinhomes Central Park', description: 'Name of the residential project' })
@IsOptional()
@IsString()
projectName?: string;
@ApiPropertyOptional({ example: 'agent-uuid-1234', description: 'Assigned agent ID' })
@IsOptional()
@IsString()
agentId?: string;
@ApiPropertyOptional({ type: String, example: '25000000', description: 'Monthly rent price in VND (as string to support bigint)' })
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
rentPriceMonthly?: bigint;
@ApiPropertyOptional({ example: 2.5, description: 'Agent commission percentage' })
@IsOptional()
@IsNumber()
@Type(() => Number)

View File

@@ -1,10 +1,13 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ModerateListingDto {
@ApiProperty({ enum: ['approve', 'reject'], example: 'approve', description: 'Moderation action' })
@IsEnum(['approve', 'reject'] as const)
action!: 'approve' | 'reject';
@ApiPropertyOptional({ example: 85, description: 'Moderation confidence score (0-100)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
@@ -12,6 +15,7 @@ export class ModerateListingDto {
@Max(100)
moderationScore?: number;
@ApiPropertyOptional({ example: 'Tin đăng hợp lệ, thông tin chính xác', description: 'Moderator notes' })
@IsOptional()
@IsString()
notes?: string;

View File

@@ -1,57 +1,70 @@
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchListingsDto {
@ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' })
@IsOptional()
@IsEnum(ListingStatus)
status?: ListingStatus;
@ApiPropertyOptional({ enum: TransactionType, example: 'SALE', description: 'Filter by transaction type' })
@IsOptional()
@IsEnum(TransactionType)
transactionType?: TransactionType;
@ApiPropertyOptional({ enum: PropertyType, example: 'APARTMENT', description: 'Filter by property type' })
@IsOptional()
@IsEnum(PropertyType)
propertyType?: PropertyType;
@ApiPropertyOptional({ example: 'Hồ Chí Minh', description: 'Filter by city / province' })
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional({ example: 'Quận 2', description: 'Filter by district' })
@IsOptional()
@IsString()
district?: string;
@ApiPropertyOptional({ type: String, example: '2000000000', description: 'Minimum price in VND' })
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
minPrice?: bigint;
@ApiPropertyOptional({ type: String, example: '10000000000', description: 'Maximum price in VND' })
@IsOptional()
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
maxPrice?: bigint;
@ApiPropertyOptional({ example: 50, description: 'Minimum area in square metres' })
@IsOptional()
@IsNumber()
@Type(() => Number)
minArea?: number;
@ApiPropertyOptional({ example: 200, description: 'Maximum area in square metres' })
@IsOptional()
@IsNumber()
@Type(() => Number)
maxArea?: number;
@ApiPropertyOptional({ example: 2, description: 'Filter by number of bedrooms' })
@IsOptional()
@IsNumber()
@Type(() => Number)
bedrooms?: number;
@ApiPropertyOptional({ example: 1, description: 'Page number (starts at 1)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
@Min(1)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page (max 100)' })
@IsOptional()
@IsNumber()
@Type(() => Number)

View File

@@ -1,10 +1,13 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ListingStatus } from '@prisma/client';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateListingStatusDto {
@ApiProperty({ enum: ListingStatus, example: 'ACTIVE', description: 'New listing status' })
@IsEnum(ListingStatus)
status!: ListingStatus;
@ApiPropertyOptional({ example: 'Đã xác minh thông tin pháp lý', description: 'Optional moderation notes' })
@IsOptional()
@IsString()
moderationNotes?: string;