From 5acbdbe4b9b952186e9c578acefc455c07a3319f Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 20 Apr 2026 00:40:53 +0300 Subject: [PATCH 1/2] refactor(core): unify exception filter and resolve technical debt --- .env.example | 2 + libs/bootstrap/src/bootstrap.ts | 1 - libs/config/src/config.schema.ts | 5 + .../src/controller/health.controller.ts | 17 +- .../session.repository.interface.ts | 2 +- .../auth/repository/session.repository.ts | 22 +- src/modules/auth/services/auth.service.ts | 142 +++++++----- src/modules/auth/services/recovery.service.ts | 81 ++++--- src/modules/auth/services/token.service.ts | 21 +- .../auth/strategies/bearer.strategy.ts | 2 +- .../auth/strategies/cookie.strategy.ts | 17 +- src/modules/auth/types/index.ts | 1 - src/modules/media/media.service.ts | 37 ++- .../projects/commands/find-project.command.ts | 51 ++++- .../projects/mappers/projects.mapper.ts | 3 +- .../projects/services/projects.service.ts | 213 ++++++++++++------ .../controller/invitations.controller.ts | 2 +- src/modules/teams/controller/me.controller.ts | 2 +- src/modules/teams/dtos/member.dto.ts | 13 +- src/modules/teams/dtos/team.dto.ts | 7 +- src/modules/teams/entities/teams.domain.ts | 9 - .../teams/services/invitations.service.ts | 128 ++++++++--- src/modules/teams/services/members.service.ts | 163 ++++++++++---- .../teams/services/settings.service.ts | 52 +++-- src/modules/teams/services/teams.service.ts | 88 ++++++-- src/modules/user/commands/create.command.ts | 44 +++- src/modules/user/commands/find-one.command.ts | 11 +- .../user/commands/update-pass.command.ts | 45 +++- src/modules/user/dtos/user.dto.ts | 13 +- src/modules/user/services/settings.service.ts | 53 +++-- src/modules/user/services/user.service.ts | 56 +++-- src/shared/constants/index.ts | 1 + src/shared/constants/roles.constant.ts | 7 + .../extract-fastify-file.decorator.ts | 37 ++- src/shared/decorators/user.decorator.ts | 14 +- src/shared/error/exception.ts | 18 ++ src/shared/error/filter.ts | 176 +++++++++++---- src/shared/error/index.ts | 1 + src/shared/error/swagger.ts | 10 + src/shared/guards/bearer.guard.ts | 22 +- src/shared/types/fastify.d.ts | 2 +- src/shared/types/index.ts | 1 + .../auth => shared}/types/jwt-payload.ts | 0 43 files changed, 1125 insertions(+), 467 deletions(-) delete mode 100644 src/modules/auth/types/index.ts create mode 100644 src/shared/constants/roles.constant.ts create mode 100644 src/shared/error/exception.ts create mode 100644 src/shared/types/index.ts rename src/{modules/auth => shared}/types/jwt-payload.ts (100%) diff --git a/.env.example b/.env.example index 5954e6e..f3d8615 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_ REDIS_HOST=127.0.0.1 REDIS_PORT=7000 +JWT_AUDIENCE="task-tracker-client" + JWT_ACCESS_SECRET=same-same-same-same-same JWT_ACCESS_EXPIRES_IN=15m diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts index 9f7ced1..39fb6bc 100644 --- a/libs/bootstrap/src/bootstrap.ts +++ b/libs/bootstrap/src/bootstrap.ts @@ -36,7 +36,6 @@ export async function bootstrapApp(options: BootstrapOptions) { let rootModule = appModule; - // TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle if (throttlerOptions) { rootModule = setupThrottler(rootModule, throttlerOptions); } diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts index 81a90bc..a957f35 100644 --- a/libs/config/src/config.schema.ts +++ b/libs/config/src/config.schema.ts @@ -35,6 +35,11 @@ export const ConfigSchema = z.object({ .min(1, "CORS_ALLOWED_ORIGINS can't be empty") .transform((val) => val.split(',').map((s) => s.trim())) .pipe(z.array(z.string().url('Each origin must be a valid URL'))), + JWT_AUDIENCE: z + .string({ + error: 'JWT_AUDIENCE is required', + }) + .min(1), JWT_ACCESS_SECRET: z.string().refine(jwtSecretValidation, { message: 'JWT_ACCESS_SECRET must be at least 32 characters long OR contain at least 5 words separated by hyphens', diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index cba9bba..e29e304 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from '../health.service'; import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; import { ApiTags } from '@nestjs/swagger'; +import { BaseException } from '@shared/error'; @SkipThrottle() @Controller() @@ -22,8 +23,18 @@ export class HealthController { if (pingData.status !== 'up') { this.logger.error(`${this.serviceName} is unhealthy!`); - throw new HttpException( - `${this.serviceName} service is unhealthy.`, + throw new BaseException( + { + code: 'SERVICE_UNHEALTHY', + message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`, + details: [ + { + target: this.serviceName, + status: pingData.status, + timestamp: new Date().toISOString(), + }, + ], + }, HttpStatus.SERVICE_UNAVAILABLE, ); } diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/modules/auth/repository/session.repository.interface.ts index ede9fc5..cde6762 100644 --- a/src/modules/auth/repository/session.repository.interface.ts +++ b/src/modules/auth/repository/session.repository.interface.ts @@ -7,7 +7,7 @@ export interface ISessionRepository { create(data: SessionInsert): Promise; findById(id: string): Promise; findAllByUserId(userId: string): Promise; - revoke(id: string): Promise; + revoke(id: string): Promise; revokeAllByUserId(userId: string, exceptSessionId?: string): Promise; deleteExpired(): Promise; } diff --git a/src/modules/auth/repository/session.repository.ts b/src/modules/auth/repository/session.repository.ts index be4ba1c..43510a0 100644 --- a/src/modules/auth/repository/session.repository.ts +++ b/src/modules/auth/repository/session.repository.ts @@ -2,11 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; import * as schema from '../entities'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { - ISessionRepository, - type SessionInsert, - SessionSelect, -} from './session.repository.interface'; +import { ISessionRepository, type SessionInsert } from './session.repository.interface'; @Injectable() export class SessionRepository implements ISessionRepository { @@ -15,12 +11,12 @@ export class SessionRepository implements ISessionRepository { private readonly db: DatabaseService, ) {} - async create(data: SessionInsert): Promise { + async create(data: SessionInsert) { const [result] = await this.db.insert(schema.sessions).values(data).returning(); return result; } - async findById(id: string): Promise { + async findById(id: string) { const [result] = await this.db .select() .from(schema.sessions) @@ -30,7 +26,7 @@ export class SessionRepository implements ISessionRepository { return result || null; } - async findAllByUserId(userId: string): Promise { + async findAllByUserId(userId: string) { return this.db .select() .from(schema.sessions) @@ -38,14 +34,16 @@ export class SessionRepository implements ISessionRepository { .orderBy(desc(schema.sessions.createdAt)); } - async revoke(id: string): Promise { - await this.db + async revoke(id: string) { + const { rowCount } = await this.db .update(schema.sessions) .set({ isRevoked: true, updatedAt: new Date() }) .where(eq(schema.sessions.id, id)); + + return (rowCount ?? 0) > 0; } - async revokeAllByUserId(userId: string, exceptSessionId?: string): Promise { + async revokeAllByUserId(userId: string, exceptSessionId?: string) { const filters = [eq(schema.sessions.userId, userId)]; if (exceptSessionId) { @@ -58,7 +56,7 @@ export class SessionRepository implements ISessionRepository { .where(and(...filters)); } - async deleteExpired(): Promise { + async deleteExpired() { const result = await this.db .delete(schema.sessions) .where(lt(schema.sessions.expiresAt, new Date())); diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 4c3e7eb..6da50ff 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ConflictException, - Inject, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; @@ -18,6 +12,7 @@ import { InjectQueue } from '@nestjs/bullmq'; import { Queues, RegisterCodeEvent } from '@shared/workers'; import type { Queue } from 'bullmq'; import { MailJobs } from '@shared/workers/enum'; +import { BaseException } from '@shared/error'; @Injectable() export class AuthService { @@ -39,20 +34,27 @@ export class AuthService { const cachedData = await this.redis.get(redisKey); if (cachedData) { - throw new BadRequestException({ - code: 'REGISTRATION_IN_PROGRESS', - message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', - }); + throw new BaseException( + { + code: 'REGISTRATION_IN_PROGRESS', + message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); } const isExists = await this.findUserCommand.execute({ email: dto.email }); if (isExists) { - throw new ConflictException({ - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: { email: dto.email }, - }); + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); } const hashPass = await argon.hash(dto.password); @@ -95,10 +97,13 @@ export class AuthService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }); + throw new BaseException( + { + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }, + HttpStatus.GONE, + ); } const userData = JSON.parse(cachedData); @@ -114,10 +119,14 @@ export class AuthService { }); if (!verifyResult.valid) { - throw new BadRequestException({ - code: 'INVALID_OTP', - message: 'Неверный или истекший код подтверждения', - }); + throw new BaseException( + { + code: 'INVALID_OTP', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'OTP code is invalid or expired' }], + }, + HttpStatus.BAD_REQUEST, + ); } const user = await this.createUserCommand.execute({ @@ -145,19 +154,25 @@ export class AuthService { const { user, security } = await this.findUserCommand.execute({ email: dto.email }); if (!user || !security) { - throw new UnauthorizedException({ - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }); + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); } const isPasswordValid = await argon.verify(security.passwordHash, dto.password); if (!isPasswordValid) { - throw new UnauthorizedException({ - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }); + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); } const { id } = await this.sessionRepo.create({ @@ -181,30 +196,39 @@ export class AuthService { public refresh = async (token: string, metadata: DeviceMetadata) => { const payload = await this.tokenService.validateToken(token, 'refresh'); - if (!payload || !payload.jti) { - throw new UnauthorizedException({ - code: 'INVALID_TOKEN', - message: 'Сессия недействительна или истекла', - }); + if (!payload?.jti) { + throw new BaseException( + { + code: 'INVALID_TOKEN', + message: 'Сессия недействительна или истекла', + }, + HttpStatus.UNAUTHORIZED, + ); } const session = await this.sessionRepo.findById(payload.jti); if (!session || session.isRevoked) { - throw new UnauthorizedException({ - code: 'SESSION_REVOKED', - message: 'Ваша сессия была отозвана или завершена', - }); + throw new BaseException( + { + code: 'SESSION_REVOKED', + message: 'Ваша сессия была отозвана или завершена', + }, + HttpStatus.UNAUTHORIZED, + ); } const { user } = await this.findUserCommand.execute({ id: session.userId }); if (!user) { await this.sessionRepo.revoke(session.id); - throw new UnauthorizedException({ - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); } await this.sessionRepo.revoke(session.id); @@ -228,20 +252,32 @@ export class AuthService { const payload = await this.tokenService.validateToken(token, 'refresh'); if (!payload?.jti) { - throw new UnauthorizedException({ code: 'SESSION_EXPIRED', message: 'Сессия истекла' }); + throw new BaseException( + { + code: 'SESSION_EXPIRED', + message: 'Сессия уже истекла', + }, + HttpStatus.UNAUTHORIZED, + ); } const session = await this.sessionRepo.findById(payload.jti); - if (!session) { - throw new UnauthorizedException({ - code: 'SESSION_NOT_FOUND', - message: 'Сессия не найдена', - }); + if (session) { + const isRevoked = await this.sessionRepo.revoke(session.id); + + if (!isRevoked) { + throw new BaseException( + { + code: 'SIGNOUT_FAILED', + message: 'Не удалось завершить сессию на сервере. Попробуйте позже.', + details: [{ target: 'database', message: 'Session revocation failed' }], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } } - await this.sessionRepo.revoke(session.id); - return { success: true, message: 'Успешно вышли из системы!' }; }; } diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts index 1a070cc..ba6312c 100644 --- a/src/modules/auth/services/recovery.service.ts +++ b/src/modules/auth/services/recovery.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; @@ -16,6 +10,7 @@ import { Queues } from '@shared/workers'; import type { Queue } from 'bullmq'; import { MailJobs } from '@shared/workers/enum'; import { ResetPasswordEvent } from '@shared/workers/events'; +import { BaseException } from '@shared/error'; @Injectable() export class AuthRecoveryService { @@ -32,11 +27,14 @@ export class AuthRecoveryService { const { user } = await this.findUserCommand.execute({ email: dto.email }); if (!user) { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: { email: dto.email }, - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь с таким email не найден', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); } const secret = generateSecret(); @@ -75,10 +73,14 @@ export class AuthRecoveryService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_EXPIRED', - message: 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }); + throw new BaseException( + { + code: 'RESET_SESSION_EXPIRED', + message: + 'Время подтверждения истекло или запрос не найден. Запросите код снова.', + }, + HttpStatus.GONE, + ); } const resetSession = JSON.parse(cachedData); @@ -92,10 +94,14 @@ export class AuthRecoveryService { }); if (!verifyResult.valid) { - throw new BadRequestException({ - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - }); + throw new BaseException( + { + code: 'INVALID_VERIFICATION_CODE', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); } await this.redis.set( @@ -116,29 +122,40 @@ export class AuthRecoveryService { const cachedData = await this.redis.get(redisKey); if (!cachedData) { - throw new BadRequestException({ - code: 'RESET_SESSION_NOT_FOUND', - message: 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }); + throw new BaseException( + { + code: 'RESET_SESSION_NOT_FOUND', + message: + 'Сессия восстановления не найдена или истекла. Начните процесс заново.', + }, + HttpStatus.BAD_REQUEST, + ); } const resetSession = JSON.parse(cachedData); if (!resetSession.isVerified) { - throw new ForbiddenException({ - code: 'CODE_NOT_VERIFIED', - message: 'Код подтверждения еще не был верифицирован.', - }); + throw new BaseException( + { + code: 'CODE_NOT_VERIFIED', + message: 'Код подтверждения еще не был верифицирован.', + details: [{ target: 'isVerified', value: false }], + }, + HttpStatus.FORBIDDEN, + ); } const hashed = await argon.hash(dto.password); const isUpdated = await this.updateUserPass.execute(dto.email, hashed); if (!isUpdated) { - throw new InternalServerErrorException({ - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }); + throw new BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Попробуйте позже.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } await this.redis.del(redisKey); diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts index b61426c..43d61fe 100644 --- a/src/modules/auth/services/token.service.ts +++ b/src/modules/auth/services/token.service.ts @@ -1,36 +1,35 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; @Injectable() export class TokenService { constructor( private readonly jwtService: JwtService, - private readonly configService: ConfigService, + private readonly cfg: ConfigService, ) {} async generateTokens(user: any, sessionId: string) { - const domain = this.configService.get('DOMAIN'); + const domain = this.cfg.get('DOMAIN'); const payload = { jti: sessionId, sub: user.id, email: user.email, iss: btoa(domain), - // TODO: ADD TO ENV GLOBAL - aud: btoa('task-tracker-client'), + aud: btoa(this.cfg.getOrThrow('JWT_AUDIENCE')), role: user.role, }; const [access, refresh] = await Promise.all([ this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_ACCESS_SECRET'), - expiresIn: this.configService.get('JWT_ACCESS_EXPIRES_IN'), + secret: this.cfg.get('JWT_ACCESS_SECRET'), + expiresIn: this.cfg.get('JWT_ACCESS_EXPIRES_IN'), }), this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), + secret: this.cfg.get('JWT_REFRESH_SECRET'), + expiresIn: this.cfg.get('JWT_REFRESH_EXPIRES_IN'), }), ]); @@ -41,8 +40,8 @@ export class TokenService { try { const secret = type === 'access' - ? this.configService.get('JWT_ACCESS_SECRET') - : this.configService.get('JWT_REFRESH_SECRET'); + ? this.cfg.get('JWT_ACCESS_SECRET') + : this.cfg.get('JWT_REFRESH_SECRET'); return this.jwtService.verifyAsync(token, { secret }); } catch (e) { diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/modules/auth/strategies/bearer.strategy.ts index d7914ed..a7ccdfc 100644 --- a/src/modules/auth/strategies/bearer.strategy.ts +++ b/src/modules/auth/strategies/bearer.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/modules/auth/strategies/cookie.strategy.ts index d821a1f..4411361 100644 --- a/src/modules/auth/strategies/cookie.strategy.ts +++ b/src/modules/auth/strategies/cookie.strategy.ts @@ -1,9 +1,10 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { FastifyRequest } from 'fastify'; -import type { JwtPayload } from '../types'; +import type { JwtPayload } from '@shared/types'; +import { BaseException } from '@shared/error'; @Injectable() export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { @@ -21,10 +22,14 @@ export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { validate(_req: FastifyRequest, payload: JwtPayload) { if (!payload || !payload.jti) { - throw new UnauthorizedException({ - code: 'INVALID_REFRESH_TOKEN', - message: 'Refresh токен невалиден или протух', - }); + throw new BaseException( + { + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или протух', + details: [{ target: 'auth', reason: 'Payload is missing or jti is invalid' }], + }, + HttpStatus.UNAUTHORIZED, + ); } return payload; diff --git a/src/modules/auth/types/index.ts b/src/modules/auth/types/index.ts deleted file mode 100644 index 324f5b4..0000000 --- a/src/modules/auth/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './jwt-payload'; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 2a94960..a775a26 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, HttpException, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { S3Service } from '@libs/s3'; import type { FileUploadDto, FileUploadResponseDto } from './dtos'; import { IUserMedia } from './interfaces/user-media.interface'; import { ITeamMedia } from './interfaces/team-media.interface'; +import { BaseException } from '@shared/error'; @Injectable() export class MediaService implements IUserMedia, ITeamMedia { @@ -19,20 +20,42 @@ export class MediaService implements IUserMedia, ITeamMedia { const isUpdated = await updateDbFn(url); if (!isUpdated) { - throw new Error('ENTITY_NOT_FOUND'); + throw new BaseException( + { + code: 'ENTITY_NOT_FOUND', + message: 'Сущность не найдена, обновление отменено', + details: [ + { + target: 'id', + message: 'Record with provided ID does not exist in database', + }, + ], + }, + HttpStatus.NOT_FOUND, + ); } return { success: true, url }; } catch (error) { - const isHttpException = error instanceof HttpException; - await this.s3.deleteFile(url); - if (isHttpException && error.message === 'ENTITY_NOT_FOUND') { - throw new BadRequestException('Сущность не найдена, обновление отменено'); + if (error instanceof BaseException) { + throw error; } - throw new BadRequestException('Ошибка при сохранении медиа-данных'); + throw new BaseException( + { + code: 'MEDIA_SAVE_FAILED', + message: 'Ошибка при сохранении медиа-данных', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } } diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts index cecc68f..099e8eb 100644 --- a/src/modules/projects/commands/find-project.command.ts +++ b/src/modules/projects/commands/find-project.command.ts @@ -1,14 +1,9 @@ -import { - ForbiddenException, - Inject, - Injectable, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; import { FindTeamMemberCommand } from '@core/modules/teams'; import { createHash } from 'crypto'; import type { Project } from '../entities'; +import { BaseException } from '@shared/error'; @Injectable() export class FindProjectCommand { @@ -22,7 +17,14 @@ export class FindProjectCommand { const project = await this.projectsRepo.findOne(projectId); if (!project) { - throw new NotFoundException('Проект не найден или доступ ограничен'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден или доступ ограничен', + details: [{ target: 'projectId', value: projectId }], + }, + HttpStatus.NOT_FOUND, + ); } if (shareToken) { @@ -34,13 +36,26 @@ export class FindProjectCommand { private findPrivate = async (project: Project, userId?: string) => { if (!userId) { - throw new UnauthorizedException('Для доступа к приватному проекту нужна авторизация'); + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Для доступа к приватному проекту нужна авторизация', + }, + HttpStatus.UNAUTHORIZED, + ); } const member = await this.findTeamMemberCommand.execute(project.teamId, userId); if (!member) { - throw new ForbiddenException('У вас нет прав для просмотра этого проекта'); + throw new BaseException( + { + code: 'ACCESS_DENIED', + message: 'У вас нет прав для просмотра этого проекта', + details: [{ target: 'teamId', value: project.teamId }], + }, + HttpStatus.FORBIDDEN, + ); } return { project, member }; @@ -48,14 +63,26 @@ export class FindProjectCommand { private findPublic = async (project: Project, token: string) => { if (project.visibility !== 'public') { - throw new ForbiddenException('Этот проект не является публичным'); + throw new BaseException( + { + code: 'PROJECT_NOT_PUBLIC', + message: 'Этот проект не является публичным', + }, + HttpStatus.FORBIDDEN, + ); } const hashedToken = createHash('sha256').update(token).digest('hex'); const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); if (!isValidToken) { - throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + throw new BaseException( + { + code: 'SHARE_LINK_INVALID', + message: 'Ссылка недействительна или срок её действия истек', + }, + HttpStatus.GONE, + ); } return { project, member: null }; diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/modules/projects/mappers/projects.mapper.ts index 5e17f42..e63220e 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/modules/projects/mappers/projects.mapper.ts @@ -1,5 +1,6 @@ import type { RawMemberRow } from '@core/modules/teams/repository'; -import { type Project, ROLE_PRIORITY } from '@shared/entities'; +import type { Project } from '@shared/entities'; +import { ROLE_PRIORITY } from '@shared/constants'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 94b7c10..4ea0667 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,19 +1,12 @@ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, - UnauthorizedException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; import { FindTeamCommand, FindTeamMemberCommand } from '@core/modules/teams'; -import { ROLE_PRIORITY } from '../../teams/entities/teams.domain'; +import { ROLE_PRIORITY } from '@shared/constants'; import { ProjectStatus } from '../entities'; import { ProjectsMapper } from '../mappers'; import { createHash, randomBytes } from 'crypto'; +import { BaseException } from '@shared/error'; @Injectable() export class ProjectsService { @@ -25,21 +18,7 @@ export class ProjectsService { ) {} public create = async (userId: string, slug: string, dto: CreateProjectDto) => { - const team = await this.findTeamCommand.execute(slug); - if (!team) { - throw new NotFoundException('Команда не найдена'); - } - - const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('Вы не являетесь участником этой команды'); - } - - if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY.admin) { - throw new ForbiddenException( - 'Только администраторы и владельцы могут создавать проекты', - ); - } + const { team } = await this.ensureTeamAccess(slug, userId, 'admin'); const data = { ...dto, @@ -49,18 +28,13 @@ export class ProjectsService { status: ProjectStatus.Active, }; - try { - const { result, id } = await this.projectsRepo.create(data); - - // TODO: RESOLVE AT ACTION RESPONSE EXTEND WITH PROJECT ID - return { - success: result, - message: `Проект ${dto.name} успешно создан`, - projectId: id, - }; - } catch (error) { - throw error; - } + const { result, id } = await this.projectsRepo.create(data); + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + projectId: id, + }; }; public generateToken = async ( @@ -77,10 +51,16 @@ export class ProjectsService { expiresAt = new Date(dto.ttl); if (expiresAt <= new Date()) { - throw new BadRequestException({ - code: 'INVALID_EXPIRATION', - message: 'Дата истечения не может быть в прошлом', - }); + throw new BaseException( + { + code: 'INVALID_EXPIRATION', + message: 'Дата истечения не может быть в прошлом', + details: [ + { target: 'ttl', message: 'Expiration date is behind current time' }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } } else { expiresAt = new Date(); @@ -97,11 +77,13 @@ export class ProjectsService { }); if (!isSaved) { - throw new InternalServerErrorException({ - code: 'SHARE_CREATE_FAILED', - message: 'Не удалось сгенерировать ссылку доступа', - service: 'pg', - }); + throw new BaseException( + { + code: 'SHARE_CREATE_FAILED', + message: 'Не удалось сгенерировать ссылку доступа', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } const durationMsg = dto.ttl @@ -123,6 +105,16 @@ export class ProjectsService { const project = await this.validateAccess(id, slug, userId); const result = await this.projectsRepo.delete(project.id); + if (!result) { + throw new BaseException( + { + code: 'DELETE_FAILED', + message: 'Не удалось удалить проект', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + return { success: result, message: result @@ -143,6 +135,17 @@ export class ProjectsService { }), }); + if (!result) { + throw new BaseException( + { + code: 'UPDATE_FAILED', + message: + 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', + }, + HttpStatus.BAD_REQUEST, + ); + } + return { success: result, message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', @@ -153,7 +156,13 @@ export class ProjectsService { const project = await this.projectsRepo.findOne(id); if (!project) { - throw new NotFoundException('Проект не найден'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден', + }, + HttpStatus.NOT_FOUND, + ); } if (token) { @@ -164,43 +173,39 @@ export class ProjectsService { ); if (!isValidAccess) { - throw new NotFoundException('Ссылка недействительна или срок её действия истек'); + throw new BaseException( + { + code: 'INVALID_TOKEN', + message: 'Ссылка недействительна или срок её действия истек', + }, + HttpStatus.GONE, + ); } return ProjectsMapper.toDetailResponse(project, null, token); } - let member = null; - if (!userId) { - throw new UnauthorizedException('Требуется авторизация'); + throw new BaseException( + { code: 'AUTH_REQUIRED', message: 'Требуется авторизация' }, + HttpStatus.UNAUTHORIZED, + ); } - const team = await this.findTeamCommand.execute(slug); - if (!team || team.id !== project.teamId) { - throw new NotFoundException('Команда не найдена или проект к ней не относится'); - } + const { member, team } = await this.ensureTeamAccess(slug, userId, 'viewer'); - member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('У вас нет доступа к этой команде'); + if (team.id !== project.teamId) { + throw new BaseException( + { code: 'PROJECT_MISMATCH', message: 'Проект не принадлежит этой команде' }, + HttpStatus.BAD_REQUEST, + ); } return ProjectsMapper.toDetailResponse(project, member); }; public findByTeam = async (slug: string, userId: string) => { - const team = await this.findTeamCommand.execute(slug); - - if (!team) { - throw new NotFoundException('Команда не найдена'); - } - - const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member) { - throw new ForbiddenException('У вас нет доступа к этой команде'); - } - + const { team, member } = await this.ensureTeamAccess(slug, userId, 'viewer'); const projects = await this.projectsRepo.findByTeam(team.id); return { @@ -221,6 +226,17 @@ export class ProjectsService { const project = await this.validateAccess(id, slug, userId); const result = await this.projectsRepo.update(project.id, { status }); + if (!result) { + throw new BaseException( + { + code: 'STATUS_UPDATE_FAILED', + message: 'Не удалось обновить статус проекта', + details: [{ target: 'status', value: status }], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + const messages: Record = { archived: `Проект «${project.name}» успешно архивирован`, active: `Проект «${project.name}» теперь активен`, @@ -233,20 +249,69 @@ export class ProjectsService { }; }; - private async validateAccess(id: string, slug: string, userId: string, minRole = 'admin') { + private async ensureTeamAccess( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { const team = await this.findTeamCommand.execute(slug); if (!team) { - throw new NotFoundException('Team not found'); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.findTeamMemberCommand.execute(team.id, userId); - if (!member || ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { - throw new ForbiddenException(`You need at least ${minRole} role to manage projects`); + if (!member) { + throw new BaseException( + { + code: 'NOT_TEAM_MEMBER', + message: 'Вы не являетесь участником этой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Только ${minRole} и выше могут выполнять это действие`, + details: [ + { + target: 'role', + message: `Current role: ${member.role}, Required: ${minRole}`, + }, + ], + }, + HttpStatus.FORBIDDEN, + ); } + return { team, member }; + } + + private async validateAccess( + id: string, + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'admin', + ) { + const { team } = await this.ensureTeamAccess(slug, userId, minRole); + const project = await this.projectsRepo.findOne(id); if (!project || project.teamId !== team.id) { - throw new NotFoundException('Project not found in this team'); + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден в этой команде', + }, + HttpStatus.NOT_FOUND, + ); } return project; diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/modules/teams/controller/invitations.controller.ts index ba09481..c1adc0c 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/modules/teams/controller/invitations.controller.ts @@ -2,7 +2,7 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { TeamInvitationsService } from '../services'; import { AcceptInviteSwagger, InviteMemberSwagger } from './teams.swagger'; -import type { JwtPayload } from '@core/modules/auth/types'; +import type { JwtPayload } from '@shared/types'; import { ApiOperation } from '@nestjs/swagger'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) diff --git a/src/modules/teams/controller/me.controller.ts b/src/modules/teams/controller/me.controller.ts index 5b3390f..9ec2f60 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/modules/teams/controller/me.controller.ts @@ -2,7 +2,7 @@ import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { MeService } from '../services'; import { Get, Query } from '@nestjs/common'; import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; -import type { JwtPayload } from '@core/modules/auth/types'; +import type { JwtPayload } from '@shared/types'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts index 80eb841..fb740dc 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/modules/teams/dtos/member.dto.ts @@ -11,10 +11,15 @@ export const InviteMemberSchema = z.object({ export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} -const UpdateMemberDtoSchema = z.object({ - role: z.string().optional().describe('Новая роль участника'), - status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), -}); +const UpdateMemberDtoSchema = z + .object({ + role: z.string().optional().describe('Новая роль участника'), + status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); export class UpdateMemberDto extends createZodDto(UpdateMemberDtoSchema) {} diff --git a/src/modules/teams/dtos/team.dto.ts b/src/modules/teams/dtos/team.dto.ts index 0f45858..1394e05 100644 --- a/src/modules/teams/dtos/team.dto.ts +++ b/src/modules/teams/dtos/team.dto.ts @@ -17,7 +17,12 @@ export const CreateTeamSchema = z.object({ }); export class CreateTeamDto extends createZodDto(CreateTeamSchema) {} -export class UpdateTeamDto extends createZodDto(CreateTeamSchema.partial()) {} +export class UpdateTeamDto extends createZodDto( + CreateTeamSchema.partial().refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }), +) {} export const TagSchema = z.object({ id: z.string().describe('Уникальный идентификатор тега (CUID2)'), diff --git a/src/modules/teams/entities/teams.domain.ts b/src/modules/teams/entities/teams.domain.ts index c1df53e..75c044b 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/modules/teams/entities/teams.domain.ts @@ -20,12 +20,3 @@ export type TeamWithMembers = Team & { export type TeamWithTags = Team & { tags: Tag[]; }; - -// TODO: ADD TO GLOBAL -export const ROLE_PRIORITY: Record = { - owner: 4, - admin: 3, - moderator: 2, - member: 1, - viewer: 0, -}; diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts index 03b3f0b..9a3b0fd 100644 --- a/src/modules/teams/services/invitations.service.ts +++ b/src/modules/teams/services/invitations.service.ts @@ -1,11 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - GoneException, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { generateSecret } from 'otplib'; import { InjectRedis } from '@nestjs-modules/ioredis'; @@ -16,6 +9,7 @@ import { Queue } from 'bullmq'; import { TeamInvitationEvent } from '@shared/workers/events'; import type { InviteMemberDto } from '../dtos'; import { ConfigService } from '@nestjs/config'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamInvitationsService { @@ -31,11 +25,25 @@ export class TeamInvitationsService { public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } const inviter = await this.teamsRepo.findMember(team.id, inviterId); if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { - throw new ForbiddenException('У вас нет прав приглашать новых участников'); + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав приглашать новых участников', + }, + HttpStatus.FORBIDDEN, + ); } const code = generateSecret({ length: 8 }); @@ -56,11 +64,21 @@ export class TeamInvitationsService { expiresAt: expiresAt.toISOString(), }; - const multi = this.redis.multi(); - multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); - multi.sadd(`team:invites:${team.id}`, code); - multi.sadd(`user:invites:${dto.email}`, code); - await multi.exec(); + try { + const multi = this.redis.multi(); + multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(`team:invites:${team.id}`, code); + multi.sadd(`user:invites:${dto.email.toLowerCase()}`, code); + await multi.exec(); + } catch (error) { + throw new BaseException( + { + code: 'REDIS_TRANSACTION_FAILED', + message: 'Не удалось создать приглашение в системе', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); const FRONTEND_URL = origins[0]; @@ -94,44 +112,80 @@ export class TeamInvitationsService { public acceptInvite = async (code: string, userId: string, email: string) => { const inviteRaw = await this.redis.get(`inv:code:${code}`); if (!inviteRaw) { - throw new GoneException('Срок действия приглашения истек или код неверен'); + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.GONE, + ); } const invite = JSON.parse(inviteRaw); if (invite.email.toLowerCase() !== email.toLowerCase()) { - throw new ForbiddenException('Этот инвайт предназначен для другого почтового адреса'); + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'Этот инвайт предназначен для другого почтового адреса', + details: [{ target: 'email', expected: invite.email, actual: email }], + }, + HttpStatus.FORBIDDEN, + ); } const member = await this.teamsRepo.findMember(invite.teamId, userId); if (member) { if (member.status === 'banned') { - throw new ForbiddenException('Вы заблокированы в этой команде'); + throw new BaseException( + { + code: 'MEMBER_BANNED', + message: 'Вы заблокированы в этой команде', + }, + HttpStatus.FORBIDDEN, + ); } if (member.status === 'active') { - throw new BadRequestException('Вы уже являетесь участником этой команды'); + throw new BaseException( + { + code: 'ALREADY_MEMBER', + message: 'Вы уже являетесь участником этой команды', + }, + HttpStatus.BAD_REQUEST, + ); } } - await this.teamsRepo.addMember({ - teamId: invite.teamId, - userId, - role: invite.role, - status: 'active', - joinedAt: new Date(), - }); - - const multi = this.redis.multi(); - multi.del(`inv:code:${code}`); - multi.srem(`team:invites:${invite.teamId}`, code); - multi.srem(`user:invites:${email}`, code); - await multi.exec(); - - return { - success: true, - message: 'Вы успешно присоединились к команде', - }; + try { + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(`inv:code:${code}`); + multi.srem(`team:invites:${invite.teamId}`, code); + multi.srem(`user:invites:${email.toLowerCase()}`, code); + await multi.exec(); + + return { + success: true, + message: 'Вы успешно присоединились к команде', + }; + } catch (error) { + throw new BaseException( + { + code: 'ACCEPT_INVITE_FAILED', + message: 'Ошибка при вступлении в команду', + details: [{ reason: error instanceof Error ? error.message : 'DB Error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; } diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts index 6138bea..9fca6e9 100644 --- a/src/modules/teams/services/members.service.ts +++ b/src/modules/teams/services/members.service.ts @@ -1,14 +1,9 @@ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; -import { ROLE_PRIORITY } from '../entities'; import type { UpdateMemberDto } from '../dtos'; import { TeamMemberMapper } from '../mappers'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; @Injectable() export class TeamMembersService { @@ -21,7 +16,13 @@ export class TeamMembersService { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const members = await this.teamsRepo.findMembers(team.id); @@ -35,17 +36,39 @@ export class TeamMembersService { dto: UpdateMemberDto, ) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } const [currentUser, targetUser] = await Promise.all([ this.teamsRepo.findMember(team.id, currentUserId), this.teamsRepo.findMember(team.id, targetUserId), ]); - if (!currentUser || !targetUser) throw new NotFoundException('Участник не найден'); + if (!currentUser || !targetUser) { + throw new BaseException( + { + code: 'MEMBER_NOT_FOUND', + message: 'Участник не найден', + }, + HttpStatus.NOT_FOUND, + ); + } if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new ForbiddenException('У вас нет прав на редактирование участников'); + throw new BaseException( + { + code: 'ADMIN_ROLE_REQUIRED', + message: 'У вас нет прав на редактирование участников', + }, + HttpStatus.FORBIDDEN, + ); } // Нельзя менять роль тому, кто выше тебя или равен тебе по весу @@ -53,15 +76,25 @@ export class TeamMembersService { currentUserId !== targetUserId && ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] ) { - throw new ForbiddenException( - 'Вы не можете менять данные участника с равным или высшим рангом', + throw new BaseException( + { + code: 'INSUFFICIENT_RANK', + message: 'Вы не можете менять данные участника с равным или высшим рангом', + details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], + }, + HttpStatus.FORBIDDEN, ); } // Защита от потери овнера: нельзя разжаловать овнера в админа if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { - throw new BadRequestException( - 'Нельзя изменить роль владельца. Используйте процедуру передачи прав.', + throw new BaseException( + { + code: 'OWNER_PROTECTION_VIOLATION', + message: + 'Нельзя изменить роль владельца через это меню. Используйте передачу прав.', + }, + HttpStatus.BAD_REQUEST, ); } @@ -71,35 +104,73 @@ export class TeamMembersService { ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && currentUser.role !== 'owner' ) { - throw new ForbiddenException('Вы не можете назначить роль выше своей'); + throw new BaseException( + { + code: 'CANNOT_ASSIGN_HIGHER_ROLE', + message: 'Вы не можете назначить роль выше своей или равную своей', + }, + HttpStatus.FORBIDDEN, + ); } - const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); - - return { - success: result, - message: `Данные участника команды "${team.name}" успешно обновлены`, - }; + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды "${team.name}" успешно обновлены`, + }; + } catch (error) { + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; public removeMember = async (slug: string, currentUserId: string, targetUserId: string) => { const team = await this.teamsRepo.findBySlug(slug); - if (!team) throw new NotFoundException('Команда не найдена'); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } const [currentUser, targetUser] = await Promise.all([ this.teamsRepo.findMember(team.id, currentUserId), this.teamsRepo.findMember(team.id, targetUserId), ]); - if (!targetUser) throw new NotFoundException('Участник не найден в этой команде'); - if (!currentUser) throw new ForbiddenException('Вы не состоите в этой команде'); + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } const isSelfRemoval = currentUserId === targetUserId; if (isSelfRemoval) { if (currentUser.role === 'owner') { - throw new BadRequestException( - 'Владелец не может покинуть команду. Передайте права или удалите команду.', + throw new BaseException( + { + code: 'OWNER_CANNOT_LEAVE', + message: + 'Владелец не может покинуть команду. Передайте права или удалите команду.', + }, + HttpStatus.BAD_REQUEST, ); } } else { @@ -107,19 +178,35 @@ export class TeamMembersService { const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; if (!hasAuthority || !canKick) { - throw new ForbiddenException( - 'У вас недостаточно прав, чтобы исключить этого участника', + throw new BaseException( + { + code: 'KICK_FORBIDDEN', + message: 'У вас недостаточно прав, чтобы исключить этого участника', + details: [ + { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, + ], + }, + HttpStatus.FORBIDDEN, ); } } - const result = await this.teamsRepo.removeMember(team.id, targetUserId); - - return { - success: result, - message: isSelfRemoval - ? `Вы успешно покинули команду ${team.name}` - : `Участник успешно исключен из команды ${team.name}`, - }; + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + } catch (error) { + throw new BaseException( + { + code: 'MEMBER_REMOVAL_FAILED', + message: 'Ошибка при удалении участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } }; } diff --git a/src/modules/teams/services/settings.service.ts b/src/modules/teams/services/settings.service.ts index 7f1f9ef..15ee711 100644 --- a/src/modules/teams/services/settings.service.ts +++ b/src/modules/teams/services/settings.service.ts @@ -1,11 +1,7 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { ITeamMedia, TEAM_MEDIA_TOKEN, type FileUploadDto } from '../../media'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamsSettingsService { @@ -19,10 +15,14 @@ export class TeamsSettingsService { public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); } return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => @@ -33,10 +33,14 @@ export class TeamsSettingsService { public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); } return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => @@ -47,17 +51,27 @@ export class TeamsSettingsService { public syncTags = async (slug: string, tags: string[]) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException({ - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); } const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); if (!isSynced) { - throw new InternalServerErrorException('Не удалось обновить теги команды'); + throw new BaseException( + { + code: 'TAGS_SYNC_FAILED', + message: 'Не удалось обновить теги команды. Попробуйте позже.', + details: [{ target: 'tags', count: normalizedTags.length }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } return { diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts index e003607..4675851 100644 --- a/src/modules/teams/services/teams.service.ts +++ b/src/modules/teams/services/teams.service.ts @@ -1,10 +1,4 @@ -import { - Inject, - Injectable, - ConflictException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { ITeamsRepository } from '../repository'; import { FindTagsQuery } from '../dtos'; import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; @@ -12,6 +6,7 @@ import { slugify } from 'transliteration'; import { TeamMemberMapper } from '../mappers'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; +import { BaseException } from '@shared/error'; @Injectable() export class TeamsService { @@ -44,7 +39,14 @@ export class TeamsService { const existingTeam = await this.teamsRepo.findBySlug(baseSlug); if (existingTeam) { - throw new ConflictException(`Команда со ссылкой "${baseSlug}" уже существует`); + throw new BaseException( + { + code: 'SLUG_ALREADY_EXISTS', + message: `Ссылка "${baseSlug}" уже занята другой командой`, + details: [{ target: 'slug', value: baseSlug }], + }, + HttpStatus.CONFLICT, + ); } const { tags, ...teamData } = dto; @@ -65,14 +67,27 @@ export class TeamsService { message: 'Команда успешно создана', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; public update = async (slug: string, userId: string, dto: UpdateTeamDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.teamsRepo.findMember(team.id, userId); @@ -80,7 +95,14 @@ export class TeamsService { const canEdit = member?.role === 'admin' || member?.role === 'owner'; if (!canEdit) { - throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); } const { tags, ...data } = dto; @@ -93,7 +115,13 @@ export class TeamsService { message: 'Данные команды успешно обновлены', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; @@ -101,15 +129,27 @@ export class TeamsService { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } const member = await this.teamsRepo.findMember(team.id, userId); - const canEdit = team.ownerId === userId || member?.role === 'owner'; + const canDelete = team.ownerId === userId || member?.role === 'owner'; - if (!canEdit) { - throw new ForbiddenException('У вас нет прав для выполнения этой команды'); + if (!canDelete) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); } try { @@ -120,7 +160,13 @@ export class TeamsService { message: 'Данные команды успешно обновлены', }; } catch (error) { - throw error; + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; @@ -157,7 +203,13 @@ export class TeamsService { public getOne = async (slug: string) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { - throw new NotFoundException(`Команда ${slug} не найдена`); + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); } return team; }; diff --git a/src/modules/user/commands/create.command.ts b/src/modules/user/commands/create.command.ts index b5e1d54..97861b4 100644 --- a/src/modules/user/commands/create.command.ts +++ b/src/modules/user/commands/create.command.ts @@ -1,7 +1,8 @@ -import { ConflictException, Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import { NewUser } from '../entities/user.domain'; import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; @Injectable() export class CreateUserCommand { @@ -14,16 +15,39 @@ export class CreateUserCommand { const existingUser = await this.repository.findByEmail(dto.email); if (existingUser) { - throw new ConflictException(`User with email ${dto.email} already exists`); + throw new BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: `Пользователь с email ${dto.email} уже зарегистрирован`, + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); } - const user = await this.repository.create(dto); - await this.repository.logActivity({ - eventType: 'registered', - userId: user.id, - id: createId(), - }); - await this.repository.updatePasswordHash(user.id, dto.password); - return user; + try { + const user = await this.repository.create(dto); + + await this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }); + + await this.repository.updatePasswordHash(user.id, dto.password); + + return user; + } catch (error) { + throw new BaseException( + { + code: 'USER_REGISTRATION_FAILED', + message: 'Не удалось завершить регистрацию пользователя', + details: [ + { reason: error instanceof Error ? error.message : 'Database error' }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/modules/user/commands/find-one.command.ts b/src/modules/user/commands/find-one.command.ts index 1e44d15..8a78e1f 100644 --- a/src/modules/user/commands/find-one.command.ts +++ b/src/modules/user/commands/find-one.command.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UserWithSecurity } from '../entities/user.domain'; +import { BaseException } from '@shared/error'; @Injectable() export class FindOneUserCommand { @@ -22,6 +23,12 @@ export class FindOneUserCommand { return this.repository.findById(id); } - throw new Error('FindOneUserCommand: email or id must be provided'); + throw new BaseException( + { + code: 'COMMAND_PARAMS_MISSING', + message: 'Критическая ошибка: не указаны параметры поиска пользователя', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } diff --git a/src/modules/user/commands/update-pass.command.ts b/src/modules/user/commands/update-pass.command.ts index 3ad7228..6fc61dd 100644 --- a/src/modules/user/commands/update-pass.command.ts +++ b/src/modules/user/commands/update-pass.command.ts @@ -1,5 +1,6 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; +import { BaseException } from '@shared/error'; @Injectable() export class UpdatePassUserCommand { @@ -12,13 +13,43 @@ export class UpdatePassUserCommand { const { user } = await this.repository.findByEmail(email); if (!user) { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь для обновления пароля не найден', - details: { email }, - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь для обновления пароля не найден', + details: [{ target: 'email', value: email }], + }, + HttpStatus.NOT_FOUND, + ); } - return this.repository.updatePasswordHash(user.id, password); + try { + const isUpdated = await this.repository.updatePasswordHash(user.id, password); + + if (!isUpdated) { + throw new BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Запись не была изменена.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return isUpdated; + } catch (error) { + throw new BaseException( + { + code: 'DATABASE_ERROR', + message: 'Произошла критическая ошибка при работе с базой данных', + details: [ + { + reason: error instanceof Error ? error.message : 'Unknown DB error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } diff --git a/src/modules/user/dtos/user.dto.ts b/src/modules/user/dtos/user.dto.ts index d342b79..de3ffe4 100644 --- a/src/modules/user/dtos/user.dto.ts +++ b/src/modules/user/dtos/user.dto.ts @@ -15,9 +15,12 @@ const NotificationsSchema = z }) .describe('Настройки уведомлений пользователя'); -export const UpdateNotificationsSchema = NotificationsSchema.partial().describe( - 'Схема для частичного обновления настроек уведомлений', -); +export const UpdateNotificationsSchema = NotificationsSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для частичного обновления настроек уведомлений'); export class UpdateNotificationsDto extends createZodDto(UpdateNotificationsSchema) {} @@ -70,6 +73,10 @@ export const UpdateProfileSchema = z .length(2, 'Используйте формат ISO (например, "ru" или "en")') .optional(), }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) .describe('Схема для частичного обновления данных профиля'); export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} diff --git a/src/modules/user/services/settings.service.ts b/src/modules/user/services/settings.service.ts index 0e72987..c4931c9 100644 --- a/src/modules/user/services/settings.service.ts +++ b/src/modules/user/services/settings.service.ts @@ -1,12 +1,8 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UpdateNotificationsDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; @Injectable() export class UserSettingsService { @@ -16,21 +12,16 @@ export class UserSettingsService { ) {} private throwUserNotFound() { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }, + HttpStatus.NOT_FOUND, + ); } public updateNotifications = async (id: string, dto: UpdateNotificationsDto) => { - const keysToUpdate = Object.keys(dto); - if (keysToUpdate.length === 0) { - return { - success: true, - message: 'Изменений не обнаружено', - }; - } - const user = await this.userRepo.findById(id); if (!user) this.throwUserNotFound(); @@ -41,8 +32,12 @@ export class UserSettingsService { }); if (!isUpdated) { - throw new InternalServerErrorException( - 'Ошибка при сохранении настроек уведомлений', + throw new BaseException( + { + code: 'NOTIFICATIONS_UPDATE_FAILED', + message: 'Не удалось обновить настройки уведомлений', + }, + HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -57,7 +52,23 @@ export class UserSettingsService { message: 'Настройки уведомлений обновлены', }; } catch (error) { - throw error; + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'USER_SETTINGS_ERROR', + message: 'Ошибка при сохранении настроек пользователя', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; } diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 287cc52..2d95e6d 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -1,13 +1,9 @@ -import { - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IUserRepository } from '../repository/user.repository.interface'; import type { UpdateProfileDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; import { IUserMedia, USER_MEDIA_TOKEN, type FileUploadDto } from '../../media'; +import { BaseException } from '@shared/error'; @Injectable() export class UserService { @@ -19,10 +15,13 @@ export class UserService { ) {} private throwUserNotFound() { - throw new NotFoundException({ - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь не найден в системе', + }, + HttpStatus.NOT_FOUND, + ); } public getProfile = async (userId: string) => { @@ -40,28 +39,23 @@ export class UserService { }; public updateProfile = async (id: string, dto: UpdateProfileDto) => { - const keysToUpdate = Object.keys(dto); - if (keysToUpdate.length === 0) { - return { - success: true, - message: 'Изменений не обнаружено', - }; - } - try { const isUpdated = await this.userRepo.updateProfile(id, dto); if (!isUpdated) { - throw new InternalServerErrorException('Не удалось обновить профиль'); + throw new BaseException( + { + code: 'PROFILE_UPDATE_FAILED', + message: 'Не удалось обновить данные профиля', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } await this.userRepo.logActivity({ id: createId(), userId: id, eventType: 'PROFILE_UPDATED', - metadata: { - fields: keysToUpdate, - }, }); return { @@ -69,7 +63,23 @@ export class UserService { message: 'Профиль успешно обновлен', }; } catch (error) { - throw error; + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'PROFILE_SERVICE_ERROR', + message: 'Произошла ошибка при обновлении профиля', + details: [ + { + reason: + error instanceof Error ? error.message : 'Unknown database error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } }; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index b4b8a55..8a4ea9d 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1 +1,2 @@ export * from './file.constants'; +export * from './roles.constant'; diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts new file mode 100644 index 0000000..1da5f2c --- /dev/null +++ b/src/shared/constants/roles.constant.ts @@ -0,0 +1,7 @@ +export const ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + moderator: 2, + member: 1, + viewer: 0, +}; diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 14cd03e..05efe78 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -1,7 +1,8 @@ -import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common'; +import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { IMAGE_MIME_TYPES } from '../constants'; import type { FileUploadDto } from '../../modules/media'; +import { BaseException } from '@shared/error'; export const ExtractFastifyFile = createParamDecorator( async ( @@ -11,16 +12,44 @@ export const ExtractFastifyFile = createParamDecorator( const req = ctx.switchToHttp().getRequest(); if (!req.isMultipart()) { - throw new BadRequestException('Request is not multipart'); + throw new BaseException( + { + code: 'INVALID_CONTENT_TYPE', + message: 'Ожидался multipart/form-data запрос', + details: [ + { target: 'header', message: 'Content-Type must be multipart/form-data' }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } const file = await req.file(); if (!file) { - throw new BadRequestException('Файл не найден'); + throw new BaseException( + { + code: 'FILE_NOT_FOUND', + message: 'Файл не был передан в запросе', + }, + HttpStatus.BAD_REQUEST, + ); } if (data?.allowedMimetypes && !data.allowedMimetypes.includes(file.mimetype)) { - throw new BadRequestException('Недопустимый формат файла'); + throw new BaseException( + { + code: 'INVALID_FILE_TYPE', + message: 'Недопустимый формат файла', + details: [ + { + target: 'mimetype', + received: file.mimetype, + expected: data.allowedMimetypes, + }, + ], + }, + HttpStatus.BAD_REQUEST, + ); } const buffer = await file.toBuffer(); diff --git a/src/shared/decorators/user.decorator.ts b/src/shared/decorators/user.decorator.ts index 7fc2467..938bc37 100644 --- a/src/shared/decorators/user.decorator.ts +++ b/src/shared/decorators/user.decorator.ts @@ -1,15 +1,12 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { FastifyRequest } from 'fastify'; -import { JwtPayload } from '../../modules/auth/types'; +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import type { JwtPayload } from '@shared/types'; export const GetUser = createParamDecorator( (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - - const user = request.user as JwtPayload; - + const user = request.user; if (!user) return null; - return data ? user[data] : user; }, ); @@ -17,8 +14,7 @@ export const GetUser = createParamDecorator( export const GetUserId = createParamDecorator( (_data: unknown, ctx: ExecutionContext): string | undefined => { const request = ctx.switchToHttp().getRequest(); - const user = request.user as JwtPayload; - + const user = request.user; return user?.sub; }, ); diff --git a/src/shared/error/exception.ts b/src/shared/error/exception.ts new file mode 100644 index 0000000..640645f --- /dev/null +++ b/src/shared/error/exception.ts @@ -0,0 +1,18 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +interface IDetailsOptions { + target?: string; + [key: string]: any; +} + +export interface IErrorOptions { + code: string; + message: string; + details?: IDetailsOptions[]; +} + +export class BaseException extends HttpException { + constructor(options: IErrorOptions, status: HttpStatus) { + super(options, status); + } +} diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index f9536f7..f698ce8 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -1,54 +1,150 @@ -import { - type ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, -} from '@nestjs/common'; +import { type ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common'; +import { ZodValidationException } from 'nestjs-zod'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { DatabaseError } from 'pg'; +import { BaseException, IErrorOptions } from './exception'; +import { DrizzleQueryError } from 'drizzle-orm'; +import type { ZodError, ZodIssue } from 'zod/v4'; +import { DATABASE_ERRORS } from './swagger'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { - catch(exception: any, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - let status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - let details = []; - let message = exception.message; - let code = 'INTERNAL_ERROR'; - - if (exception?.name === 'ZodValidationException') { - status = 400; - code = 'VALIDATION_FAILED'; - details = exception.getResponse()?.errors || []; - message = 'Validation failed'; - } else if (exception instanceof HttpException) { - const res = exception.getResponse() as any; - code = res.code || 'HTTP_ERROR'; - details = res.details || []; + private isDev = process.env.NODE_ENV === 'development'; + + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof ZodValidationException) { + return this.parseZodValidation(exception, host); + } + + if (exception instanceof BaseException) { + return this.parseHttp(exception, host); } + if (exception instanceof DrizzleQueryError) { + return this.parseDatabase(exception, host); + } + + return this.handleUnknownError(exception, host); + } + + private parseZodValidation = async (exception: ZodValidationException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const zodError = exception.getZodError() as ZodError; + const issues: ZodIssue[] = zodError.issues || []; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'VALIDATION_FAILED', + message: 'Переданные данные не прошли валидацию', + details: issues, + stack: exception.stack, + }), + ); + }; + + private parseDatabase = async (exception: DrizzleQueryError, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + + const error = + exception.cause instanceof DatabaseError + ? exception.cause + : exception instanceof DatabaseError + ? exception + : null; + + let status = 500; + let message = exception.message || 'Database operation failed'; + const errorCode = 'DATABASE_ERROR'; + + if (error) { + const mapping = DATABASE_ERRORS[error.code]; + if (mapping) { + status = mapping.code; + message = mapping.msg; + } + } + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: errorCode, + message, + details: error?.constraint ? [{ target: error.constraint }] : [], + stack: exception.stack, + service: 'postgres', + }), + ); + }; + + private parseHttp = async (exception: BaseException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const error = exception.getResponse() as IErrorOptions; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: error.code, + message: error.message || exception.message, + details: error.details || [], + stack: exception.stack, + }), + ); + }; + + private handleUnknownError(exception: any, host: ArgumentsHost) { + const { request, response } = this.getCtxBase(host); + const status = HttpStatus.INTERNAL_SERVER_ERROR; + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'INTERNAL_SERVER_ERROR', + message: 'Произошла непредвиденная ошибка на сервере', + details: [], + stack: exception?.stack, + }), + ); + } + + private formatErrorResponse( + request: FastifyRequest, + status: number, + data: { code: string; message: string; details: any[]; stack?: string; service?: string }, + ) { const requestId = request.id ?? request.headers['x-request-id']; - const errorResponse = { - code, - message, - retryable: status >= 500, - details, + return { + success: false, + error: { + code: data.code, + message: data.message, + retryable: status >= 500, + }, + details: data.details, meta: { - requestId, + service: data.service ?? 'gateway', + request: { + requestId, + path: request.url, + method: request.method, + ip: request.ip, + }, timestamp: new Date().toISOString(), - path: request.url, - method: request.method, - service: 'main-api', + ...(this.isDev && { + debug: { + stack: data.stack, + }, + }), }, }; + } - response.status(status).send(errorResponse); + private getCtxBase(host: ArgumentsHost) { + const ctx = host.switchToHttp(); + return { + response: ctx.getResponse(), + request: ctx.getRequest(), + }; } } diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts index 544657a..9ddc922 100644 --- a/src/shared/error/index.ts +++ b/src/shared/error/index.ts @@ -1,2 +1,3 @@ export * from './swagger'; export * from './filter'; +export * from './exception'; diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index ad5c30d..dff5e87 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -48,3 +48,13 @@ export const ApiValidationError = ( export const ApiConflict = (description: string = 'Ресурс уже существует') => applyDecorators(ApiErrorResponse(409, 'CONFLICT', description)); + +export const DATABASE_ERRORS: Record = { + '23505': { code: 409, msg: 'Запись с таким значением уже существует (дубликат).' }, + '23503': { code: 409, msg: 'Ошибка внешнего ключа: связанная запись не найдена.' }, + '22P02': { code: 400, msg: 'Неверный формат данных (например, некорректный UUID).' }, + '23514': { code: 400, msg: 'Нарушено ограничение проверки (check constraint).' }, + '23502': { code: 400, msg: 'Отсутствует обязательное поле.' }, + '08006': { code: 500, msg: 'Ошибка соединения с базой данных.' }, + '40001': { code: 500, msg: 'Конфликт транзакции. Пожалуйста, повторите попытку.' }, +}; diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts index 2e59c5e..a7b2b02 100644 --- a/src/shared/guards/bearer.guard.ts +++ b/src/shared/guards/bearer.guard.ts @@ -1,8 +1,9 @@ -import type { JwtPayload } from '@core/modules/auth/types'; -import { type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { type ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { IS_PUBLIC_KEY } from '@shared/decorators'; +import { BaseException } from '@shared/error'; +import type { JwtPayload } from '@shared/types'; import type { FastifyRequest } from 'fastify'; @Injectable() @@ -26,7 +27,7 @@ export class BearerAuthGuard extends AuthGuard('bearer') { handleRequest( err: unknown, user: TUser, - _info: unknown, + info: unknown, context: ExecutionContext, ): TUser { if (user) { @@ -37,7 +38,14 @@ export class BearerAuthGuard extends AuthGuard('bearer') { return null; } - throw err || new UnauthorizedException(); + throw new BaseException( + { + code: 'AUTH_FAILED', + message: 'Доступ запрещен: требуется валидный токен авторизации', + details: this.getAuthDetails(err, info), + }, + HttpStatus.UNAUTHORIZED, + ); } private isPublicOrHasToken(context: ExecutionContext): boolean { @@ -52,4 +60,10 @@ export class BearerAuthGuard extends AuthGuard('bearer') { return !!(isPublic || query.token); } + + private getAuthDetails(err: unknown, info: any) { + const message = info?.message || (err instanceof Error ? err.message : null); + + return message ? [{ target: 'auth', reason: message }] : []; + } } diff --git a/src/shared/types/fastify.d.ts b/src/shared/types/fastify.d.ts index db45904..9c77358 100644 --- a/src/shared/types/fastify.d.ts +++ b/src/shared/types/fastify.d.ts @@ -1,4 +1,4 @@ -import { JwtPayload } from './jwt-payload.type'; +import type { JwtPayload } from './jwt-payload'; declare module 'fastify' { interface FastifyRequest { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..9a3c79a --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export type { JwtPayload } from './jwt-payload'; diff --git a/src/modules/auth/types/jwt-payload.ts b/src/shared/types/jwt-payload.ts similarity index 100% rename from src/modules/auth/types/jwt-payload.ts rename to src/shared/types/jwt-payload.ts From 7e2802144b033af2bcb57af43dc21ca113d88bb0 Mon Sep 17 00:00:00 2001 From: soorq Date: Mon, 20 Apr 2026 00:54:38 +0300 Subject: [PATCH 2/2] refactor: finalize unified error handling and sync test suites --- .../src/controller/health.controlller.spec.ts | 29 +++---- src/shared/error/schema.ts | 76 +++++++------------ 2 files changed, 43 insertions(+), 62 deletions(-) diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index d322112..8e061a4 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -15,20 +15,21 @@ describe('HealthController', () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); - describe('checkHealth', () => { - it('should return "healthy" when service status is "up"', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: 'up' }); - - await expect(controller.checkHealth()).resolves.toBe('healthy'); - }); - - it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); - - await expect(controller.checkHealth()).rejects.toMatchObject({ - status: HttpStatus.SERVICE_UNAVAILABLE, - response: `${SERVICE_NAME} service is unhealthy.`, - }); + it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); + + await expect(controller.checkHealth()).rejects.toMatchObject({ + status: HttpStatus.SERVICE_UNAVAILABLE, + response: { + code: 'SERVICE_UNHEALTHY', + message: expect.stringContaining(SERVICE_NAME), + details: expect.arrayContaining([ + expect.objectContaining({ + status: 'down', + target: SERVICE_NAME, + }), + ]), + }, }); }); diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts index 20e2a8b..e064c5c 100644 --- a/src/shared/error/schema.ts +++ b/src/shared/error/schema.ts @@ -1,56 +1,36 @@ -import { z } from 'zod/v4'; +import { z } from 'zod'; import { createZodDto } from 'nestjs-zod'; -const ErrorDetailSchema = z - .object({ - field: z.string().describe('Путь к полю в формате dot-notation (например, "user.email")'), - message: z.string().describe('Человекочитаемое сообщение о конкретной ошибке в этом поле'), - code: z - .string() - .describe( - 'Машиночитаемый код ошибки валидации (например, "invalid_email", "too_short")', - ), - }) - .describe('Детальная информация о конкретном нарушении в запросе'); +const ErrorDetailSchema = z.object({ + field: z.string().describe('Путь к полю (например, "user.email")'), + message: z.string().describe('Сообщение об ошибке'), + code: z.string().describe('Машиночитаемый код (например, "too_short")'), +}); -const ErrorMetaSchema = z - .object({ - requestId: z - .string() - .describe( - 'Уникальный ID запроса (Trace ID). Используется для поиска логов в Sentry/ELK/Kibana', - ), - timestamp: z - .string() - .datetime() - .describe('Точное время возникновения ошибки в формате ISO 8601'), - path: z.string().describe('URL-путь эндпоинта, который вернул ошибку'), - method: z.string().describe('HTTP метод запроса (GET, POST, etc.)'), - service: z - .string() - .optional() - .describe( - 'Имя микросервиса, в котором произошел сбой (полезно для будущего масштабирования)', - ), - }) - .describe('Техническая мета-информация для мониторинга и отладки'); +const ErrorMetaSchema = z.object({ + service: z.string().default('gateway').describe('Имя микросервиса'), + request: z.object({ + requestId: z.string().describe('Trace ID для логов'), + path: z.string().describe('URL эндпоинта'), + method: z.string().describe('HTTP метод'), + ip: z.string().optional().describe('IP клиента'), + }), + timestamp: z.string().datetime().describe('Время ошибки ISO 8601'), + debug: z + .object({ + stack: z.string().optional().describe('Стек вызовов (только в Dev)'), + }) + .optional(), +}); export const GlobalErrorSchema = z.object({ - code: z - .string() - .describe( - 'Уникальный бизнес-код ошибки (например, "INSUFFICIENT_FUNDS", "TEAM_NOT_FOUND")', - ), - message: z.string().describe('Краткое описание ошибки для пользователя или разработчика'), - retryable: z - .boolean() - .describe( - 'Флаг, указывающий клиенту, есть ли смысл повторять запрос без изменений (например, при 503 или Lock Timeout)', - ), - details: z - .array(ErrorDetailSchema) - .optional() - .describe('Список ошибок валидации (заполняется только для 400 ошибок)'), + success: z.literal(false).default(false), + error: z.object({ + code: z.string().describe('Бизнес-код ошибки'), + message: z.string().describe('Описание для пользователя'), + retryable: z.boolean().describe('Флаг возможности повтора'), + }), + details: z.array(ErrorDetailSchema).optional(), meta: ErrorMetaSchema, });