diff --git a/apps/api/package.json b/apps/api/package.json index d139cbf..bd2eb84 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@paralleldrive/cuid2": "^3.3.0", "@prisma/client": "^6.0.0", @@ -43,6 +44,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "sanitize-html": "^2.17.2", + "swagger-ui-express": "^5.0.1", "typesense": "^3.0.5" }, "devDependencies": { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 02b4cfe..a6ca43d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { LoggerService } from '@modules/shared'; import helmet from 'helmet'; import { AppModule } from './app.module'; @@ -9,17 +10,40 @@ async function bootstrap() { const logger = app.get(LoggerService); app.useLogger(logger); + // ── OpenAPI / Swagger ── + const swaggerConfig = new DocumentBuilder() + .setTitle('Goodgo Platform API') + .setDescription('Real-estate platform API — listings, search, payments, subscriptions, analytics') + .setVersion('1.0') + .addBearerAuth( + { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + 'JWT', + ) + .addTag('auth', 'Authentication & user profile') + .addTag('listings', 'Property listings CRUD & moderation') + .addTag('search', 'Full-text & geo search') + .addTag('payments', 'Payment processing & callbacks') + .addTag('subscriptions', 'Plans, billing & usage metering') + .addTag('admin', 'Admin panel operations') + .addTag('notifications', 'Notification history & preferences') + .addTag('analytics', 'Market reports & price analytics') + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, document, { + swaggerOptions: { persistAuthorization: true }, + }); + // ── Security Headers (Helmet) ── app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:', 'https:'], + imgSrc: ["'self'", 'data:', 'https:', 'blob:'], connectSrc: ["'self'"], - fontSrc: ["'self'"], + fontSrc: ["'self'", 'data:'], objectSrc: ["'none'"], frameSrc: ["'none'"], baseUri: ["'self'"], diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index 2ab70aa..2868295 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -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 { @@ -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 { 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 { diff --git a/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts b/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts index 269d322..a1ad66f 100644 --- a/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/adjust-subscription.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts b/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts index 5c79cb0..2a6a758 100644 --- a/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/approve-kyc.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts b/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts index fb358ba..531ab92 100644 --- a/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/approve-listing.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts b/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts index 3dd9eb4..5935137 100644 --- a/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts b/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts index b138005..298af4c 100644 --- a/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/bulk-moderate.dto.ts @@ -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) diff --git a/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts b/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts index bbc1c40..45d1600 100644 --- a/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/get-users-query.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts b/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts index 4abecb5..9146bb7 100644 --- a/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/reject-kyc.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts b/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts index 9aa8894..8fd7c72 100644 --- a/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/reject-listing.dto.ts @@ -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; diff --git a/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts index 071861a..07a6c8a 100644 --- a/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/revenue-stats.dto.ts @@ -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'; diff --git a/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts b/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts index ff59c58..f204d9d 100644 --- a/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts +++ b/apps/api/src/modules/admin/presentation/dto/update-user-status.dto.ts @@ -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; diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index b6a1c40..6fbe048 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -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 { 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 { 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 { 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 { return this.queryBus.execute( new GetDistrictStatsQuery(dto.city, dto.period), diff --git a/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts index 05822f8..d9afcca 100644 --- a/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts +++ b/apps/api/src/modules/analytics/presentation/dto/get-district-stats.dto.ts @@ -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; } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts index a6c1614..339de29 100644 --- a/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts +++ b/apps/api/src/modules/analytics/presentation/dto/get-heatmap.dto.ts @@ -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; } diff --git a/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts index 5db00e7..ac5b33a 100644 --- a/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts +++ b/apps/api/src/modules/analytics/presentation/dto/get-market-report.dto.ts @@ -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; diff --git a/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts index 0a390ea..5dbc1e3 100644 --- a/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts +++ b/apps/api/src/modules/analytics/presentation/dto/get-price-trend.dto.ts @@ -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)) diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index fedfb79..dca8cd8 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -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 { 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 { 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 { 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 { 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 { 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 }> { diff --git a/apps/api/src/modules/auth/presentation/dto/login.dto.ts b/apps/api/src/modules/auth/presentation/dto/login.dto.ts index b093c14..c14a248 100644 --- a/apps/api/src/modules/auth/presentation/dto/login.dto.ts +++ b/apps/api/src/modules/auth/presentation/dto/login.dto.ts @@ -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; } diff --git a/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts b/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts index 752ff4f..c4049f1 100644 --- a/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts +++ b/apps/api/src/modules/auth/presentation/dto/refresh-token.dto.ts @@ -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; } diff --git a/apps/api/src/modules/auth/presentation/dto/register.dto.ts b/apps/api/src/modules/auth/presentation/dto/register.dto.ts index cd46515..4457fae 100644 --- a/apps/api/src/modules/auth/presentation/dto/register.dto.ts +++ b/apps/api/src/modules/auth/presentation/dto/register.dto.ts @@ -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; diff --git a/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts b/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts index b76b08d..0fda0d7 100644 --- a/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts +++ b/apps/api/src/modules/auth/presentation/dto/verify-kyc.dto.ts @@ -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; diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 6a8c122..27ce0f6 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -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 { 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> { 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') diff --git a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts index 8cc5328..342215e 100644 --- a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts @@ -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) diff --git a/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts index 6a37baa..417ac3a 100644 --- a/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/moderate-listing.dto.ts @@ -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; diff --git a/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts index 5f59ffc..9cab065 100644 --- a/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/search-listings.dto.ts @@ -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) diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts index 99ee4e5..98613e8 100644 --- a/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/update-listing-status.dto.ts @@ -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; diff --git a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts index 95965c9..d11ebba 100644 --- a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts +++ b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts @@ -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() }; } diff --git a/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts b/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts index ba5e471..e347064 100644 --- a/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts +++ b/apps/api/src/modules/payments/presentation/dto/create-payment.dto.ts @@ -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; diff --git a/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts b/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts index 0eff483..013d308 100644 --- a/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts +++ b/apps/api/src/modules/payments/presentation/dto/list-transactions.dto.ts @@ -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) diff --git a/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts b/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts index c0330d1..6b844ae 100644 --- a/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts +++ b/apps/api/src/modules/payments/presentation/dto/refund-payment.dto.ts @@ -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; diff --git a/apps/api/src/modules/search/presentation/controllers/search.controller.ts b/apps/api/src/modules/search/presentation/controllers/search.controller.ts index 96e4c22..d1335e7 100644 --- a/apps/api/src/modules/search/presentation/controllers/search.controller.ts +++ b/apps/api/src/modules/search/presentation/controllers/search.controller.ts @@ -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 { 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 { 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 { return this.commandBus.execute(new ReindexAllCommand()); } diff --git a/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts b/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts index 74888a6..0b315a0 100644 --- a/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts +++ b/apps/api/src/modules/search/presentation/dto/geo-search.dto.ts @@ -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() diff --git a/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts index fe130ce..352df30 100644 --- a/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts +++ b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts @@ -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() diff --git a/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts index 5bb3894..39082e3 100644 --- a/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts +++ b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts @@ -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 { 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 { 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( diff --git a/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts index 5eb2930..106067c 100644 --- a/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts +++ b/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts @@ -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() diff --git a/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts index f7500a6..6c21552 100644 --- a/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts +++ b/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts @@ -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; diff --git a/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts index d851cf8..14081cf 100644 --- a/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts +++ b/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts @@ -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'; } diff --git a/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts index d9c59b4..34f330c 100644 --- a/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts +++ b/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts @@ -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; diff --git a/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts index 83a4bf5..92a19ce 100644 --- a/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts +++ b/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts @@ -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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37dd583..d03cc7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18) + '@nestjs/swagger': + specifier: ^11.2.6 + version: 11.2.6(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2) @@ -150,6 +153,9 @@ importers: sanitize-html: specifier: ^2.17.2 version: 2.17.2 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) typesense: specifier: ^3.0.5 version: 3.0.5(@babel/runtime@7.29.2) @@ -959,6 +965,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1035,6 +1044,19 @@ packages: peerDependencies: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/mapped-types@2.1.0': + resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/passport@11.0.5': resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} peerDependencies: @@ -1052,6 +1074,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.2.6': + resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.18': resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} peerDependencies: @@ -1361,6 +1400,9 @@ packages: cpu: [x64] os: [win32] + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@smithy/chunked-blob-reader-native@4.2.3': resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} @@ -3335,6 +3377,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -3671,6 +3716,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -4219,6 +4267,18 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.31.0: + resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==} + + swagger-ui-dist@5.32.2: + resolution: {integrity: sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -5589,6 +5649,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.13(hono@4.12.12) @@ -5692,6 +5754,14 @@ snapshots: '@types/jsonwebtoken': 9.0.10 jsonwebtoken: 9.0.3 + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + '@nestjs/passport@11.0.5(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5720,6 +5790,21 @@ snapshots: transitivePeerDependencies: - chokidar + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.17.23 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.31.0 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-express@11.1.18)': dependencies: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5946,6 +6031,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@scarf/scarf@1.4.0': {} + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: '@smithy/util-base64': 4.3.2 @@ -8206,6 +8293,8 @@ snapshots: lodash.once@4.1.1: {} + lodash@4.17.23: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -8504,6 +8593,8 @@ snapshots: lru-cache: 11.3.2 minipass: 7.1.3 + path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -9107,6 +9198,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.31.0: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-dist@5.32.2: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.2 + symbol-observable@4.0.0: {} tailwind-merge@3.5.0: {}