Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
5 changes: 3 additions & 2 deletions apps/backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';
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 {
Expand Down Expand Up @@ -56,7 +57,7 @@ export class AuthService {
async me(email: string): Promise<UserDto> {
const user = await this.userService.findByEmailOrNull(email);
if (!user) {
throw new UnauthorizedException('Not authorized');
throw DomainError.Unauthorized();
}

return {
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/modules/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -17,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
async validate(payload: JwtPayload): Promise<JwtPayload> {
const user = await this.userService.findByEmailOrNull(payload.email);
if (!user) {
throw new UnauthorizedException('Not authorized');
throw DomainError.Unauthorized();
}
return payload;
}
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/modules/auth/strategies/local.strategy.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -16,7 +17,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
async validate(email: string, password: string): Promise<UserDto> {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Not authorized');
throw DomainError.Unauthorized();
}
return user;
}
Expand Down
62 changes: 25 additions & 37 deletions apps/backend/src/modules/user/user.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -28,7 +28,7 @@ export class UserService {
where: { id },
});
if (!user) {
throw new NotFoundException('User not found');
throw DomainError.NotFound('User not found');
}

return {
Expand All @@ -46,47 +46,35 @@ export class UserService {
}

async create(createUserDto: CreateUserDto): Promise<UserDto> {
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<UserDto> {
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<void> {
try {
await this.prisma.user.delete({
where: { id },
});
} catch (error) {
handlePrismaError(error, { NOT_FOUND: 'User not found' });
}
await this.prisma.user.delete({
where: { id },
});
}
}
29 changes: 29 additions & 0 deletions apps/backend/src/shared/errors/domain.error.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
1 change: 1 addition & 0 deletions apps/backend/src/shared/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DomainError } from './domain.error';
54 changes: 54 additions & 0 deletions apps/backend/src/shared/filters/all-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();

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);
}
}
46 changes: 46 additions & 0 deletions apps/backend/src/shared/filters/domain-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();

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);
}
}
3 changes: 3 additions & 0 deletions apps/backend/src/shared/filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AllExceptionsFilter } from './all-exception.filter';
export { DomainExceptionFilter } from './domain-exception.filter';
export { PrismaExceptionFilter } from './prisma-exception.filter';
66 changes: 66 additions & 0 deletions apps/backend/src/shared/filters/prisma-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();

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);
}
}
Loading
Loading