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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user