diff --git a/package-lock.json b/package-lock.json index 20147db..7bce065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "moment": "^2.29.4", "multer": "^2.0.2", "nest-winston": "^1.10.2", + "nestjs-i18n": "^10.4.0", "opossum": "^9.0.0", "passport": "^0.7.0", "passport-custom": "^1.1.1", @@ -7061,6 +7062,12 @@ "node": ">=6.5" } }, + "node_modules/accept-language-parser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", + "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -15188,6 +15195,29 @@ "winston": "^3.0.0" } }, + "node_modules/nestjs-i18n": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.0.tgz", + "integrity": "sha512-fPOgwrnb8u8UO6YXNlnamF7GDhNLOHQE8hD/pT/L7oUQibvL7PBZYZgquwppOFRaOPnH0uING2BzQGq7uQNNmQ==", + "license": "MIT", + "dependencies": { + "accept-language-parser": "^1.5.0", + "chokidar": "^3.6.0", + "cookie": "^0.7.0", + "iterare": "^1.2.1", + "js-yaml": "^4.1.0", + "string-format": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": "*", + "@nestjs/core": "*", + "class-validator": "*", + "rxjs": "*" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -17756,6 +17786,12 @@ "node": ">=0.6.19" } }, + "node_modules/string-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", + "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", + "license": "WTFPL OR MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index d382439..bef5c12 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "moment": "^2.29.4", "multer": "^2.0.2", "nest-winston": "^1.10.2", + "nestjs-i18n": "^10.4.0", "opossum": "^9.0.0", "passport": "^0.7.0", "passport-custom": "^1.1.1", @@ -188,4 +189,4 @@ "url": "https://github.com/MettaChain/PropChain-BackEnd/issues" }, "homepage": "https://github.com/MettaChain/PropChain-BackEnd#readme" -} +} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 01ab22b..30e666e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,10 @@ import { LoggingInterceptor } from './common/logging/logging.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { AllExceptionsFilter } from './common/errors/error.filter'; +// I18n +import { I18nModule, AcceptLanguageResolver, QueryResolver, HeaderResolver } from 'nestjs-i18n'; +import * as path from 'path'; + // Redis import { RedisModule } from './common/services/redis.module'; import { createRedisConfig } from './common/services/redis.config'; @@ -59,6 +63,23 @@ import { ObservabilityModule } from './observability/observability.module'; }), ConfigurationModule, + // I18n + I18nModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + fallbackLanguage: configService.getOrThrow('FALLBACK_LANGUAGE', 'en'), + loaderOptions: { + path: path.join(__dirname, '/i18n/'), + watch: true, + }, + }), + resolvers: [ + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + new HeaderResolver(['x-lang']), + ], + inject: [ConfigService], + }), + // Caching CacheModule, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index e13d685..846254f 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -11,9 +11,9 @@ import { ResetPasswordDto, VerifyEmailParamsDto, } from './dto'; -import { ErrorResponseDto } from '../common/errors/error.dto'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; import { Request } from 'express'; +import { ApiStandardErrorResponse } from '../common/errors/api-standard-error-response.decorator'; /** * AuthController @@ -27,7 +27,7 @@ import { Request } from 'express'; @ApiTags('authentication') @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor(private authService: AuthService) { } /** * Register a new user account @@ -63,12 +63,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 409, description: 'User with this email already exists.', type: ErrorResponseDto }) - @ApiResponse({ - status: 400, - description: 'Validation failed (weak password, invalid email, etc).', - type: ErrorResponseDto, - }) + @ApiStandardErrorResponse([400, 409]) async register(@Body() createUserDto: CreateUserDto) { return this.authService.register(createUserDto); } @@ -101,8 +96,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Invalid credentials or too many attempts.', type: ErrorResponseDto }) - @ApiResponse({ status: 400, description: 'Validation failed.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([400, 401]) @HttpCode(HttpStatus.OK) async login(@Body() loginDto: LoginDto, @Req() req: Request) { return this.authService.login({ @@ -137,7 +131,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Invalid wallet address or signature.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async web3Login(@Body() loginDto: LoginWeb3Dto) { return this.authService.login({ @@ -171,7 +165,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Invalid or expired refresh token.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { return this.authService.refreshToken(refreshTokenDto.refreshToken); @@ -202,7 +196,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async logout(@Req() req: Request) { const user = req['user'] as any; @@ -262,7 +256,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 400, description: 'Invalid, expired, or already-used reset token.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([400]) @HttpCode(HttpStatus.OK) async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { return this.authService.resetPassword(resetPasswordDto.token, resetPasswordDto.newPassword); @@ -296,6 +290,7 @@ export class AuthController { description: 'Invalid, expired, or already-used verification token.', type: ErrorResponseDto, }) + @ApiStandardErrorResponse([400]) async verifyEmail(@Param() params: VerifyEmailParamsDto) { return this.authService.verifyEmail(params.token); } @@ -333,7 +328,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async getSessions(@Req() req: Request) { const user = req['user'] as any; @@ -366,7 +361,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async invalidateSession(@Req() req: Request, @Param('sessionId') sessionId: string) { const user = req['user'] as any; @@ -399,7 +394,7 @@ export class AuthController { }, }, }) - @ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto }) + @ApiStandardErrorResponse([401]) @HttpCode(HttpStatus.OK) async invalidateAllSessions(@Req() req: Request) { const user = req['user'] as any; diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c388679..54ab8e5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,4 +1,5 @@ -import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { UnauthorizedException, InvalidCredentialsException, TokenExpiredException, InvalidInputException, UserNotFoundException } from '../common/errors/custom.exceptions'; import { UserService } from '../users/user.service'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -64,7 +65,7 @@ export class AuthService { } else if (credentials.walletAddress) { user = await this.validateUserByWallet(credentials.walletAddress, credentials.signature); } else { - throw new BadRequestException('Email/password or wallet address/signature required'); + throw new InvalidInputException(undefined, 'Email/password or wallet address/signature required'); } if (!user) { @@ -75,7 +76,7 @@ export class AuthService { const attempts = parseInt(existing || '0', 10) + 1; await this.redisService.setex(attemptsKey, attemptWindow, attempts.toString()); } - throw new UnauthorizedException('Invalid credentials'); + throw new InvalidCredentialsException(); } // successful login, clear attempts @@ -99,13 +100,13 @@ export class AuthService { if (!user || !user.password) { this.logger.warn('Email validation failed: User not found', { email }); - throw new UnauthorizedException('Invalid credentials'); + throw new InvalidCredentialsException(); } const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { this.logger.warn('Email validation failed: Invalid password', { email }); - throw new UnauthorizedException('Invalid credentials'); + throw new InvalidCredentialsException(); } const { password: _, ...result } = user as any; @@ -141,7 +142,7 @@ export class AuthService { this.logger.warn('Refresh token validation failed: User not found', { userId: payload.sub, }); - throw new UnauthorizedException('User not found'); + throw new UserNotFoundException(payload.sub); } const storedToken = await this.redisService.get(`refresh_token:${payload.sub}`); @@ -149,14 +150,14 @@ export class AuthService { this.logger.warn('Refresh token validation failed: Invalid token', { userId: payload.sub, }); - throw new UnauthorizedException('Invalid refresh token'); + throw new TokenExpiredException('Invalid refresh token'); } this.logger.logAuth('Token refreshed successfully', { userId: user.id }); return this.generateTokens(user); } catch (error) { this.logger.error('Token refresh failed', error.stack); - throw new UnauthorizedException('Invalid refresh token'); + throw new TokenExpiredException('Invalid refresh token'); } } @@ -177,7 +178,7 @@ export class AuthService { } } - + // === REFRESH TOKEN REVOCATION === // Prevents token refresh even if JWT signature is still valid @@ -212,7 +213,7 @@ export class AuthService { if (!resetData) { this.logger.warn('Invalid or expired password reset token received'); - throw new BadRequestException('Invalid or expired reset token'); + throw new InvalidInputException(undefined, 'Invalid or expired reset token'); } const { userId, expiry } = JSON.parse(resetData); @@ -220,7 +221,7 @@ export class AuthService { if (Date.now() > expiry) { await this.redisService.del(`password_reset:${resetToken}`); this.logger.warn('Expired password reset token used', { userId }); - throw new BadRequestException('Reset token has expired'); + throw new InvalidInputException(undefined, 'Reset token has expired'); } await this.userService.updatePassword(userId, newPassword); @@ -235,7 +236,7 @@ export class AuthService { if (!verificationData) { this.logger.warn('Invalid or expired email verification token'); - throw new BadRequestException('Invalid or expired verification token'); + throw new InvalidInputException(undefined, 'Invalid or expired verification token'); } const { userId } = JSON.parse(verificationData); @@ -311,7 +312,7 @@ export class AuthService { const payload = { sub: user.id, // Subject (user ID) email: user.email, - jti, // JWT ID for blacklisting + jti: jti // JWT ID for blacklisting }; const accessToken = this.jwtService.sign(payload, { diff --git a/src/common/errors/api-standard-error-response.decorator.ts b/src/common/errors/api-standard-error-response.decorator.ts new file mode 100644 index 0000000..b531e9f --- /dev/null +++ b/src/common/errors/api-standard-error-response.decorator.ts @@ -0,0 +1,17 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse, getSchemaPath } from '@nestjs/swagger'; +import { ErrorResponseDto } from './error.dto'; + +export const ApiStandardErrorResponse = (statusCodes: number[]) => { + const responses = statusCodes.map((status) => + ApiResponse({ + status, + description: `Standardized Error Response (${status})`, + schema: { + $ref: getSchemaPath(ErrorResponseDto), + }, + }), + ); + + return applyDecorators(...responses); +}; diff --git a/src/common/errors/custom.exceptions.ts b/src/common/errors/custom.exceptions.ts index 549048f..45440fe 100644 --- a/src/common/errors/custom.exceptions.ts +++ b/src/common/errors/custom.exceptions.ts @@ -65,6 +65,12 @@ export class InsufficientPermissionsException extends BaseCustomException { } // Resource Exceptions +export class NotFoundException extends BaseCustomException { + constructor(message?: string) { + super(ErrorCode.RESOURCE_NOT_FOUND, message || 'Resource not found', undefined, HttpStatus.NOT_FOUND); + } +} + export class ResourceNotFoundException extends BaseCustomException { constructor(resourceType?: string, message?: string) { const customMessage = message || (resourceType ? `${resourceType} not found` : undefined); diff --git a/src/common/errors/error.filter.spec.ts b/src/common/errors/error.filter.spec.ts index 6a6d378..f239316 100644 --- a/src/common/errors/error.filter.spec.ts +++ b/src/common/errors/error.filter.spec.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { StructuredLoggerService } from '../../../src/common/logging/logger.service'; import { HttpException, HttpStatus, ArgumentsHost } from '@nestjs/common'; import { ErrorCode } from '../../../src/common/errors/error.codes'; +import { I18nService } from 'nestjs-i18n'; describe('AppExceptionFilter', () => { let filter: AppExceptionFilter; @@ -44,6 +45,17 @@ describe('AppExceptionFilter', () => { error: jest.fn(), }, }, + { + provide: I18nService, + useValue: { + translate: jest.fn().mockImplementation((key) => { + if (key === `errors.${ErrorCode.VALIDATION_ERROR}`) { + return 'The provided data is invalid'; + } + return key; + }), + }, + }, ], }).compile(); diff --git a/src/common/errors/error.filter.ts b/src/common/errors/error.filter.ts index 1df2785..fb5ead3 100644 --- a/src/common/errors/error.filter.ts +++ b/src/common/errors/error.filter.ts @@ -4,6 +4,7 @@ import { Request, Response } from 'express'; import { ErrorResponseDto } from './error.dto'; import { ErrorCode, ErrorMessages } from './error.codes'; import { v4 as uuidv4 } from 'uuid'; +import { I18nContext, I18nService } from 'nestjs-i18n'; import { StructuredLoggerService } from '../logging/logger.service'; import { getCorrelationId } from '../logging/correlation-id'; import axios from 'axios'; @@ -13,7 +14,8 @@ export class AllExceptionsFilter implements ExceptionFilter { constructor( private readonly configService: ConfigService, private readonly loggerService: StructuredLoggerService, - ) {} + private readonly i18n: I18nService, + ) { } catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); @@ -71,17 +73,21 @@ export class AllExceptionsFilter implements ExceptionFilter { let message: string; let details: string[] | undefined; + const lang = I18nContext.current()?.lang || request.headers['accept-language'] || 'en'; + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { const responseObj = exceptionResponse as any; // Handle validation errors if (Array.isArray(responseObj.message)) { errorCode = ErrorCode.VALIDATION_ERROR; - message = ErrorMessages[ErrorCode.VALIDATION_ERROR]; + message = this.i18n.translate(`errors.${ErrorCode.VALIDATION_ERROR}`, { lang }) as string; details = responseObj.message; } else { errorCode = this.mapStatusToErrorCode(status); - message = responseObj.message || ErrorMessages[errorCode]; + const translatedMessage = this.i18n.translate(`errors.${errorCode}`, { lang }) as string; + // fallback to standard message if translation key returns itself or we didn't translate + message = responseObj.message || (translatedMessage !== `errors.${errorCode}` ? translatedMessage : ErrorMessages[errorCode]); details = responseObj.details; } } else { @@ -107,7 +113,12 @@ export class AllExceptionsFilter implements ExceptionFilter { private handleUnknownException(exception: unknown, request: Request, requestId: string): ErrorResponseDto { const status = HttpStatus.INTERNAL_SERVER_ERROR; const errorCode = ErrorCode.INTERNAL_SERVER_ERROR; - const message = ErrorMessages[errorCode]; + const lang = I18nContext.current()?.lang || request.headers['accept-language'] || 'en'; + + let message = this.i18n.translate(`errors.${errorCode}`, { lang }) as string; + if (message === `errors.${errorCode}`) { + message = ErrorMessages[errorCode]; + } // In production, don't expose internal error details const details = diff --git a/src/i18n/en/errors.json b/src/i18n/en/errors.json new file mode 100644 index 0000000..e2ae8ef --- /dev/null +++ b/src/i18n/en/errors.json @@ -0,0 +1,38 @@ +{ + "INTERNAL_SERVER_ERROR": "An unexpected error occurred. Please try again later", + "DATABASE_ERROR": "A database error occurred", + "EXTERNAL_SERVICE_ERROR": "An external service is currently unavailable", + "BAD_REQUEST": "The request is invalid", + "VALIDATION_ERROR": "The provided data is invalid", + "INVALID_INPUT": "The input data contains invalid values", + "MISSING_REQUIRED_FIELD": "Required field is missing", + "INVALID_FORMAT": "The data format is incorrect", + "UNPROCESSABLE_ENTITY": "The request was well-formed but could not be processed", + "UNAUTHORIZED": "You are not authorized to access this resource", + "AUTHENTICATION_REQUIRED": "Authentication is required to access this resource", + "INVALID_CREDENTIALS": "The provided credentials are invalid", + "TOKEN_EXPIRED": "Your session has expired. Please login again", + "TOKEN_INVALID": "Invalid authentication token", + "AUTH_INVALID_CREDENTIALS": "Invalid credentials provided", + "AUTH_USER_NOT_FOUND": "User not found", + "AUTH_TOKEN_EXPIRED": "Authentication token has expired", + "AUTH_TOKEN_INVALID": "Authentication token is invalid", + "AUTH_ACCOUNT_LOCKED": "Your account has been locked for security reasons", + "FORBIDDEN": "You do not have permission to perform this action", + "INSUFFICIENT_PERMISSIONS": "You lack the necessary permissions", + "ACCESS_DENIED": "Access to this resource is denied", + "NOT_FOUND": "The requested resource was not found", + "RESOURCE_NOT_FOUND": "The specified resource does not exist", + "USER_NOT_FOUND": "User not found", + "PROPERTY_NOT_FOUND": "Property not found", + "CONFLICT": "A conflict occurred while processing your request", + "DUPLICATE_ENTRY": "This entry already exists", + "RESOURCE_ALREADY_EXISTS": "A resource with this identifier already exists", + "TRANSACTION_FAILED": "The transaction could not be completed", + "BUSINESS_RULE_VIOLATION": "This operation violates business rules", + "OPERATION_NOT_ALLOWED": "This operation is not allowed", + "INVALID_STATE": "The resource is in an invalid state for this operation", + "SERVICE_UNAVAILABLE": "The requested service is temporarily unavailable.", + "CIRCUIT_OPEN": "Circuit breaker is open. Please try again later.", + "EXTERNAL_API_ERROR": "An error occurred while communicating with an external service." +} \ No newline at end of file diff --git a/src/i18n/es/errors.json b/src/i18n/es/errors.json new file mode 100644 index 0000000..ae27df8 --- /dev/null +++ b/src/i18n/es/errors.json @@ -0,0 +1,38 @@ +{ + "INTERNAL_SERVER_ERROR": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo más tarde", + "DATABASE_ERROR": "Ocurrió un error en la base de datos", + "EXTERNAL_SERVICE_ERROR": "Un servicio externo no está disponible temporalmente", + "BAD_REQUEST": "La solicitud es inválida", + "VALIDATION_ERROR": "Los datos proporcionados son inválidos", + "INVALID_INPUT": "Los datos de entrada contienen valores inválidos", + "MISSING_REQUIRED_FIELD": "Falta un campo obligatorio", + "INVALID_FORMAT": "El formato de los datos es incorrecto", + "UNPROCESSABLE_ENTITY": "La solicitud estaba bien formada pero no pudo ser procesada", + "UNAUTHORIZED": "No está autorizado para acceder a este recurso", + "AUTHENTICATION_REQUIRED": "Se requiere autenticación para acceder a este recurso", + "INVALID_CREDENTIALS": "Las credenciales proporcionadas son inválidas", + "TOKEN_EXPIRED": "Su sesión ha expirado. Por favor inicie sesión nuevamente", + "TOKEN_INVALID": "Token de autenticación inválido", + "AUTH_INVALID_CREDENTIALS": "Se han proporcionado credenciales inválidas", + "AUTH_USER_NOT_FOUND": "Usuario no encontrado", + "AUTH_TOKEN_EXPIRED": "El token de autenticación ha expirado", + "AUTH_TOKEN_INVALID": "El token de autenticación es inválido", + "AUTH_ACCOUNT_LOCKED": "Su cuenta ha sido bloqueada por razones de seguridad", + "FORBIDDEN": "No tiene permiso para realizar esta acción", + "INSUFFICIENT_PERMISSIONS": "Carece de los permisos necesarios", + "ACCESS_DENIED": "El acceso a este recurso ha sido denegado", + "NOT_FOUND": "El recurso solicitado no fue encontrado", + "RESOURCE_NOT_FOUND": "El recurso especificado no existe", + "USER_NOT_FOUND": "Usuario no encontrado", + "PROPERTY_NOT_FOUND": "Propiedad no encontrada", + "CONFLICT": "Ocurrió un conflicto al procesar su solicitud", + "DUPLICATE_ENTRY": "Esta entrada ya existe", + "RESOURCE_ALREADY_EXISTS": "Ya existe un recurso con este identificador", + "TRANSACTION_FAILED": "La transacción no pudo ser completada", + "BUSINESS_RULE_VIOLATION": "Esta operación viola las reglas de negocio", + "OPERATION_NOT_ALLOWED": "Esta operación no está permitida", + "INVALID_STATE": "El recurso se encuentra en un estado inválido para esta operación", + "SERVICE_UNAVAILABLE": "El servicio solicitado no está disponible temporalmente.", + "CIRCUIT_OPEN": "El interruptor está abierto. Por favor, inténtelo de nuevo más tarde.", + "EXTERNAL_API_ERROR": "Ocurrió un error al comunicarse con un servicio externo." +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 2ce0a77..2c3c375 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import helmet from 'helmet'; import * as compression from 'compression'; import { AppModule } from './app.module'; import { StructuredLoggerService } from './common/logging/logger.service'; +import { ErrorResponseDto } from './common/errors/error.dto'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -68,7 +69,9 @@ async function bootstrap() { .addApiKey({ type: 'apiKey', name: 'X-API-KEY', in: 'header' }, 'apiKey') .build(); - const document = SwaggerModule.createDocument(app, config); + const document = SwaggerModule.createDocument(app, config, { + extraModels: [ErrorResponseDto], + }); SwaggerModule.setup(`${apiPrefix}/docs`, app, document, { customSiteTitle: 'PropChain API Documentation', customCss: '.swagger-ui .topbar { display: none }', diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index b78c077..97025df 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -5,6 +5,7 @@ import { CreatePropertyDto, UpdatePropertyDto, PropertyQueryDto, PropertyRespons import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { PropertySearchService } from './search/property-search.service'; import { PropertySearchDto } from './dto/property-search.dto'; +import { ApiStandardErrorResponse } from '../common/errors/api-standard-error-response.decorator'; @ApiTags('properties') @Controller('properties') @@ -14,11 +15,11 @@ export class PropertiesController { constructor( private readonly propertiesService: PropertiesService, private readonly propertySearchService: PropertySearchService, - ) {} + ) { } @Post() @ApiOperation({ summary: 'Create a new property' }) @ApiResponse({ status: 201, description: 'Property created successfully.', type: PropertyResponseDto }) - @ApiResponse({ status: 400, description: 'Invalid input data.' }) + @ApiStandardErrorResponse([400, 401, 404]) create(@Body() createPropertyDto: CreatePropertyDto, @Request() req) { return this.propertiesService.create(createPropertyDto, req.user.id); } @@ -26,6 +27,7 @@ export class PropertiesController { @Get() @ApiOperation({ summary: 'Get all properties with optional filters' }) @ApiResponse({ status: 200, description: 'List of properties.' }) + @ApiStandardErrorResponse([400, 401]) findAll(@Query() query: PropertyQueryDto) { return this.propertiesService.findAll(query); } @@ -33,6 +35,7 @@ export class PropertiesController { @Get('search') @ApiOperation({ summary: 'Advanced property search (geospatial + filters)' }) @ApiResponse({ status: 200, description: 'Search results.' }) + @ApiStandardErrorResponse([400, 401]) search(@Query() dto: PropertySearchDto, @Request() req) { return this.propertySearchService.search(dto, req.user.id); } @@ -40,6 +43,7 @@ export class PropertiesController { @Get('statistics') @ApiOperation({ summary: 'Get property statistics' }) @ApiResponse({ status: 200, description: 'Property statistics.' }) + @ApiStandardErrorResponse([400, 401]) getStatistics() { return this.propertiesService.getStatistics(); } @@ -48,6 +52,7 @@ export class PropertiesController { @ApiOperation({ summary: 'Get properties by owner' }) @ApiParam({ name: 'ownerId', description: 'Owner ID' }) @ApiResponse({ status: 200, description: 'Properties by owner.' }) + @ApiStandardErrorResponse([400, 401]) findByOwner(@Param('ownerId') ownerId: string, @Query() query: PropertyQueryDto) { return this.propertiesService.findByOwner(ownerId, query); } @@ -56,7 +61,7 @@ export class PropertiesController { @ApiOperation({ summary: 'Get a property by ID' }) @ApiParam({ name: 'id', description: 'Property ID' }) @ApiResponse({ status: 200, description: 'Property found.', type: PropertyResponseDto }) - @ApiResponse({ status: 404, description: 'Property not found.' }) + @ApiStandardErrorResponse([400, 401, 404]) findOne(@Param('id') id: string) { return this.propertiesService.findOne(id); } @@ -65,8 +70,7 @@ export class PropertiesController { @ApiOperation({ summary: 'Update a property' }) @ApiParam({ name: 'id', description: 'Property ID' }) @ApiResponse({ status: 200, description: 'Property updated successfully.', type: PropertyResponseDto }) - @ApiResponse({ status: 400, description: 'Invalid input data.' }) - @ApiResponse({ status: 404, description: 'Property not found.' }) + @ApiStandardErrorResponse([400, 401, 404]) update(@Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto) { return this.propertiesService.update(id, updatePropertyDto); } @@ -75,8 +79,7 @@ export class PropertiesController { @ApiOperation({ summary: 'Update property status' }) @ApiParam({ name: 'id', description: 'Property ID' }) @ApiResponse({ status: 200, description: 'Property status updated successfully.' }) - @ApiResponse({ status: 400, description: 'Invalid status transition.' }) - @ApiResponse({ status: 404, description: 'Property not found.' }) + @ApiStandardErrorResponse([400, 401, 404, 409]) updateStatus(@Param('id') id: string, @Body('status') status: PropertyStatus, @Request() req) { return this.propertiesService.updateStatus(id, status, req.user.id); } @@ -85,7 +88,7 @@ export class PropertiesController { @ApiOperation({ summary: 'Delete a property' }) @ApiParam({ name: 'id', description: 'Property ID' }) @ApiResponse({ status: 200, description: 'Property deleted successfully.' }) - @ApiResponse({ status: 404, description: 'Property not found.' }) + @ApiStandardErrorResponse([400, 401, 404]) remove(@Param('id') id: string) { return this.propertiesService.remove(id); } diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index 3d670e0..aab832d 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -1,28 +1,11 @@ -import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { NotFoundException, UserNotFoundException, InvalidInputException, BusinessRuleViolationException } from '../common/errors/custom.exceptions'; import { PrismaService } from '../database/prisma/prisma.service'; import { CreatePropertyDto, PropertyStatus as DTOPropertyStatus } from './dto/create-property.dto'; import { UpdatePropertyDto } from './dto/update-property.dto'; import { PropertyQueryDto } from './dto/property-query.dto'; import { ConfigService } from '@nestjs/config'; -import { PrismaProperty, PrismaUser } from '../types/prisma.types'; -import { isObject } from '../types/guards'; - -/** - * PropertiesService - * - * Core service handling all property-related operations including CRUD operations, - * advanced search/filtering, geospatial queries, and property management. - * - * Features: - * - Full-text search across property titles, descriptions, and locations - * - Advanced filtering by type, price range, bedroom count, etc. - * - Geospatial querying for nearby property searches - * - Pagination and sorting support - * - Property valuation history and document tracking - * - * @class PropertiesService - * @injectable - */ + @Injectable() export class PropertiesService { private readonly logger = new Logger(PropertiesService.name); @@ -30,57 +13,18 @@ export class PropertiesService { constructor( private prisma: PrismaService, private configService: ConfigService, - ) {} - - /** - * Create a new property listing - * - * Validates owner exists and creates a new property entry with provided details. - * Formats address into a standardized location string for geospatial operations. - * Automatically includes owner information in response. - * - * @param {CreatePropertyDto} createPropertyDto - Property creation data - * @param {string} ownerId - ID of the property owner - * @returns {Promise} Created property with owner details - * @throws {NotFoundException} If owner doesn't exist - * @throws {BadRequestException} If creation fails - * - * @example - * ```typescript - * const property = await propertiesService.create({ - * title: '3BR Modern Apartment', - * description: 'Spacious apartment in downtown', - * address: { - * street: '123 Main St', - * city: 'New York', - * state: 'NY', - * zipCode: '10001', - * country: 'USA' - * }, - * type: 'APARTMENT', - * price: 500000, - * bedrooms: 3, - * bathrooms: 2, - * areaSqFt: 1500, - * status: 'AVAILABLE' - * }, userId); - * ``` - */ + ) { } + async create(createPropertyDto: CreatePropertyDto, ownerId: string) { try { - // === OWNER VALIDATION === - // Ensures property ownership is assigned to an existing user const owner = await (this.prisma as any).user.findUnique({ where: { id: ownerId }, }); if (!owner) { - throw new NotFoundException(`User with ID ${ownerId} not found`); + throw new UserNotFoundException(ownerId); } - // === ADDRESS FORMATTING === - // Converts address components into standardized location string - // Used for geographic searching and filtering const location = this.formatAddress(createPropertyDto.address); const property = await (this.prisma as any).property.create({ @@ -91,7 +35,6 @@ export class PropertiesService { price: createPropertyDto.price, status: this.mapPropertyStatus(createPropertyDto.status || DTOPropertyStatus.AVAILABLE), ownerId, - // Property features bedrooms: createPropertyDto.bedrooms, bathrooms: createPropertyDto.bathrooms, squareFootage: createPropertyDto.areaSqFt, @@ -99,11 +42,7 @@ export class PropertiesService { }, include: { owner: { - select: { - id: true, - email: true, - role: true, - }, + select: { id: true, email: true, role: true }, }, }, }); @@ -111,62 +50,14 @@ export class PropertiesService { this.logger.log(`Property created: ${property.id} by user ${ownerId}`); return property; } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { + if (error instanceof UserNotFoundException || error instanceof InvalidInputException) { throw error; } this.logger.error('Failed to create property', error); - throw new BadRequestException('Failed to create property'); + throw new InvalidInputException(undefined, 'Failed to create property'); } } - /** - * Get all properties with advanced filtering, sorting, and pagination - * - * Provides comprehensive property search with support for: - * - Full-text search across multiple fields - * - Type filtering (apartment, house, commercial, etc.) - * - Price range filtering - * - Bedroom/bathroom count filtering - * - Location filtering (city, country) - * - Status filtering - * - Custom sorting and pagination - * - * @param {PropertyQueryDto} [query] - Query parameters for filtering and pagination - * @param {number} [query.page=1] - Page number for pagination - * @param {number} [query.limit=20] - Results per page - * @param {string} [query.sortBy='createdAt'] - Field to sort by - * @param {string} [query.sortOrder='desc'] - Sort direction (asc/desc) - * @param {string} [query.search] - Full-text search term - * @param {string} [query.type] - Property type filter - * @param {string} [query.status] - Property status filter - * @param {string} [query.city] - City filter - * @param {string} [query.country] - Country filter - * @param {number} [query.minPrice] - Minimum price filter - * @param {number} [query.maxPrice] - Maximum price filter - * @param {number} [query.minBedrooms] - Minimum bedroom count - * @param {number} [query.maxBedrooms] - Maximum bedroom count - * @param {string} [query.ownerId] - Filter by owner ID - * - * @returns {Promise<{properties: Property[], total: number, page: number, limit: number, totalPages: number}>} - * Paginated results with total count - * - * @example - * ```typescript - * // Search for 2-3 bedroom apartments under $500k in New York - * const results = await propertiesService.findAll({ - * search: 'downtown', - * type: 'APARTMENT', - * minBedrooms: 2, - * maxBedrooms: 3, - * maxPrice: 500000, - * city: 'New York', - * sortBy: 'price', - * sortOrder: 'asc', - * page: 1, - * limit: 20 - * }); - * ``` - */ async findAll(query?: PropertyQueryDto) { const { page = 1, @@ -192,9 +83,6 @@ export class PropertiesService { const skip = (page - 1) * limit; const where: Record = {}; - // === FULL-TEXT SEARCH === - // Searches across title, description, and location fields - // Uses case-insensitive matching for better UX if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, @@ -203,91 +91,50 @@ export class PropertiesService { ]; } - // === PROPERTY TYPE FILTER === if (type) { where.propertyType = type; } - // === STATUS FILTER === if (status) { where.status = this.mapPropertyStatus(status); } - if (city || country) { const locationParts: string[] = []; - if (city) { - locationParts.push(city); - } - if (country) { - locationParts.push(country); - } + if (city) locationParts.push(city); + if (country) locationParts.push(country); where.location = { contains: locationParts.join(', '), mode: 'insensitive' }; - - // === LOCATION FILTERS === - // City and country filtering via location string - if (city) { - where.location = { contains: city, mode: 'insensitive' }; - } - - if (country) { - where.location = { contains: country, mode: 'insensitive' }; - } - // === PRICE RANGE FILTER === - // Supports minimum, maximum, or both bounds if (minPrice !== undefined || maxPrice !== undefined) { where.price = {}; - if (minPrice !== undefined) { - where.price.gte = minPrice; - } - if (maxPrice !== undefined) { - where.price.lte = maxPrice; - } + if (minPrice !== undefined) where.price.gte = minPrice; + if (maxPrice !== undefined) where.price.lte = maxPrice; } - // === BEDROOM COUNT FILTER === - // Allows filtering by minimum, maximum, or range if (minBedrooms !== undefined || maxBedrooms !== undefined) { where.bedrooms = {}; - if (minBedrooms !== undefined) { - where.bedrooms.gte = minBedrooms; - } - if (maxBedrooms !== undefined) { - where.bedrooms.lte = maxBedrooms; - } + if (minBedrooms !== undefined) where.bedrooms.gte = minBedrooms; + if (maxBedrooms !== undefined) where.bedrooms.lte = maxBedrooms; } - if (minBathrooms !== undefined || maxBathrooms !== undefined) { where.bathrooms = {}; - if (minBathrooms !== undefined) { - where.bathrooms.gte = minBathrooms; - } - if (maxBathrooms !== undefined) { - where.bathrooms.lte = maxBathrooms; - } + if (minBathrooms !== undefined) where.bathrooms.gte = minBathrooms; + if (maxBathrooms !== undefined) where.bathrooms.lte = maxBathrooms; } if (minArea !== undefined || maxArea !== undefined) { where.squareFootage = {}; - if (minArea !== undefined) { - where.squareFootage.gte = minArea; - } - if (maxArea !== undefined) { - where.squareFootage.lte = maxArea; - } + if (minArea !== undefined) where.squareFootage.gte = minArea; + if (maxArea !== undefined) where.squareFootage.lte = maxArea; } - if (ownerId) { where.ownerId = ownerId; } try { - // === PARALLEL DATA FETCHING === - // Fetch properties and total count concurrently for better performance const [properties, total] = await Promise.all([ (this.prisma as any).property.findMany({ where, @@ -295,13 +142,7 @@ export class PropertiesService { take: limit, orderBy: { [sortBy]: sortOrder }, include: { - owner: { - select: { - id: true, - email: true, - role: true, - }, - }, + owner: { select: { id: true, email: true, role: true } }, }, }), (this.prisma as any).property.count({ where }), @@ -316,58 +157,18 @@ export class PropertiesService { }; } catch (error) { this.logger.error('Failed to fetch properties', error); - throw new BadRequestException('Failed to fetch properties'); + throw new InvalidInputException(undefined, 'Failed to fetch properties'); } } - /** - * Get a single property by ID with full details - * - * Retrieves comprehensive property information including: - * - Owner details - * - Associated documents (deeds, certificates, etc.) - * - Recent valuation history (last 5 valuations) - * - * @param {string} id - Property ID - * @returns {Promise} Complete property object with related data - * @throws {NotFoundException} If property doesn't exist - * @throws {BadRequestException} If fetch fails - * - * @example - * ```typescript - * const property = await propertiesService.findOne('prop-id-123'); - * // Returns property with owner, documents, and valuations - * ``` - */ async findOne(id: string) { try { const property = await (this.prisma as any).property.findUnique({ where: { id }, include: { - // Include owner information for display - owner: { - select: { - id: true, - email: true, - role: true, - }, - }, - // Include associated documents (deeds, certificates, etc.) - documents: { - select: { - id: true, - name: true, - type: true, - status: true, - createdAt: true, - }, - }, - // Include recent valuations sorted by date (newest first) - // Limited to last 5 for performance - valuations: { - orderBy: { valuationDate: 'desc' }, - take: 5, - }, + owner: { select: { id: true, email: true, role: true } }, + documents: { select: { id: true, name: true, type: true, status: true, createdAt: true } }, + valuations: { orderBy: { valuationDate: 'desc' }, take: 5 }, }, }); @@ -381,36 +182,12 @@ export class PropertiesService { throw error; } this.logger.error(`Failed to fetch property ${id}`, error); - throw new BadRequestException('Failed to fetch property'); + throw new InvalidInputException(undefined, 'Failed to fetch property'); } } - /** - * Update an existing property - * - * Supports partial updates - only provided fields are updated. - * Validates property existence before updating. - * Handles address formatting for location updates. - * - * @param {string} id - Property ID to update - * @param {UpdatePropertyDto} updatePropertyDto - Fields to update - * @returns {Promise} Updated property object - * @throws {NotFoundException} If property doesn't exist - * @throws {BadRequestException} If update fails - * - * @example - * ```typescript - * // Update only the price and status - * const updated = await propertiesService.update(id, { - * price: 550000, - * status: 'PENDING' - * }); - * ``` - */ async update(id: string, updatePropertyDto: UpdatePropertyDto) { try { - // === EXISTENCE VALIDATION === - // Ensures property exists before attempting update const existingProperty = await (this.prisma as any).property.findUnique({ where: { id }, }); @@ -421,73 +198,35 @@ export class PropertiesService { const updateData: any = {}; - // === SELECTIVE FIELD UPDATES === - // Only update fields that were explicitly provided - // This allows partial updates without requiring all fields - if (updatePropertyDto.title !== undefined) { - updateData.title = updatePropertyDto.title; - } - - if (updatePropertyDto.description !== undefined) { - updateData.description = updatePropertyDto.description; - } - - if (updatePropertyDto.price !== undefined) { - updateData.price = updatePropertyDto.price; - } - - if (updatePropertyDto.address) { - updateData.location = this.formatAddress(updatePropertyDto.address); - } - - if (updatePropertyDto.status !== undefined) { - updateData.status = this.mapPropertyStatus(updatePropertyDto.status); - } - - if (updatePropertyDto.bedrooms !== undefined) { - updateData.bedrooms = updatePropertyDto.bedrooms; - } - - if (updatePropertyDto.bathrooms !== undefined) { - updateData.bathrooms = updatePropertyDto.bathrooms; - } - - if (updatePropertyDto.areaSqFt !== undefined) { - updateData.squareFootage = updatePropertyDto.areaSqFt; - } - - if (updatePropertyDto.type !== undefined) { - updateData.propertyType = updatePropertyDto.type; - } + if (updatePropertyDto.title !== undefined) updateData.title = updatePropertyDto.title; + if (updatePropertyDto.description !== undefined) updateData.description = updatePropertyDto.description; + if (updatePropertyDto.price !== undefined) updateData.price = updatePropertyDto.price; + if (updatePropertyDto.address) updateData.location = this.formatAddress(updatePropertyDto.address); + if (updatePropertyDto.status !== undefined) updateData.status = this.mapPropertyStatus(updatePropertyDto.status); + if (updatePropertyDto.bedrooms !== undefined) updateData.bedrooms = updatePropertyDto.bedrooms; + if (updatePropertyDto.bathrooms !== undefined) updateData.bathrooms = updatePropertyDto.bathrooms; + if (updatePropertyDto.areaSqFt !== undefined) updateData.squareFootage = updatePropertyDto.areaSqFt; + if (updatePropertyDto.type !== undefined) updateData.propertyType = updatePropertyDto.type; const property = await (this.prisma as any).property.update({ where: { id }, data: updateData, include: { - owner: { - select: { - id: true, - email: true, - role: true, - }, - }, + owner: { select: { id: true, email: true, role: true } }, }, }); this.logger.log(`Property updated: ${property.id}`); return property; } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { + if (error instanceof NotFoundException || error instanceof InvalidInputException) { throw error; } this.logger.error(`Failed to update property ${id}`, error); - throw new BadRequestException('Failed to update property'); + throw new InvalidInputException(undefined, 'Failed to update property'); } } - /** - * Delete a property - */ async remove(id: string): Promise { try { const existingProperty = await (this.prisma as any).property.findUnique({ @@ -508,22 +247,16 @@ export class PropertiesService { throw error; } this.logger.error(`Failed to delete property ${id}`, error); - throw new BadRequestException('Failed to delete property'); + throw new InvalidInputException(undefined, 'Failed to delete property'); } } - /** - * Search properties with geospatial capabilities - */ async searchNearby(latitude: number, longitude: number, _radiusKm: number = 10, query?: PropertyQueryDto) { try { - // For now, we'll implement a basic text-based search - // In a production environment, you would use PostGIS or similar for true geospatial queries const where: Record = { - location: { contains: '', mode: 'insensitive' }, // Basic location filter + location: { contains: '', mode: 'insensitive' }, }; - // Apply additional filters from query if (query?.search) { where.OR = [ { title: { contains: query.search, mode: 'insensitive' } }, @@ -531,82 +264,43 @@ export class PropertiesService { ]; } - if (query?.type) { - where.propertyType = query.type; - } - - if (query?.status) { - where.status = this.mapPropertyStatus(query.status); - } - + if (query?.type) where.propertyType = query.type; + if (query?.status) where.status = this.mapPropertyStatus(query.status); if (query?.minPrice !== undefined || query?.maxPrice !== undefined) { where.price = {}; - if (query.minPrice !== undefined) { - where.price.gte = query.minPrice; - } - if (query.maxPrice !== undefined) { - where.price.lte = query.maxPrice; - } + if (query.minPrice !== undefined) where.price.gte = query.minPrice; + if (query.maxPrice !== undefined) where.price.lte = query.maxPrice; } - if (query?.minBedrooms !== undefined || query?.maxBedrooms !== undefined) { where.bedrooms = {}; - if (query.minBedrooms !== undefined) { - where.bedrooms.gte = query.minBedrooms; - } - if (query.maxBedrooms !== undefined) { - where.bedrooms.lte = query.maxBedrooms; - } + if (query.minBedrooms !== undefined) where.bedrooms.gte = query.minBedrooms; + if (query.maxBedrooms !== undefined) where.bedrooms.lte = query.maxBedrooms; } - if (query?.minBathrooms !== undefined || query?.maxBathrooms !== undefined) { where.bathrooms = {}; - if (query.minBathrooms !== undefined) { - where.bathrooms.gte = query.minBathrooms; - } - if (query.maxBathrooms !== undefined) { - where.bathrooms.lte = query.maxBathrooms; - } + if (query.minBathrooms !== undefined) where.bathrooms.gte = query.minBathrooms; + if (query.maxBathrooms !== undefined) where.bathrooms.lte = query.maxBathrooms; } - if (query?.minArea !== undefined || query?.maxArea !== undefined) { where.squareFootage = {}; - if (query.minArea !== undefined) { - where.squareFootage.gte = query.minArea; - } - if (query.maxArea !== undefined) { - where.squareFootage.lte = query.maxArea; - } + if (query.minArea !== undefined) where.squareFootage.gte = query.minArea; + if (query.maxArea !== undefined) where.squareFootage.lte = query.maxArea; } const properties = await (this.prisma as any).property.findMany({ where, include: { - owner: { - select: { - id: true, - email: true, - role: true, - }, - }, + owner: { select: { id: true, email: true, role: true } }, }, }); - // Note: Actual distance calculation requires geospatial data (PostGIS). - // For now, return all filtered properties - return { - properties, - total: properties.length, - }; + return { properties, total: properties.length }; } catch (error) { this.logger.error('Failed to search nearby properties', error); - throw new BadRequestException('Failed to search nearby properties'); + throw new InvalidInputException(undefined, 'Failed to search nearby properties'); } } - /** - * Update property status with workflow validation - */ async updateStatus(id: string, newStatus: DTOPropertyStatus, userId?: string) { try { const property = await (this.prisma as any).property.findUnique({ @@ -620,65 +314,42 @@ export class PropertiesService { const currentStatus = property.status; const targetStatus = this.mapPropertyStatus(newStatus); - // Validate status transition if (!this.isValidStatusTransition(property.status, targetStatus)) { - throw new BadRequestException(`Invalid status transition from ${currentStatus} to ${targetStatus}`); + throw new BusinessRuleViolationException(`Invalid status transition from ${currentStatus} to ${targetStatus}`); } const updatedProperty = await (this.prisma as any).property.update({ where: { id }, data: { status: targetStatus }, include: { - owner: { - select: { - id: true, - email: true, - role: true, - }, - }, + owner: { select: { id: true, email: true, role: true } }, }, }); - this.logger.log( - `Property status updated: ${id} from ${currentStatus} to ${targetStatus}${userId ? ` by user ${userId}` : ''}`, - ); + this.logger.log(`Property status updated: ${id} from ${currentStatus} to ${targetStatus}${userId ? ` by user ${userId}` : ''}`); return updatedProperty; } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { + if (error instanceof NotFoundException || error instanceof BusinessRuleViolationException) { throw error; } this.logger.error(`Failed to update property status ${id}`, error); - throw new BadRequestException('Failed to update property status'); + throw new InvalidInputException(undefined, 'Failed to update property status'); } } - /** - * Get properties by owner - */ async findByOwner(ownerId: string, query?: PropertyQueryDto) { try { const ownerQuery = { ...query, ownerId }; const result = await this.findAll(ownerQuery); - return { - properties: result.properties, - total: result.total, - }; + return { properties: result.properties, total: result.total }; } catch (error) { this.logger.error(`Failed to fetch properties for owner ${ownerId}`, error); - throw new BadRequestException('Failed to fetch owner properties'); + throw new InvalidInputException(undefined, 'Failed to fetch owner properties'); } } - /** - * Get property statistics - */ - async getStatistics(): Promise<{ - total: number; - byStatus: Record; - byType: Record; - averagePrice: number; - }> { + async getStatistics(): Promise<{ total: number; byStatus: Record; byType: Record; averagePrice: number; }> { try { const [total, avgPrice] = await Promise.all([ (this.prisma as any).property.count(), @@ -688,70 +359,46 @@ export class PropertiesService { const statusResult = await (this.prisma as any).property.groupBy({ by: ['status'], - _count: { - id: true, - }, + _count: { id: true }, }); const typeResult = await (this.prisma as any).property.groupBy({ by: ['propertyType'], - _count: { - id: true, - }, + _count: { id: true }, }); - const byStatus = (statusResult || []).reduce( - (acc: Record, item: { status: string; _count: number }) => { - acc[item.status] = item._count; - return acc; - }, - {} as Record, - ); + const byStatus = (statusResult || []).reduce((acc: Record, item: { status: string; _count: number }) => { + acc[item.status] = item._count; + return acc; + }, {} as Record); - const byType = (typeResult || []).reduce( - (acc: Record, item: { propertyType: string; _count: number }) => { - acc[item.propertyType] = item._count; - return acc; - }, - {} as Record, - ); + const byType = (typeResult || []).reduce((acc: Record, item: { propertyType: string; _count: number }) => { + acc[item.propertyType] = item._count; + return acc; + }, {} as Record); - return { - total, - byStatus, - byType, - averagePrice: Number(avgPrice._avg.price || 0), - }; + return { total, byStatus, byType, averagePrice: Number(avgPrice._avg.price || 0) }; } catch (error) { this.logger.error('Failed to fetch property statistics', error); - throw new BadRequestException('Failed to fetch property statistics'); + throw new InvalidInputException(undefined, 'Failed to fetch property statistics'); } } - /** - * Helper method to format address into location string - */ private formatAddress(address: any): string { const parts = [address.street, address.city, address.state, address.postalCode, address.country].filter(Boolean); return parts.join(', '); } - /** - * Helper method to map DTO status to Prisma status - */ private mapPropertyStatus(status: DTOPropertyStatus): string { const statusMap: Record = { [DTOPropertyStatus.AVAILABLE]: 'LISTED', [DTOPropertyStatus.PENDING]: 'PENDING', [DTOPropertyStatus.SOLD]: 'SOLD', - [DTOPropertyStatus.RENTED]: 'SOLD', // Map RENTED to SOLD for now + [DTOPropertyStatus.RENTED]: 'SOLD', }; return statusMap[status] || 'DRAFT'; } - /** - * Helper method to validate status transitions - */ private isValidStatusTransition(currentStatus: string, targetStatus: string): boolean { const validTransitions: Record = { DRAFT: ['DRAFT', 'PENDING', 'APPROVED'], @@ -761,7 +408,6 @@ export class PropertiesService { SOLD: ['SOLD'], REMOVED: ['REMOVED', 'DRAFT'], }; - return validTransitions[currentStatus]?.includes(targetStatus) || false; } } diff --git a/test/auth/auth.service.spec.ts b/test/auth/auth.service.spec.ts index 51298ee..4046d3b 100644 --- a/test/auth/auth.service.spec.ts +++ b/test/auth/auth.service.spec.ts @@ -99,7 +99,7 @@ describe('AuthService', () => { jest.spyOn(authService, 'validateUserByEmail').mockResolvedValue(null); redisMock.get.mockResolvedValue('0'); - await expect(authService.login(creds)).rejects.toThrow('Invalid credentials'); + await expect(authService.login(creds)).rejects.toThrow('The provided credentials are invalid'); expect(redisMock.setex).toHaveBeenCalledWith('login_attempts:foo@bar.com', 600, '1'); }); diff --git a/test/properties/properties.service.spec.ts b/test/properties/properties.service.spec.ts index 8831464..5d8a687 100644 --- a/test/properties/properties.service.spec.ts +++ b/test/properties/properties.service.spec.ts @@ -5,7 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { CreatePropertyDto, PropertyStatus, PropertyType } from '../../src/properties/dto/create-property.dto'; import { UpdatePropertyDto } from '../../src/properties/dto/update-property.dto'; import { PropertyQueryDto } from '../../src/properties/dto/property-query.dto'; -import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { NotFoundException, UserNotFoundException, InvalidInputException, BusinessRuleViolationException } from '../../src/common/errors/custom.exceptions'; import { Decimal } from '@prisma/client/runtime/library'; describe('PropertiesService', () => { @@ -146,14 +146,14 @@ describe('PropertiesService', () => { it('should throw NotFoundException if user does not exist', async () => { mockPrismaService.user.findUnique.mockResolvedValue(null); - await expect(service.create(createPropertyDto, 'invalid_user')).rejects.toThrow(NotFoundException); + await expect(service.create(createPropertyDto, 'invalid_user')).rejects.toThrow(UserNotFoundException); }); it('should handle database errors', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.property.create.mockRejectedValue(new Error('Database error')); - await expect(service.create(createPropertyDto, 'user_123')).rejects.toThrow(BadRequestException); + await expect(service.create(createPropertyDto, 'user_123')).rejects.toThrow(InvalidInputException); }); }); @@ -529,7 +529,7 @@ describe('PropertiesService', () => { }); await expect(service.updateStatus('prop_123', PropertyStatus.AVAILABLE, 'user_123')).rejects.toThrow( - BadRequestException, + BusinessRuleViolationException, ); }); });