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:
@@ -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[]> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiQuery,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
@@ -33,6 +42,7 @@ import { type CreateListingResult } from '../../application/commands/create-list
|
||||
import { type ListingDetailDto } from '../../application/queries/get-listing/get-listing.handler';
|
||||
import { type PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
|
||||
@ApiTags('listings')
|
||||
@Controller('listings')
|
||||
export class ListingsController {
|
||||
constructor(
|
||||
@@ -40,6 +50,11 @@ export class ListingsController {
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Create a new property listing' })
|
||||
@ApiResponse({ status: 201, description: 'Listing created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async createListing(
|
||||
@@ -81,6 +96,13 @@ export class ListingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Get listings pending moderation (admin only)' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated list of pending listings' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1, description: 'Page number' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 20, description: 'Items per page' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Get('pending')
|
||||
@@ -93,11 +115,17 @@ export class ListingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get listing details by ID' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID', example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
|
||||
@ApiResponse({ status: 200, description: 'Listing details returned' })
|
||||
@ApiResponse({ status: 404, description: 'Listing not found' })
|
||||
@Get(':id')
|
||||
async getListing(@Param('id') id: string): Promise<ListingDetailDto> {
|
||||
return this.queryBus.execute(new GetListingQuery(id));
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Search and filter property listings' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated search results' })
|
||||
@Get()
|
||||
async searchListings(@Query() dto: SearchListingsDto): Promise<PaginatedResult<any>> {
|
||||
return this.queryBus.execute(
|
||||
@@ -118,6 +146,12 @@ export class ListingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Update listing status' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Status updated successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Listing not found' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch(':id/status')
|
||||
async updateStatus(
|
||||
@@ -130,6 +164,12 @@ export class ListingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Upload media (photo/video) for a listing' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Media uploaded successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@Post(':id/media')
|
||||
@@ -148,6 +188,12 @@ export class ListingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Moderate a listing (admin only)' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Listing moderated successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Patch(':id/moderate')
|
||||
|
||||
@@ -10,121 +10,150 @@ import {
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { PropertyType, TransactionType, Direction } from '@prisma/client';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateListingDto {
|
||||
@ApiProperty({ enum: TransactionType, example: 'SALE', description: 'Transaction type (SALE or RENT)' })
|
||||
@IsEnum(TransactionType)
|
||||
transactionType!: TransactionType;
|
||||
|
||||
@ApiProperty({ type: String, example: '5500000000', description: 'Price in VND (as string to support bigint)' })
|
||||
@Transform(({ value }) => BigInt(value))
|
||||
priceVND!: bigint;
|
||||
|
||||
@ApiProperty({ enum: PropertyType, example: 'APARTMENT', description: 'Type of property' })
|
||||
@IsEnum(PropertyType)
|
||||
propertyType!: PropertyType;
|
||||
|
||||
@ApiProperty({ example: 'Căn hộ 3PN view sông Sài Gòn - Vinhomes Central Park', description: 'Listing title (min 5 chars)' })
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ example: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ, view trực diện sông Sài Gòn. Tiện ích hồ bơi, gym, công viên nội khu.', description: 'Detailed description (min 10 chars)' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ example: '208 Nguyễn Hữu Cảnh', description: 'Street address' })
|
||||
@IsString()
|
||||
address!: string;
|
||||
|
||||
@ApiProperty({ example: 'Phường 22', description: 'Ward name' })
|
||||
@IsString()
|
||||
ward!: string;
|
||||
|
||||
@ApiProperty({ example: 'Bình Thạnh', description: 'District name' })
|
||||
@IsString()
|
||||
district!: string;
|
||||
|
||||
@ApiProperty({ example: 'Hồ Chí Minh', description: 'City / province' })
|
||||
@IsString()
|
||||
city!: string;
|
||||
|
||||
@ApiProperty({ example: 10.7942, description: 'Latitude (-90 to 90)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude!: number;
|
||||
|
||||
@ApiProperty({ example: 106.7219, description: 'Longitude (-180 to 180)' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude!: number;
|
||||
|
||||
@ApiProperty({ example: 85.5, description: 'Total area in square metres' })
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
areaM2!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 78.0, description: 'Usable area in square metres' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
usableAreaM2?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'Number of bedrooms' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
bedrooms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2, description: 'Number of bathrooms' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
bathrooms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Number of floors (for houses)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
floors?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 15, description: 'Floor number (for apartments)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
floor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30, description: 'Total floors in the building' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
totalFloors?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: Direction, example: 'EAST', description: 'Primary facing direction' })
|
||||
@IsOptional()
|
||||
@IsEnum(Direction)
|
||||
direction?: Direction;
|
||||
|
||||
@ApiPropertyOptional({ example: 2020, description: 'Year the property was built' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
yearBuilt?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Sổ hồng', description: 'Legal document status (e.g. Sổ hồng, Sổ đỏ, Hợp đồng mua bán)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
legalStatus?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'], description: 'List of amenities' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
amenities?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: { schools: ['Trường Quốc tế ISHCMC'], hospitals: ['Bệnh viện FV'] }, description: 'Nearby points of interest' })
|
||||
@IsOptional()
|
||||
nearbyPOIs?: unknown;
|
||||
|
||||
@ApiPropertyOptional({ example: 500, description: 'Distance to nearest metro station in metres' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
metroDistanceM?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Vinhomes Central Park', description: 'Name of the residential project' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
projectName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'agent-uuid-1234', description: 'Assigned agent ID' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
agentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, example: '25000000', description: 'Monthly rent price in VND (as string to support bigint)' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
|
||||
rentPriceMonthly?: bigint;
|
||||
|
||||
@ApiPropertyOptional({ example: 2.5, description: 'Agent commission percentage' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ModerateListingDto {
|
||||
@ApiProperty({ enum: ['approve', 'reject'], example: 'approve', description: 'Moderation action' })
|
||||
@IsEnum(['approve', 'reject'] as const)
|
||||
action!: 'approve' | 'reject';
|
||||
|
||||
@ApiPropertyOptional({ example: 85, description: 'Moderation confidence score (0-100)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@@ -12,6 +15,7 @@ export class ModerateListingDto {
|
||||
@Max(100)
|
||||
moderationScore?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Tin đăng hợp lệ, thông tin chính xác', description: 'Moderator notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@@ -1,57 +1,70 @@
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class SearchListingsDto {
|
||||
@ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' })
|
||||
@IsOptional()
|
||||
@IsEnum(ListingStatus)
|
||||
status?: ListingStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: TransactionType, example: 'SALE', description: 'Filter by transaction type' })
|
||||
@IsOptional()
|
||||
@IsEnum(TransactionType)
|
||||
transactionType?: TransactionType;
|
||||
|
||||
@ApiPropertyOptional({ enum: PropertyType, example: 'APARTMENT', description: 'Filter by property type' })
|
||||
@IsOptional()
|
||||
@IsEnum(PropertyType)
|
||||
propertyType?: PropertyType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Hồ Chí Minh', description: 'Filter by city / province' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Quận 2', description: 'Filter by district' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, example: '2000000000', description: 'Minimum price in VND' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
|
||||
minPrice?: bigint;
|
||||
|
||||
@ApiPropertyOptional({ type: String, example: '10000000000', description: 'Maximum price in VND' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value != null ? BigInt(value) : undefined))
|
||||
maxPrice?: bigint;
|
||||
|
||||
@ApiPropertyOptional({ example: 50, description: 'Minimum area in square metres' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
minArea?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 200, description: 'Maximum area in square metres' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
maxArea?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2, description: 'Filter by number of bedrooms' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
bedrooms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Page number (starts at 1)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, description: 'Items per page (max 100)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ListingStatus } from '@prisma/client';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateListingStatusDto {
|
||||
@ApiProperty({ enum: ListingStatus, example: 'ACTIVE', description: 'New listing status' })
|
||||
@IsEnum(ListingStatus)
|
||||
status!: ListingStatus;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Đã xác minh thông tin pháp lý', description: 'Optional moderation notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
moderationNotes?: string;
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user