diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b10713a..16e299a 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,10 +4,30 @@ import { AppService } from './app.service'; import { UserModule } from './modules/user/user.module'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './modules/auth/auth.module'; +import { APP_FILTER } from '@nestjs/core'; +import { + AllExceptionsFilter, + DomainExceptionFilter, + PrismaExceptionFilter, +} from './shared/filters'; @Module({ imports: [PrismaModule, UserModule, AuthModule], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + { + provide: APP_FILTER, + useClass: DomainExceptionFilter, + }, + { + provide: APP_FILTER, + useClass: PrismaExceptionFilter, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index 0a0dcf5..8fc0f8e 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UserDto } from '../user/dto/user.dto'; import bcrypt from 'bcrypt'; import { CreateUserDto } from './dto/create-user.dto'; @@ -6,6 +6,7 @@ import { hashPassword } from './utils/hashPassword/hashPassword'; import { JwtService } from '@nestjs/jwt'; import { UserService } from '../user/user.service'; import { JwtPayload } from './types/jwt-payload.type'; +import { DomainError } from '../../shared/errors'; @Injectable() export class AuthService { @@ -56,7 +57,7 @@ export class AuthService { async me(email: string): Promise { const user = await this.userService.findByEmailOrNull(email); if (!user) { - throw new UnauthorizedException('Not authorized'); + throw DomainError.Unauthorized(); } return { diff --git a/apps/backend/src/modules/auth/strategies/jwt.strategy.ts b/apps/backend/src/modules/auth/strategies/jwt.strategy.ts index 37d95a5..0a7efb4 100644 --- a/apps/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -1,9 +1,10 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { env } from '../../../env'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { JwtPayload } from '../types/jwt-payload.type'; import { UserService } from '../../user/user.service'; +import { DomainError } from '../../../shared/errors'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -17,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: JwtPayload): Promise { const user = await this.userService.findByEmailOrNull(payload.email); if (!user) { - throw new UnauthorizedException('Not authorized'); + throw DomainError.Unauthorized(); } return payload; } diff --git a/apps/backend/src/modules/auth/strategies/local.strategy.ts b/apps/backend/src/modules/auth/strategies/local.strategy.ts index 60a8a8e..cb8163a 100644 --- a/apps/backend/src/modules/auth/strategies/local.strategy.ts +++ b/apps/backend/src/modules/auth/strategies/local.strategy.ts @@ -1,8 +1,9 @@ import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-local'; import { AuthService } from '../auth.service'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UserDto } from '../../user/dto/user.dto'; +import { DomainError } from '../../../shared/errors'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { @@ -16,7 +17,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(email: string, password: string): Promise { const user = await this.authService.validateUser(email, password); if (!user) { - throw new UnauthorizedException('Not authorized'); + throw DomainError.Unauthorized(); } return user; } diff --git a/apps/backend/src/modules/user/user.service.ts b/apps/backend/src/modules/user/user.service.ts index 2a12da6..66fd438 100644 --- a/apps/backend/src/modules/user/user.service.ts +++ b/apps/backend/src/modules/user/user.service.ts @@ -1,10 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { UpdateUserDto } from './dto/update-user.dto'; import { PrismaService } from '../../prisma/prisma.service'; import { UserDto } from './dto/user.dto'; import { CreateUserDto } from '../auth/dto/create-user.dto'; import { User } from '@prisma/client'; -import { handlePrismaError } from '../../shared/helpers/handle-prisma-error.helper'; +import { DomainError } from '../../shared/errors'; @Injectable() export class UserService { @@ -28,7 +28,7 @@ export class UserService { where: { id }, }); if (!user) { - throw new NotFoundException('User not found'); + throw DomainError.NotFound('User not found'); } return { @@ -46,47 +46,35 @@ export class UserService { } async create(createUserDto: CreateUserDto): Promise { - try { - const user = await this.prisma.user.create({ - data: createUserDto, - }); + const user = await this.prisma.user.create({ + data: createUserDto, + }); - return { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - }; - } catch (error) { - handlePrismaError(error, { UNIQUE_CONSTRAINT: 'User with this email already exists' }); - } + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }; } async update(id: string, updateUserDto: UpdateUserDto): Promise { - try { - const updatedUser = await this.prisma.user.update({ - where: { id }, - data: updateUserDto, - }); + const updatedUser = await this.prisma.user.update({ + where: { id }, + data: updateUserDto, + }); - return { - id: updatedUser.id, - name: updatedUser.name, - email: updatedUser.email, - role: updatedUser.role, - }; - } catch (error) { - handlePrismaError(error, { NOT_FOUND: 'User not found' }); - } + return { + id: updatedUser.id, + name: updatedUser.name, + email: updatedUser.email, + role: updatedUser.role, + }; } async remove(id: string): Promise { - try { - await this.prisma.user.delete({ - where: { id }, - }); - } catch (error) { - handlePrismaError(error, { NOT_FOUND: 'User not found' }); - } + await this.prisma.user.delete({ + where: { id }, + }); } } diff --git a/apps/backend/src/shared/errors/domain.error.ts b/apps/backend/src/shared/errors/domain.error.ts new file mode 100644 index 0000000..bff5b5c --- /dev/null +++ b/apps/backend/src/shared/errors/domain.error.ts @@ -0,0 +1,29 @@ +export class DomainError extends Error { + constructor( + public readonly message: string, + public readonly code: 'NOT_FOUND' | 'CONFLICT' | 'BAD_REQUEST' | 'FORBIDDEN' | 'UNAUTHORIZED' + ) { + super(message); + this.name = 'DomainError'; + } + + static NotFound(message: string = 'Not Found'): DomainError { + return new DomainError(message, 'NOT_FOUND'); + } + + static Unauthorized(message: string = 'Not authorized'): DomainError { + return new DomainError(message, 'UNAUTHORIZED'); + } + + static Conflict(message: string = 'Conflict'): DomainError { + return new DomainError(message, 'CONFLICT'); + } + + static BadRequest(message: string = 'Bad Request'): DomainError { + return new DomainError(message, 'BAD_REQUEST'); + } + + static Forbidden(message: string = "You don't have permissions to do this!"): DomainError { + return new DomainError(message, 'FORBIDDEN'); + } +} diff --git a/apps/backend/src/shared/errors/index.ts b/apps/backend/src/shared/errors/index.ts new file mode 100644 index 0000000..243da4f --- /dev/null +++ b/apps/backend/src/shared/errors/index.ts @@ -0,0 +1 @@ +export { DomainError } from './domain.error'; diff --git a/apps/backend/src/shared/filters/all-exception.filter.ts b/apps/backend/src/shared/filters/all-exception.filter.ts new file mode 100644 index 0000000..4817bf9 --- /dev/null +++ b/apps/backend/src/shared/filters/all-exception.filter.ts @@ -0,0 +1,54 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { HttpAdapterHost } from '@nestjs/core'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + constructor(private readonly httpAdapterHost: HttpAdapterHost) {} + + catch(exception: unknown, host: ArgumentsHost) { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message: string | object = 'Internal server error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + message = + typeof exceptionResponse === 'object' && + exceptionResponse !== null && + 'message' in exceptionResponse + ? (exceptionResponse as { message: string | string[] }).message + : exception.message; + } else if (exception instanceof Error) { + this.logger.error( + `[${request.method}] ${request.url} - ${exception.message}`, + exception.stack + ); + message = 'Internal server error. Please try again later.'; + } + + const responseBody = { + statusCode: status, + message: message, + timestamp: new Date().toISOString(), + path: request.url, + }; + + httpAdapter.reply(response, responseBody, status); + } +} diff --git a/apps/backend/src/shared/filters/domain-exception.filter.ts b/apps/backend/src/shared/filters/domain-exception.filter.ts new file mode 100644 index 0000000..e083ba3 --- /dev/null +++ b/apps/backend/src/shared/filters/domain-exception.filter.ts @@ -0,0 +1,46 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common'; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { DomainError } from '../errors'; +import { HttpAdapterHost } from '@nestjs/core'; + +@Catch(DomainError) +export class DomainExceptionFilter implements ExceptionFilter { + constructor(private readonly httpAdapterHost: HttpAdapterHost) {} + + catch(exception: DomainError, host: ArgumentsHost) { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + switch (exception.code) { + case 'NOT_FOUND': + status = HttpStatus.NOT_FOUND; + break; + case 'CONFLICT': + status = HttpStatus.CONFLICT; + break; + case 'BAD_REQUEST': + status = HttpStatus.BAD_REQUEST; + break; + case 'FORBIDDEN': + status = HttpStatus.FORBIDDEN; + break; + case 'UNAUTHORIZED': + status = HttpStatus.UNAUTHORIZED; + break; + } + + const responseBody = { + statusCode: status, + message: exception.message, + error: exception.code, + timestamp: new Date().toISOString(), + path: request.url, + }; + + httpAdapter.reply(response, responseBody, status); + } +} diff --git a/apps/backend/src/shared/filters/index.ts b/apps/backend/src/shared/filters/index.ts new file mode 100644 index 0000000..360b7d9 --- /dev/null +++ b/apps/backend/src/shared/filters/index.ts @@ -0,0 +1,3 @@ +export { AllExceptionsFilter } from './all-exception.filter'; +export { DomainExceptionFilter } from './domain-exception.filter'; +export { PrismaExceptionFilter } from './prisma-exception.filter'; diff --git a/apps/backend/src/shared/filters/prisma-exception.filter.ts b/apps/backend/src/shared/filters/prisma-exception.filter.ts new file mode 100644 index 0000000..0bf4540 --- /dev/null +++ b/apps/backend/src/shared/filters/prisma-exception.filter.ts @@ -0,0 +1,66 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { FastifyRequest, FastifyReply } from 'fastify'; +import { HttpAdapterHost } from '@nestjs/core'; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(PrismaExceptionFilter.name); + + constructor(private readonly httpAdapterHost: HttpAdapterHost) {} + + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal database error'; + + switch (exception.code) { + case 'P2002': + // Unique constraint + status = HttpStatus.CONFLICT; + message = 'Record already exist'; + break; + case 'P2025': // Record is not found for create/update/delete + status = HttpStatus.NOT_FOUND; + message = 'Record not found'; + break; + case 'P2003': // Foreign key constraint + status = HttpStatus.BAD_REQUEST; + message = 'Foreign key constraint failed'; + break; + case 'P2000': { + // Value too long + status = HttpStatus.BAD_REQUEST; + const column = exception.meta?.column_name + ? String(exception.meta.column_name) + : 'UNKNOWN_FIELD'; + message = `The provided value is too long for a field "${column}"`; + break; + } + } + + if (status === HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.error( + `[${request.method}] ${request.url} - Prisma Error ${exception.code}`, + exception.stack + ); + } else { + this.logger.warn( + `[${request.method}] ${request.url} - Client Error (Prisma ${exception.code}): ${message}` + ); + } + + const responseBody = { + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }; + + httpAdapter.reply(response, responseBody, status); + } +} diff --git a/apps/backend/src/shared/helpers/handle-prisma-error.helper.ts b/apps/backend/src/shared/helpers/handle-prisma-error.helper.ts deleted file mode 100644 index 24c466a..0000000 --- a/apps/backend/src/shared/helpers/handle-prisma-error.helper.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; - -type PrismaErrorMessages = { - NOT_FOUND?: string; - UNIQUE_CONSTRAINT?: string; - FOREIGN_KEY?: string; - VALUE_TOO_LONG?: string; -}; - -export function handlePrismaError(error: unknown, messages: PrismaErrorMessages): never { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - switch (error.code) { - case 'P2025': - if (messages.NOT_FOUND) { - throw new NotFoundException(messages.NOT_FOUND ?? 'Not found'); - } - break; - - case 'P2002': - if (messages.UNIQUE_CONSTRAINT) { - throw new ConflictException(messages.UNIQUE_CONSTRAINT ?? 'Unique constraint failed'); - } - break; - - case 'P2003': - if (messages.FOREIGN_KEY) { - throw new ConflictException(messages.FOREIGN_KEY ?? 'Foreign key constraint failed'); - } - break; - - case 'P2000': - if (messages.VALUE_TOO_LONG) { - throw new BadRequestException(messages.VALUE_TOO_LONG ?? 'Value too long'); - } - break; - } - } - - throw error; -}