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

@@ -8,6 +8,7 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
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';
@@ -57,6 +58,8 @@ import {
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@@ -69,6 +72,12 @@ export class AdminController {
// ── Moderation ──
@Get('moderation')
@ApiOperation({ summary: 'Get listing moderation queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'Moderation queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getModerationQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
@@ -82,6 +91,10 @@ export class AdminController {
}
@Post('moderation/approve')
@ApiOperation({ summary: 'Approve a listing' })
@ApiResponse({ status: 201, description: 'Listing approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async approveListing(
@Body() dto: ApproveListingDto,
@CurrentUser() user: JwtPayload,
@@ -92,6 +105,10 @@ export class AdminController {
}
@Post('moderation/reject')
@ApiOperation({ summary: 'Reject a listing' })
@ApiResponse({ status: 201, description: 'Listing rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectListing(
@Body() dto: RejectListingDto,
@CurrentUser() user: JwtPayload,
@@ -102,6 +119,10 @@ export class AdminController {
}
@Post('moderation/bulk')
@ApiOperation({ summary: 'Bulk approve or reject listings' })
@ApiResponse({ status: 201, description: 'Bulk moderation completed successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async bulkModerate(
@Body() dto: BulkModerateDto,
@CurrentUser() user: JwtPayload,
@@ -114,6 +135,15 @@ export class AdminController {
// ── User Management ──
@Get('users')
@ApiOperation({ summary: 'List users with optional filters' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiQuery({ name: 'role', required: false, type: String, description: 'Filter by role (BUYER, SELLER, AGENT, ADMIN)' })
@ApiQuery({ name: 'isActive', required: false, type: String, description: 'Filter by active status (true/false)' })
@ApiQuery({ name: 'search', required: false, type: String, description: 'Search by name or email' })
@ApiResponse({ status: 200, description: 'User list retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@@ -133,6 +163,11 @@ export class AdminController {
}
@Get('users/:id')
@ApiOperation({ summary: 'Get user details by ID' })
@ApiParam({ name: 'id', description: 'User ID' })
@ApiResponse({ status: 200, description: 'User detail retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getUserDetail(
@Param('id') id: string,
): Promise<UserDetail> {
@@ -140,6 +175,10 @@ export class AdminController {
}
@Patch('users/status')
@ApiOperation({ summary: 'Update user active status' })
@ApiResponse({ status: 200, description: 'User status updated successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async updateUserStatus(
@Body() dto: UpdateUserStatusDto,
@CurrentUser() user: JwtPayload,
@@ -150,6 +189,10 @@ export class AdminController {
}
@Post('users/ban')
@ApiOperation({ summary: 'Ban or unban a user' })
@ApiResponse({ status: 201, description: 'User ban status updated successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async banUser(
@Body() dto: BanUserDto,
@CurrentUser() user: JwtPayload,
@@ -162,6 +205,12 @@ export class AdminController {
// ── KYC ──
@Get('kyc')
@ApiOperation({ summary: 'Get KYC verification queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'KYC queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getKycQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
@@ -175,6 +224,10 @@ export class AdminController {
}
@Post('kyc/approve')
@ApiOperation({ summary: 'Approve KYC verification' })
@ApiResponse({ status: 201, description: 'KYC approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async approveKyc(
@Body() dto: ApproveKycDto,
@CurrentUser() user: JwtPayload,
@@ -185,6 +238,10 @@ export class AdminController {
}
@Post('kyc/reject')
@ApiOperation({ summary: 'Reject KYC verification' })
@ApiResponse({ status: 201, description: 'KYC rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectKyc(
@Body() dto: RejectKycDto,
@CurrentUser() user: JwtPayload,
@@ -197,6 +254,10 @@ export class AdminController {
// ── Subscription Management ──
@Post('subscriptions/adjust')
@ApiOperation({ summary: 'Adjust user subscription plan' })
@ApiResponse({ status: 201, description: 'Subscription adjusted successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async adjustSubscription(
@Body() dto: AdjustSubscriptionDto,
@CurrentUser() user: JwtPayload,
@@ -209,11 +270,22 @@ export class AdminController {
// ── Dashboard ──
@Get('dashboard')
@ApiOperation({ summary: 'Get admin dashboard statistics' })
@ApiResponse({ status: 200, description: 'Dashboard stats retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getDashboardStats(): Promise<DashboardStats> {
return this.queryBus.execute(new GetDashboardStatsQuery());
}
@Get('revenue')
@ApiOperation({ summary: 'Get revenue statistics' })
@ApiQuery({ name: 'startDate', required: true, type: String, description: 'Start date (ISO 8601)' })
@ApiQuery({ name: 'endDate', required: true, type: String, description: 'End date (ISO 8601)' })
@ApiQuery({ name: 'groupBy', required: false, enum: ['day', 'month'], description: 'Group results by day or month (default month)' })
@ApiResponse({ status: 200, description: 'Revenue stats retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getRevenueStats(
@Query() dto: RevenueStatsDto,
): Promise<RevenueStatsItem[]> {

View File

@@ -1,12 +1,16 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AdjustSubscriptionDto {
@ApiProperty({ description: 'ID of the user whose subscription to adjust', example: 'usr_abc123' })
@IsString()
userId!: string;
@ApiProperty({ description: 'New subscription plan tier', example: 'premium' })
@IsString()
newPlanTier!: string;
@ApiProperty({ description: 'Reason for the adjustment (min 5 chars)', example: 'Promotional upgrade for early adopter', minLength: 5 })
@IsString()
@MinLength(5)
reason!: string;

View File

@@ -1,9 +1,12 @@
import { IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ApproveKycDto {
@ApiProperty({ description: 'ID of the user whose KYC to approve', example: 'usr_abc123' })
@IsString()
userId!: string;
@ApiPropertyOptional({ description: 'Optional reviewer comments', example: 'Documents verified successfully' })
@IsOptional()
@IsString()
comments?: string;

View File

@@ -1,9 +1,12 @@
import { IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ApproveListingDto {
@ApiProperty({ description: 'ID of the listing to approve', example: 'lst_abc123' })
@IsString()
listingId!: string;
@ApiPropertyOptional({ description: 'Optional moderation notes', example: 'Verified photos and description' })
@IsOptional()
@IsString()
moderationNotes?: string;

View File

@@ -1,13 +1,17 @@
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BanUserDto {
@ApiProperty({ description: 'ID of the user to ban/unban', example: 'usr_abc123' })
@IsString()
userId!: string;
@ApiProperty({ description: 'Reason for ban/unban action (min 5 chars)', example: 'Repeated policy violations', minLength: 5 })
@IsString()
@MinLength(5)
reason!: string;
@ApiPropertyOptional({ description: 'Set to true to unban the user', default: false })
@IsOptional()
@IsBoolean()
unban?: boolean;

View File

@@ -1,15 +1,19 @@
import { IsArray, IsIn, IsOptional, IsString, ArrayMaxSize, ArrayMinSize, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BulkModerateDto {
@ApiProperty({ description: 'Array of listing IDs to moderate (1-50)', example: ['lst_abc123', 'lst_def456'], minItems: 1, maxItems: 50 })
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
@ArrayMaxSize(50)
listingIds!: string[];
@ApiProperty({ description: 'Moderation action to apply', enum: ['approve', 'reject'], example: 'approve' })
@IsIn(['approve', 'reject'])
action!: 'approve' | 'reject';
@ApiPropertyOptional({ description: 'Reason for the moderation action (min 5 chars, required for reject)', example: 'Batch approved after manual review', minLength: 5 })
@IsOptional()
@IsString()
@MinLength(5)

View File

@@ -1,24 +1,30 @@
import { IsOptional, IsString, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class GetUsersQueryDto {
@ApiPropertyOptional({ description: 'Page number', example: '1' })
@IsOptional()
@IsString()
page?: string;
@ApiPropertyOptional({ description: 'Items per page', example: '20' })
@IsOptional()
@IsString()
limit?: string;
@ApiPropertyOptional({ description: 'Filter by user role', enum: ['BUYER', 'SELLER', 'AGENT', 'ADMIN'] })
@IsOptional()
@IsIn(['BUYER', 'SELLER', 'AGENT', 'ADMIN'])
role?: string;
@ApiPropertyOptional({ description: 'Filter by active status (true/false)', example: 'true' })
@IsOptional()
@IsString()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : undefined)
isActive?: string;
@ApiPropertyOptional({ description: 'Search by name or email', example: 'john' })
@IsOptional()
@IsString()
search?: string;

View File

@@ -1,9 +1,12 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RejectKycDto {
@ApiProperty({ description: 'ID of the user whose KYC to reject', example: 'usr_abc123' })
@IsString()
userId!: string;
@ApiProperty({ description: 'Reason for KYC rejection (min 5 chars)', example: 'Document image is blurry and unreadable', minLength: 5 })
@IsString()
@MinLength(5)
reason!: string;

View File

@@ -1,9 +1,12 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RejectListingDto {
@ApiProperty({ description: 'ID of the listing to reject', example: 'lst_abc123' })
@IsString()
listingId!: string;
@ApiProperty({ description: 'Reason for rejection (min 5 chars)', example: 'Listing contains misleading information', minLength: 5 })
@IsString()
@MinLength(5)
reason!: string;

View File

@@ -1,12 +1,16 @@
import { IsDateString, IsIn, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RevenueStatsDto {
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
@IsDateString()
startDate!: string;
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
@IsDateString()
endDate!: string;
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
@IsOptional()
@IsIn(['day', 'month'])
groupBy?: 'day' | 'month';

View File

@@ -1,12 +1,16 @@
import { IsBoolean, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserStatusDto {
@ApiProperty({ description: 'ID of the user to update', example: 'usr_abc123' })
@IsString()
userId!: string;
@ApiProperty({ description: 'Whether the user should be active', example: true })
@IsBoolean()
isActive!: boolean;
@ApiProperty({ description: 'Reason for the status change (min 5 chars)', example: 'Account reactivated after review', minLength: 5 })
@IsString()
@MinLength(5)
reason!: string;

View File

@@ -3,6 +3,7 @@ import {
Get,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { QueryBus } from '@nestjs/cqrs';
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
@@ -17,6 +18,7 @@ import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatm
import { type PriceTrendDto } from '../../application/queries/get-price-trend/get-price-trend.handler';
import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler';
@ApiTags('analytics')
@Controller('analytics')
export class AnalyticsController {
constructor(
@@ -24,6 +26,8 @@ export class AnalyticsController {
) {}
@Get('market-report')
@ApiOperation({ summary: 'Get market report for a city' })
@ApiResponse({ status: 200, description: 'Market report retrieved' })
async getMarketReport(@Query() dto: GetMarketReportDto): Promise<MarketReportDto> {
return this.queryBus.execute(
new GetMarketReportQuery(dto.city, dto.period, dto.propertyType),
@@ -31,6 +35,8 @@ export class AnalyticsController {
}
@Get('price-trend')
@ApiOperation({ summary: 'Get price trend for a district' })
@ApiResponse({ status: 200, description: 'Price trend data retrieved' })
async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise<PriceTrendDto> {
return this.queryBus.execute(
new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods),
@@ -38,6 +44,8 @@ export class AnalyticsController {
}
@Get('heatmap')
@ApiOperation({ summary: 'Get price heatmap for a city' })
@ApiResponse({ status: 200, description: 'Heatmap data retrieved' })
async getHeatmap(@Query() dto: GetHeatmapDto): Promise<HeatmapDto> {
return this.queryBus.execute(
new GetHeatmapQuery(dto.city, dto.period),
@@ -45,6 +53,8 @@ export class AnalyticsController {
}
@Get('district-stats')
@ApiOperation({ summary: 'Get statistics by district' })
@ApiResponse({ status: 200, description: 'District statistics retrieved' })
async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise<DistrictStatsDto> {
return this.queryBus.execute(
new GetDistrictStatsQuery(dto.city, dto.period),

View File

@@ -1,9 +1,12 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GetDistrictStatsDto {
@ApiProperty({ description: 'City name' })
@IsString()
city!: string;
@ApiProperty({ description: 'Time period' })
@IsString()
period!: string;
}

View File

@@ -1,9 +1,12 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GetHeatmapDto {
@ApiProperty({ description: 'City name' })
@IsString()
city!: string;
@ApiProperty({ description: 'Time period' })
@IsString()
period!: string;
}

View File

@@ -1,13 +1,17 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PropertyType } from '@prisma/client';
export class GetMarketReportDto {
@ApiProperty({ description: 'City name' })
@IsString()
city!: string;
@ApiProperty({ description: 'Time period' })
@IsString()
period!: string;
@ApiPropertyOptional({ enum: PropertyType, description: 'Property type filter' })
@IsOptional()
@IsEnum(PropertyType)
propertyType?: PropertyType;

View File

@@ -1,17 +1,22 @@
import { IsArray, IsEnum, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { PropertyType } from '@prisma/client';
export class GetPriceTrendDto {
@ApiProperty({ description: 'District name' })
@IsString()
district!: string;
@ApiProperty({ description: 'City name' })
@IsString()
city!: string;
@ApiProperty({ enum: PropertyType, description: 'Property type' })
@IsEnum(PropertyType)
propertyType!: PropertyType;
@ApiProperty({ description: 'Comma-separated list of periods', type: [String] })
@IsArray()
@IsString({ each: true })
@Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value))

View File

@@ -6,6 +6,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
@@ -14,6 +15,7 @@ import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-k
import { GetProfileQuery } from '../../application/queries/get-profile/get-profile.query';
import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { VerifyKycDto } from '../dto/verify-kyc.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@@ -25,6 +27,7 @@ import { type JwtPayload, type TokenPair } from '../../infrastructure/services/t
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
@@ -33,6 +36,10 @@ export class AuthController {
) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered, tokens returned' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 409, description: 'Phone already registered' })
async register(@Body() dto: RegisterDto): Promise<TokenPair> {
return this.commandBus.execute(
new RegisterUserCommand(dto.phone, dto.password, dto.fullName, dto.email),
@@ -41,6 +48,10 @@ export class AuthController {
@UseGuards(LocalAuthGuard)
@Post('login')
@ApiOperation({ summary: 'Login with phone and password' })
@ApiBody({ type: LoginDto })
@ApiResponse({ status: 201, description: 'Login successful, tokens returned' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@CurrentUser() user: { id: string; phone: string; role: string }): Promise<TokenPair> {
return this.commandBus.execute(
new LoginUserCommand(user.id, user.phone, user.role),
@@ -48,18 +59,29 @@ export class AuthController {
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 201, description: 'New token pair returned' })
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
async refresh(@Body() dto: RefreshTokenDto): Promise<TokenPair> {
return this.commandBus.execute(new RefreshTokenCommand(dto.refreshToken));
}
@UseGuards(JwtAuthGuard)
@Get('profile')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'User profile returned' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getProfile(@CurrentUser() user: JwtPayload): Promise<UserProfileDto> {
return this.queryBus.execute(new GetProfileQuery(user.sub));
}
@UseGuards(JwtAuthGuard)
@Get('profile/agent')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get agent profile for current user' })
@ApiResponse({ status: 200, description: 'Agent profile returned (null if not an agent)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getAgentProfile(@CurrentUser() user: JwtPayload): Promise<AgentDto | null> {
return this.queryBus.execute(new GetAgentByUserIdQuery(user.sub));
}
@@ -67,6 +89,11 @@ export class AuthController {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Patch('kyc')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Verify user KYC (admin only)' })
@ApiResponse({ status: 200, description: 'KYC status updated' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin only' })
async verifyKyc(
@Body() dto: VerifyKycDto & { userId: string },
): Promise<{ message: string }> {

View File

@@ -1,9 +1,12 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: '0901234567' })
@IsString()
phone!: string;
@ApiProperty({ example: 'P@ssw0rd!' })
@IsString()
password!: string;
}

View File

@@ -1,6 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ description: 'JWT refresh token' })
@IsString()
refreshToken!: string;
}

View File

@@ -1,17 +1,22 @@
import { IsString, IsOptional, IsEmail, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: '0901234567', description: 'Phone number' })
@IsString()
phone!: string;
@ApiProperty({ example: 'P@ssw0rd!', minLength: 8 })
@IsString()
@MinLength(8)
password!: string;
@ApiProperty({ example: 'Nguyen Van A' })
@IsString()
@MinLength(1)
fullName!: string;
@ApiPropertyOptional({ example: 'user@example.com' })
@IsOptional()
@IsEmail()
email?: string;

View File

@@ -1,10 +1,13 @@
import { IsEnum, IsOptional, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { KYCStatus } from '@prisma/client';
export class VerifyKycDto {
@ApiProperty({ enum: KYCStatus, description: 'New KYC status' })
@IsEnum(KYCStatus)
kycStatus!: KYCStatus;
@ApiPropertyOptional({ description: 'Additional KYC verification data' })
@IsOptional()
@IsObject()
kycData?: Record<string, unknown>;

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;

View File

@@ -8,6 +8,8 @@ import {
Inject,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { CurrentUser } from '@modules/auth/presentation/decorators';
import { type JwtPayload } from '@modules/auth';
import {
@@ -21,16 +23,21 @@ import { IsBoolean, IsEnum, IsString } from 'class-validator';
import { NotificationChannel as PrismaChannel } from '@prisma/client';
class UpdatePreferenceDto {
@ApiProperty({ enum: PrismaChannel, description: 'Notification channel' })
@IsEnum(PrismaChannel)
channel!: PrismaChannel;
@ApiProperty({ description: 'Event type identifier' })
@IsString()
eventType!: string;
@ApiProperty({ description: 'Whether the preference is enabled' })
@IsBoolean()
enabled!: boolean;
}
@ApiTags('notifications')
@ApiBearerAuth('JWT')
@Controller('notifications')
@UseGuards(AuthGuard('jwt'))
export class NotificationsController {
@@ -43,6 +50,10 @@ export class NotificationsController {
) {}
@Get('history')
@ApiOperation({ summary: 'Get notification history' })
@ApiResponse({ status: 200, description: 'Notification history retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getHistory(
@CurrentUser() user: JwtPayload,
@Query('limit') limit?: number,
@@ -51,11 +62,17 @@ export class NotificationsController {
}
@Get('preferences')
@ApiOperation({ summary: 'Get notification preferences' })
@ApiResponse({ status: 200, description: 'Preferences retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getPreferences(@CurrentUser() user: JwtPayload) {
return this.preferenceRepo.findByUserId(user.sub);
}
@Put('preferences')
@ApiOperation({ summary: 'Update a notification preference' })
@ApiResponse({ status: 200, description: 'Preference updated' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async updatePreference(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdatePreferenceDto,
@@ -64,6 +81,9 @@ export class NotificationsController {
}
@Get('templates')
@ApiOperation({ summary: 'Get available notification templates' })
@ApiResponse({ status: 200, description: 'Templates retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getTemplates() {
return { templates: this.templateService.getTemplateKeys() };
}

View File

@@ -6,29 +6,37 @@ import {
MinLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentProvider, PaymentType } from '@prisma/client';
export class CreatePaymentDto {
@ApiProperty({ enum: PaymentProvider, description: 'Payment provider' })
@IsEnum(PaymentProvider)
provider!: PaymentProvider;
@ApiProperty({ enum: PaymentType, description: 'Payment type' })
@IsEnum(PaymentType)
type!: PaymentType;
@ApiProperty({ type: Number, description: 'Amount in VND', example: 500000 })
@Transform(({ value }) => BigInt(value))
amountVND!: bigint;
@ApiProperty({ description: 'Payment description', example: 'Listing fee' })
@IsString()
@MinLength(1)
description!: string;
@ApiProperty({ description: 'URL to redirect after payment', example: 'https://example.com/return' })
@IsUrl()
returnUrl!: string;
@ApiPropertyOptional({ description: 'External transaction ID' })
@IsOptional()
@IsString()
transactionId?: string;
@ApiPropertyOptional({ description: 'Idempotency key to prevent duplicate payments' })
@IsOptional()
@IsString()
idempotencyKey?: string;

View File

@@ -1,17 +1,21 @@
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentStatus } from '@prisma/client';
export class ListTransactionsDto {
@ApiPropertyOptional({ enum: PaymentStatus, description: 'Filter by payment status' })
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;
@ApiPropertyOptional({ type: Number, description: 'Maximum number of results', minimum: 1, maximum: 100, default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@ApiPropertyOptional({ type: Number, description: 'Number of results to skip', minimum: 0, default: 0 })
@IsOptional()
@IsInt()
@Min(0)

View File

@@ -1,6 +1,8 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefundPaymentDto {
@ApiProperty({ description: 'Reason for the refund', example: 'Customer requested cancellation' })
@IsString()
@MinLength(1)
reason!: string;

View File

@@ -5,6 +5,12 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
@@ -17,6 +23,7 @@ import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { type SearchResult } from '../../domain/repositories/search.repository';
import { type ReindexResult } from '../../application/commands/reindex-all/reindex-all.handler';
@ApiTags('search')
@Controller('search')
export class SearchController {
constructor(
@@ -25,6 +32,8 @@ export class SearchController {
) {}
@Get()
@ApiOperation({ summary: 'Search properties', description: 'Public full-text and faceted property search' })
@ApiResponse({ status: 200, description: 'Search results returned successfully' })
async search(@Query() dto: SearchPropertiesDto): Promise<SearchResult> {
return this.queryBus.execute(
new SearchPropertiesQuery(
@@ -46,6 +55,8 @@ export class SearchController {
}
@Get('geo')
@ApiOperation({ summary: 'Geo search properties', description: 'Public geographic radius property search' })
@ApiResponse({ status: 200, description: 'Geo search results returned successfully' })
async geoSearch(@Query() dto: GeoSearchDto): Promise<SearchResult> {
return this.queryBus.execute(
new GeoSearchQuery(
@@ -66,6 +77,11 @@ export class SearchController {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post('reindex')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Reindex all properties', description: 'Admin-only endpoint to trigger a full reindex' })
@ApiResponse({ status: 201, description: 'Reindex completed successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden — requires ADMIN role' })
async reindex(): Promise<ReindexResult> {
return this.commandBus.execute(new ReindexAllCommand());
}

View File

@@ -8,6 +8,7 @@ import {
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum GeoSortByOption {
DISTANCE = 'distance',
@@ -17,48 +18,57 @@ export enum GeoSortByOption {
}
export class GeoSearchDto {
@ApiProperty({ description: 'Latitude of the search center', example: 10.7769, minimum: -90, maximum: 90 })
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
lat!: number;
@ApiProperty({ description: 'Longitude of the search center', example: 106.7009, minimum: -180, maximum: 180 })
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
lng!: number;
@ApiProperty({ description: 'Search radius in kilometres', example: 5, minimum: 0.1, maximum: 100 })
@Type(() => Number)
@IsNumber()
@Min(0.1)
@Max(100)
radiusKm!: number;
@ApiPropertyOptional({ description: 'Property type filter', example: 'apartment' })
@IsOptional()
@IsString()
propertyType?: string;
@ApiPropertyOptional({ description: 'Transaction type filter (sale or rent)', example: 'sale' })
@IsOptional()
@IsString()
transactionType?: string;
@ApiPropertyOptional({ description: 'Minimum price in VND', example: 1000000000, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMin?: number;
@ApiPropertyOptional({ description: 'Maximum price in VND', example: 5000000000, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMax?: number;
@ApiPropertyOptional({ description: 'Sort order', enum: GeoSortByOption, example: GeoSortByOption.DISTANCE })
@IsOptional()
@IsEnum(GeoSortByOption)
sortBy?: GeoSortByOption;
@ApiPropertyOptional({ description: 'Page number (1-based)', example: 1, default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -66,6 +76,7 @@ export class GeoSearchDto {
@Transform(({ value }) => value ?? 1)
page?: number;
@ApiPropertyOptional({ description: 'Results per page', example: 20, default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -8,6 +8,7 @@ import {
Max,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export enum SortByOption {
PRICE_ASC = 'price_asc',
@@ -17,60 +18,72 @@ export enum SortByOption {
}
export class SearchPropertiesDto {
@ApiPropertyOptional({ description: 'Free-text search query', example: 'chung cu quan 7' })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: 'Property type filter', example: 'apartment' })
@IsOptional()
@IsString()
propertyType?: string;
@ApiPropertyOptional({ description: 'Transaction type filter (sale or rent)', example: 'sale' })
@IsOptional()
@IsString()
transactionType?: string;
@ApiPropertyOptional({ description: 'Minimum price in VND', example: 1000000000, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMin?: number;
@ApiPropertyOptional({ description: 'Maximum price in VND', example: 5000000000, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
priceMax?: number;
@ApiPropertyOptional({ description: 'Minimum area in m²', example: 50, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
areaMin?: number;
@ApiPropertyOptional({ description: 'Maximum area in m²', example: 200, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
areaMax?: number;
@ApiPropertyOptional({ description: 'Number of bedrooms', example: 2, minimum: 0 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
bedrooms?: number;
@ApiPropertyOptional({ description: 'District name', example: 'Quan 7' })
@IsOptional()
@IsString()
district?: string;
@ApiPropertyOptional({ description: 'City name', example: 'Ho Chi Minh' })
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC })
@IsOptional()
@IsEnum(SortByOption)
sortBy?: SortByOption;
@ApiPropertyOptional({ description: 'Page number (1-based)', example: 1, default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -78,6 +91,7 @@ export class SearchPropertiesDto {
@Transform(({ value }) => value ?? 1)
page?: number;
@ApiPropertyOptional({ description: 'Results per page', example: 20, default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -9,6 +9,13 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
@@ -34,6 +41,7 @@ import { MeterUsageDto } from '../dto/meter-usage.dto';
import { BillingHistoryParamsDto } from '../dto/billing-history.dto';
import { type PlanTier } from '@prisma/client';
@ApiTags('subscriptions')
@Controller('subscriptions')
export class SubscriptionsController {
constructor(
@@ -43,11 +51,17 @@ export class SubscriptionsController {
// ── Plans (Public) ──
@ApiOperation({ summary: 'List all subscription plans' })
@ApiResponse({ status: 200, description: 'List of available plans' })
@Get('plans')
async listPlans(): Promise<PlanDto[]> {
return this.queryBus.execute(new GetPlanQuery());
}
@ApiOperation({ summary: 'Get a subscription plan by tier' })
@ApiParam({ name: 'tier', description: 'Plan tier identifier' })
@ApiResponse({ status: 200, description: 'Plan details' })
@ApiResponse({ status: 404, description: 'Plan not found' })
@Get('plans/:tier')
async getPlan(@Param('tier') tier: string): Promise<PlanDto> {
return this.queryBus.execute(new GetPlanQuery(tier.toUpperCase() as PlanTier));
@@ -55,6 +69,11 @@ export class SubscriptionsController {
// ── Subscriptions (Authenticated) ──
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new subscription' })
@ApiResponse({ status: 201, description: 'Subscription created' })
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Post()
async createSubscription(
@@ -66,6 +85,10 @@ export class SubscriptionsController {
);
}
@ApiBearerAuth()
@ApiOperation({ summary: 'Upgrade an existing subscription' })
@ApiResponse({ status: 200, description: 'Subscription upgraded' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Put('upgrade')
async upgradeSubscription(
@@ -77,6 +100,10 @@ export class SubscriptionsController {
);
}
@ApiBearerAuth()
@ApiOperation({ summary: 'Cancel an active subscription' })
@ApiResponse({ status: 200, description: 'Subscription cancelled' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Delete()
async cancelSubscription(
@@ -90,6 +117,10 @@ export class SubscriptionsController {
// ── Usage / Quota ──
@ApiBearerAuth()
@ApiOperation({ summary: 'Record metered usage' })
@ApiResponse({ status: 201, description: 'Usage recorded' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Post('usage')
async meterUsage(
@@ -101,6 +132,11 @@ export class SubscriptionsController {
);
}
@ApiBearerAuth()
@ApiOperation({ summary: 'Check remaining quota for a metric' })
@ApiParam({ name: 'metric', description: 'Usage metric identifier' })
@ApiResponse({ status: 200, description: 'Quota check result' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('quota/:metric')
async checkQuota(
@@ -112,6 +148,10 @@ export class SubscriptionsController {
// ── Billing ──
@ApiBearerAuth()
@ApiOperation({ summary: 'Get billing history' })
@ApiResponse({ status: 200, description: 'Billing history records' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('billing')
async getBillingHistory(

View File

@@ -1,13 +1,16 @@
import { IsInt, IsOptional, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class BillingHistoryParamsDto {
@ApiPropertyOptional({ description: 'Maximum number of records to return', minimum: 1, example: 20 })
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
limit?: number;
@ApiPropertyOptional({ description: 'Number of records to skip', minimum: 0, example: 0 })
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()

View File

@@ -1,6 +1,8 @@
import { IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class CancelSubscriptionDto {
@ApiPropertyOptional({ description: 'Reason for cancellation' })
@IsOptional()
@IsString()
reason?: string;

View File

@@ -1,10 +1,13 @@
import { IsEnum, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { PlanTier } from '@prisma/client';
export class CreateSubscriptionDto {
@ApiProperty({ enum: PlanTier, description: 'Subscription plan tier' })
@IsEnum(PlanTier)
planTier!: PlanTier;
@ApiProperty({ enum: ['monthly', 'yearly'], description: 'Billing cycle frequency' })
@IsIn(['monthly', 'yearly'])
billingCycle!: 'monthly' | 'yearly';
}

View File

@@ -1,10 +1,13 @@
import { IsInt, IsString, Min, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class MeterUsageDto {
@ApiProperty({ description: 'Usage metric identifier', example: 'api_calls' })
@IsString()
@MinLength(1)
metric!: string;
@ApiProperty({ description: 'Number of units consumed', minimum: 1, example: 1 })
@IsInt()
@Min(1)
count!: number;

View File

@@ -1,7 +1,9 @@
import { IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { PlanTier } from '@prisma/client';
export class UpgradeSubscriptionDto {
@ApiProperty({ enum: PlanTier, description: 'Target plan tier to upgrade to' })
@IsEnum(PlanTier)
newPlanTier!: PlanTier;
}