From a360c20660e468a07016e1400aa2f8aeabf3d184 Mon Sep 17 00:00:00 2001 From: soorq Date: Tue, 28 Apr 2026 21:31:46 +0300 Subject: [PATCH 1/9] refactor: pass app to global scope --- src/{modules/app => }/app.module.ts | 10 +++++----- src/main.ts | 2 +- src/modules/auth/services/recovery.service.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) rename src/{modules/app => }/app.module.ts (91%) diff --git a/src/modules/app/app.module.ts b/src/app.module.ts similarity index 91% rename from src/modules/app/app.module.ts rename to src/app.module.ts index 7d796ef..37d8e74 100644 --- a/src/modules/app/app.module.ts +++ b/src/app.module.ts @@ -2,22 +2,22 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@libs/config'; import { DatabaseModule } from '@libs/database'; import { ConfigService } from '@nestjs/config'; -import * as schema from '../../shared/entities'; +import * as schema from './shared/entities'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; -import { UserModule } from '../user'; +import { UserModule } from './modules/user'; import { GlobalExceptionFilter } from '@shared/error'; -import { AuthModule } from '../auth'; +import { AuthModule } from './modules/auth'; import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; import { MailProcessor } from '@shared/workers'; import { BullModule } from '@nestjs/bullmq'; import { MailAdapter } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; -import { TeamsModule } from '../teams'; -import { ProjectsModule } from '../projects'; +import { TeamsModule } from './modules/teams'; +import { ProjectsModule } from './modules/projects'; @Module({ imports: [ diff --git a/src/main.ts b/src/main.ts index 5f7ae4e..009bef9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { bootstrapApp } from '@libs/bootstrap'; -import { AppModule } from './modules/app/app.module'; +import { AppModule } from './app.module'; bootstrapApp({ serviceName: 'Tracker Monolit', diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts index ba6312c..c579cc9 100644 --- a/src/modules/auth/services/recovery.service.ts +++ b/src/modules/auth/services/recovery.service.ts @@ -24,9 +24,9 @@ export class AuthRecoveryService { ) {} public resetPass = async (dto: ResetPasswordDto) => { - const { user } = await this.findUserCommand.execute({ email: dto.email }); + const entity = await this.findUserCommand.execute({ email: dto.email }); - if (!user) { + if (!entity.user) { throw new BaseException( { code: 'USER_NOT_FOUND', @@ -46,7 +46,7 @@ export class AuthRecoveryService { }); const resetPayload = { - email: user.email, + email: entity.user.email, otp: { secret, token }, isVerified: false, }; From b21a631925c6f6693154ece62d594ebe1b4e04ea Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:44:23 +0300 Subject: [PATCH 2/9] refactor(auth): implement DDD architecture and layer separation (#49) --- src/app.module.ts | 11 +- src/auth/application/auth.facade.ts | 66 ++++ .../controller/auth/controller.ts} | 18 +- .../application/controller/auth/swagger.ts | 143 +++++++++ src/auth/application/controller/index.ts | 2 + .../controller/recovery/controller.ts} | 20 +- .../controller/recovery/swagger.ts} | 100 +----- .../auth => auth/application}/dtos/2fa.dto.ts | 0 .../application}/dtos/auth.dto.ts | 0 .../auth => auth/application}/dtos/index.ts | 0 .../application}/dtos/password.dto.ts | 0 .../confirm-reset-password.use-case.ts | 64 ++++ src/auth/application/use-cases/index.ts | 8 + .../use-cases/refresh-tokens.use-case.ts | 71 +++++ .../use-cases/reset-password.use-case.ts | 67 +++++ .../application/use-cases/sign-in.use-case.ts | 62 ++++ .../use-cases/sign-out.use-case.ts | 46 +++ .../use-cases/sign-up-verify.use-case.ts | 81 +++++ .../application/use-cases/sign-up.use-case.ts | 86 ++++++ .../verify-reset-password.use-case.ts | 63 ++++ src/{modules => }/auth/auth.module.ts | 64 +++- src/auth/domain/domain/.gitkeep | 1 + src/auth/domain/enums/index.ts | 1 + src/auth/domain/enums/mail-jobs.enum.ts | 9 + src/auth/domain/events/index.ts | 2 + .../domain}/events/register-code.event.ts | 0 .../domain}/events/reset-password.event.ts | 0 src/auth/domain/repository/index.ts | 1 + .../session.repository.interface.ts | 2 +- .../persistence/models/index.ts | 1 + .../persistence/models/session.model.ts} | 3 +- .../persistence/repositories}/index.ts | 1 - .../repositories}/session.repository.ts | 4 +- src/auth/infrastructure/security/index.ts | 1 + .../infrastructure/security}/token.service.ts | 0 .../strategies/bearer.strategy.ts | 0 .../strategies/cookie.strategy.ts | 0 .../infrastructure}/strategies/index.ts | 0 .../infrastructure/utils}/get-device-meta.ts | 0 src/auth/infrastructure/workers/index.ts | 1 + .../infrastructure/workers/mail.processor.ts | 72 +++++ src/modules/auth/controller/index.ts | 2 - src/modules/auth/entities/index.ts | 1 - src/modules/auth/helpers/index.ts | 1 - src/modules/auth/index.ts | 1 - src/modules/auth/services/auth.service.ts | 284 ------------------ src/modules/auth/services/index.ts | 3 - src/modules/auth/services/recovery.service.ts | 167 ---------- src/shared/adapters/mail/index.ts | 1 + src/shared/adapters/mail/module.ts | 16 + src/shared/entities/index.ts | 2 +- src/shared/workers/enum.ts | 3 - src/shared/workers/events/index.ts | 2 - src/shared/workers/index.ts | 1 - src/shared/workers/mail/worker.ts | 34 +-- 55 files changed, 942 insertions(+), 647 deletions(-) create mode 100644 src/auth/application/auth.facade.ts rename src/{modules/auth/controller/auth.controller.ts => auth/application/controller/auth/controller.ts} (86%) create mode 100644 src/auth/application/controller/auth/swagger.ts create mode 100644 src/auth/application/controller/index.ts rename src/{modules/auth/controller/recovery.controller.ts => auth/application/controller/recovery/controller.ts} (53%) rename src/{modules/auth/controller/auth.swagger.ts => auth/application/controller/recovery/swagger.ts} (63%) rename src/{modules/auth => auth/application}/dtos/2fa.dto.ts (100%) rename src/{modules/auth => auth/application}/dtos/auth.dto.ts (100%) rename src/{modules/auth => auth/application}/dtos/index.ts (100%) rename src/{modules/auth => auth/application}/dtos/password.dto.ts (100%) create mode 100644 src/auth/application/use-cases/confirm-reset-password.use-case.ts create mode 100644 src/auth/application/use-cases/index.ts create mode 100644 src/auth/application/use-cases/refresh-tokens.use-case.ts create mode 100644 src/auth/application/use-cases/reset-password.use-case.ts create mode 100644 src/auth/application/use-cases/sign-in.use-case.ts create mode 100644 src/auth/application/use-cases/sign-out.use-case.ts create mode 100644 src/auth/application/use-cases/sign-up-verify.use-case.ts create mode 100644 src/auth/application/use-cases/sign-up.use-case.ts create mode 100644 src/auth/application/use-cases/verify-reset-password.use-case.ts rename src/{modules => }/auth/auth.module.ts (66%) create mode 100644 src/auth/domain/domain/.gitkeep create mode 100644 src/auth/domain/enums/index.ts create mode 100644 src/auth/domain/enums/mail-jobs.enum.ts create mode 100644 src/auth/domain/events/index.ts rename src/{shared/workers => auth/domain}/events/register-code.event.ts (100%) rename src/{shared/workers => auth/domain}/events/reset-password.event.ts (100%) create mode 100644 src/auth/domain/repository/index.ts rename src/{modules/auth => auth/domain}/repository/session.repository.interface.ts (85%) create mode 100644 src/auth/infrastructure/persistence/models/index.ts rename src/{modules/auth/entities/session.entity.ts => auth/infrastructure/persistence/models/session.model.ts} (92%) rename src/{modules/auth/repository => auth/infrastructure/persistence/repositories}/index.ts (54%) rename src/{modules/auth/repository => auth/infrastructure/persistence/repositories}/session.repository.ts (93%) create mode 100644 src/auth/infrastructure/security/index.ts rename src/{modules/auth/services => auth/infrastructure/security}/token.service.ts (100%) rename src/{modules/auth => auth/infrastructure}/strategies/bearer.strategy.ts (100%) rename src/{modules/auth => auth/infrastructure}/strategies/cookie.strategy.ts (100%) rename src/{modules/auth => auth/infrastructure}/strategies/index.ts (100%) rename src/{modules/auth/helpers => auth/infrastructure/utils}/get-device-meta.ts (100%) create mode 100644 src/auth/infrastructure/workers/index.ts create mode 100644 src/auth/infrastructure/workers/mail.processor.ts delete mode 100644 src/modules/auth/controller/index.ts delete mode 100644 src/modules/auth/entities/index.ts delete mode 100644 src/modules/auth/helpers/index.ts delete mode 100644 src/modules/auth/index.ts delete mode 100644 src/modules/auth/services/auth.service.ts delete mode 100644 src/modules/auth/services/index.ts delete mode 100644 src/modules/auth/services/recovery.service.ts create mode 100644 src/shared/adapters/mail/module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 37d8e74..696bb75 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,12 +9,11 @@ import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; import { UserModule } from './modules/user'; import { GlobalExceptionFilter } from '@shared/error'; -import { AuthModule } from './modules/auth'; +import { AuthModule } from './auth/auth.module'; import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; -import { MailProcessor } from '@shared/workers'; import { BullModule } from '@nestjs/bullmq'; -import { MailAdapter } from '@shared/adapters/mail'; +import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; import { TeamsModule } from './modules/teams'; import { ProjectsModule } from './modules/projects'; @@ -63,11 +62,7 @@ import { ProjectsModule } from './modules/projects'; ], providers: [ MigrationService, - { - provide: 'IMailPort', - useClass: MailAdapter, - }, - MailProcessor, + MailModule, { provide: APP_PIPE, useClass: ZodValidationPipe, diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts new file mode 100644 index 0000000..e2aac1f --- /dev/null +++ b/src/auth/application/auth.facade.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { + SignInUseCase, + SignUpUseCase, + SignOutUseCase, + SignUpVerifyUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + VerifyResetPasswordUseCase, + ConfirmResetPasswordUseCase, +} from './use-cases'; +import { + PasswordResetConfirmDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from './dtos'; +import type { DeviceMetadata } from '../infrastructure/utils/get-device-meta'; + +@Injectable() +export class AuthFacade { + constructor( + private readonly signInUseCase: SignInUseCase, + private readonly signUpUseCase: SignUpUseCase, + private readonly signOutUseCase: SignOutUseCase, + private readonly signUpVerifyUseCase: SignUpVerifyUseCase, + private readonly refreshTokensUseCase: RefreshTokensUseCase, + private readonly resetPasswordUseCase: ResetPasswordUseCase, + private readonly verifyResetPasswordUseCase: VerifyResetPasswordUseCase, + private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, + ) {} + + async signIn(dto: SignInDto, device: DeviceMetadata) { + return this.signInUseCase.execute(dto, device); + } + + async signUp(dto: SignUpDto) { + return this.signUpUseCase.execute(dto); + } + + async verifySignUp(dto: VerifyDto, device: DeviceMetadata) { + return this.signUpVerifyUseCase.execute(dto, device); + } + + async signOut(userId: string) { + return this.signOutUseCase.execute(userId); + } + + async refreshTokens(token: string, device: DeviceMetadata) { + return this.refreshTokensUseCase.execute(token, device); + } + + async sendResetCode(dto: ResetPasswordDto) { + return this.resetPasswordUseCase.execute(dto); + } + + async verifyResetCode(dto: VerifyResetCodeDto) { + return this.verifyResetPasswordUseCase.execute(dto); + } + + async confirmNewPassword(dto: PasswordResetConfirmDto) { + return this.confirmResetPasswordUseCase.execute(dto); + } +} diff --git a/src/modules/auth/controller/auth.controller.ts b/src/auth/application/controller/auth/controller.ts similarity index 86% rename from src/modules/auth/controller/auth.controller.ts rename to src/auth/application/controller/auth/controller.ts index 0e1ae9c..ec136be 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -1,21 +1,21 @@ -import { ApiBaseController } from '../../../shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common'; -import { AuthService } from '../services'; import { PostLoginSwagger, PostLogoutSwagger, PostRefreshSwagger, PostRegisterSwagger, PostSignUpConfirmSwagger, -} from './auth.swagger'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; +} from './swagger'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { getDeviceMeta } from '../helpers'; import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; +import { AuthFacade } from '../../auth.facade'; +import { getDeviceMeta } from '@core/auth/infrastructure/utils/get-device-meta'; @ApiBaseController('auth', 'Auth') export class AuthController { - constructor(private readonly facade: AuthService) {} + constructor(private readonly facade: AuthFacade) {} @Post('sign-up') @PostRegisterSwagger() @@ -27,13 +27,13 @@ export class AuthController { @Post('sign-up/confirm') @PostSignUpConfirmSwagger() @HttpCode(201) - async verify( + async verifySignUp( @Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest, @Body() dto: VerifyDto, ) { const meta = getDeviceMeta(req); - const { tokens, ...response } = await this.facade.verify(dto, meta); + const { tokens, ...response } = await this.facade.verifySignUp(dto, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, @@ -84,7 +84,7 @@ export class AuthController { async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { const meta = getDeviceMeta(req); const session = req.cookies?.['refresh']; - const { tokens, ...response } = await this.facade.refresh(session, meta); + const { tokens, ...response } = await this.facade.refreshTokens(session, meta); res.setCookie('refresh', tokens.refresh, { httpOnly: true, diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controller/auth/swagger.ts new file mode 100644 index 0000000..41ff77a --- /dev/null +++ b/src/auth/application/controller/auth/swagger.ts @@ -0,0 +1,143 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { SignInDto, SignUpDto, VerifyDto } from '../../dtos'; +import { ActionResponse } from '@shared/dtos'; + +export const PostRegisterSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Регистрация нового пользователя', + description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', + }), + ApiBody({ type: SignUpDto.Output }), + ApiResponse({ + status: 201, + description: 'Пользователь успешно зарегистрирован.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), + ApiConflict('Пользователь с таким email уже существует'), + ); + +export const PostLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вход в систему', + description: + 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', + }), + ApiBody({ type: SignInDto.Output }), + ApiResponse({ + status: 200, + description: 'Успешный вход.', + schema: { + example: { + success: true, + message: false, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiBadRequest('Неверный формат email'), + ApiUnauthorized('Неверный email или пароль'), + ); + +export const PostRefreshSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновление токенов', + description: 'Выдает новую пару Access и Refresh токенов.', + }), + ApiResponse({ + status: 200, + description: 'Токены успешно обновлены.', + schema: { + example: { + success: true, + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + message: 'def50200508a1768c7e...', + }, + }, + }), + ApiBadRequest('Ошибка валидации (не передан refresh токен)'), + ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + ); + +export const PostLogoutSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Выход из системы', + description: 'Удаляет текущую сессию пользователя из Redis.', + }), + ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), + ApiUnauthorized(), + ); + +export const PostSignUpConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение регистрации по коду', + description: + 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', + }), + ApiBody({ type: VerifyDto.Output }), + ApiResponse({ + status: 201, + description: 'Аккаунт подтверждён, сессия создана.', + schema: { + example: { + success: true, + message: 'Аккаунт успешно подтвержден', + token: 'eyJhbGciOiJIUzI1NiIsInR5c...', + }, + }, + }), + ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), + ApiBadRequest('Срок регистрации истёк или сессия не найдена'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + schema: { + example: [ + { + id: 'clj1xyz990000abc1', + device: 'Chrome on macOS', + ip: '192.168.1.1', + lastActive: '2026-04-11T14:30:00.000Z', + isCurrent: true, + }, + ], + }, + }), + ApiUnauthorized(), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ status: 200, description: 'Сессия успешно завершена.' }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + ); diff --git a/src/auth/application/controller/index.ts b/src/auth/application/controller/index.ts new file mode 100644 index 0000000..c2c6838 --- /dev/null +++ b/src/auth/application/controller/index.ts @@ -0,0 +1,2 @@ +export { AuthController } from './auth/controller'; +export { AuthRecoveryController } from './recovery/controller'; diff --git a/src/modules/auth/controller/recovery.controller.ts b/src/auth/application/controller/recovery/controller.ts similarity index 53% rename from src/modules/auth/controller/recovery.controller.ts rename to src/auth/application/controller/recovery/controller.ts index 25961bd..e274427 100644 --- a/src/modules/auth/controller/recovery.controller.ts +++ b/src/auth/application/controller/recovery/controller.ts @@ -1,32 +1,32 @@ -import { ApiBaseController } from '../../../shared/decorators'; +import { ApiBaseController } from '@shared/decorators'; import { Body, Post } from '@nestjs/common'; -import { AuthRecoveryService } from '../services'; import { PostPasswordResetConfirmSwagger, PostPasswordResetSwagger, PostPasswordResetVerifySwagger, -} from './auth.swagger'; -import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; +} from './swagger'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../../dtos'; +import { AuthFacade } from '../../auth.facade'; @ApiBaseController('auth', 'Auth Recovery') export class AuthRecoveryController { - constructor(private readonly facade: AuthRecoveryService) {} + constructor(private readonly facade: AuthFacade) {} @Post('password/reset') @PostPasswordResetSwagger() - async resetPasswordRequest(@Body() dto: ResetPasswordDto) { - return this.facade.resetPass(dto); + async sendResetCode(@Body() dto: ResetPasswordDto) { + return this.facade.sendResetCode(dto); } @Post('password/reset/verify') @PostPasswordResetVerifySwagger() async verifyResetCode(@Body() dto: VerifyResetCodeDto) { - return this.facade.verifyResetPassword(dto); + return this.facade.verifyResetCode(dto); } @Post('password/reset/confirm') @PostPasswordResetConfirmSwagger() - async confirmPasswordReset(@Body() dto: PasswordResetConfirmDto) { - return this.facade.confirmResetPass(dto); + async confirmNewPassword(@Body() dto: PasswordResetConfirmDto) { + return this.facade.confirmNewPassword(dto); } } diff --git a/src/modules/auth/controller/auth.swagger.ts b/src/auth/application/controller/recovery/swagger.ts similarity index 63% rename from src/modules/auth/controller/auth.swagger.ts rename to src/auth/application/controller/recovery/swagger.ts index d381d31..3e10cdc 100644 --- a/src/modules/auth/controller/auth.swagger.ts +++ b/src/auth/application/controller/recovery/swagger.ts @@ -2,7 +2,6 @@ import { applyDecorators } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiBadRequest, - ApiConflict, ApiErrorResponse, ApiForbidden, ApiNotFound, @@ -15,107 +14,10 @@ import { Disable2FaDto, PasswordResetConfirmDto, ResetPasswordDto, - SignInDto, - SignUpDto, - VerifyDto, VerifyResetCodeDto, -} from '../dtos'; +} from '../../dtos'; import { ActionResponse } from '@shared/dtos'; -export const PostRegisterSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Регистрация нового пользователя', - description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', - }), - ApiBody({ type: SignUpDto.Output }), - ApiResponse({ - status: 201, - description: 'Пользователь успешно зарегистрирован.', - type: ActionResponse.Output, - }), - ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), - ApiConflict('Пользователь с таким email уже существует'), - ); - -export const PostLoginSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Вход в систему', - description: - 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', - }), - ApiBody({ type: SignInDto.Output }), - ApiResponse({ - status: 200, - description: 'Успешный вход.', - schema: { - example: { - success: true, - message: false, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiBadRequest('Неверный формат email'), - ApiUnauthorized('Неверный email или пароль'), - ); - -export const PostRefreshSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновление токенов', - description: 'Выдает новую пару Access и Refresh токенов.', - }), - ApiResponse({ - status: 200, - description: 'Токены успешно обновлены.', - schema: { - example: { - success: true, - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - message: 'def50200508a1768c7e...', - }, - }, - }), - ApiBadRequest('Ошибка валидации (не передан refresh токен)'), - ApiUnauthorized('Refresh токен недействителен, истек или отозван'), - ); - -export const PostLogoutSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Выход из системы', - description: 'Удаляет текущую сессию пользователя из Redis.', - }), - ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), - ApiUnauthorized(), - ); - -export const PostSignUpConfirmSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Подтверждение регистрации по коду', - description: - 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', - }), - ApiBody({ type: VerifyDto.Output }), - ApiResponse({ - status: 201, - description: 'Аккаунт подтверждён, сессия создана.', - schema: { - example: { - success: true, - message: 'Аккаунт успешно подтвержден', - token: 'eyJhbGciOiJIUzI1NiIsInR5c...', - }, - }, - }), - ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), - ApiBadRequest('Срок регистрации истёк или сессия не найдена'), - ApiBadRequest('Неверный или истёкший код подтверждения'), - ); - export const PostPasswordResetSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/modules/auth/dtos/2fa.dto.ts b/src/auth/application/dtos/2fa.dto.ts similarity index 100% rename from src/modules/auth/dtos/2fa.dto.ts rename to src/auth/application/dtos/2fa.dto.ts diff --git a/src/modules/auth/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts similarity index 100% rename from src/modules/auth/dtos/auth.dto.ts rename to src/auth/application/dtos/auth.dto.ts diff --git a/src/modules/auth/dtos/index.ts b/src/auth/application/dtos/index.ts similarity index 100% rename from src/modules/auth/dtos/index.ts rename to src/auth/application/dtos/index.ts diff --git a/src/modules/auth/dtos/password.dto.ts b/src/auth/application/dtos/password.dto.ts similarity index 100% rename from src/modules/auth/dtos/password.dto.ts rename to src/auth/application/dtos/password.dto.ts diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts new file mode 100644 index 0000000..b6be75a --- /dev/null +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -0,0 +1,64 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import Redis from 'ioredis'; +import { UpdatePassUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { PasswordResetConfirmDto } from '../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + private readonly updateUserPass: UpdatePassUserCommand, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'RESET_SESSION_NOT_FOUND', + message: + 'Сессия восстановления не найдена или истекла. Начните процесс заново.', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession.isVerified) { + 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 BaseException( + { + code: 'PASSWORD_UPDATE_FAILED', + message: 'Не удалось обновить пароль. Попробуйте позже.', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + await this.redis.del(redisKey); + + return { + success: true, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } +} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts new file mode 100644 index 0000000..97a9482 --- /dev/null +++ b/src/auth/application/use-cases/index.ts @@ -0,0 +1,8 @@ +export { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; +export { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +export { RefreshTokensUseCase } from './refresh-tokens.use-case'; +export { ResetPasswordUseCase } from './reset-password.use-case'; +export { SignUpVerifyUseCase } from './sign-up-verify.use-case'; +export { SignInUseCase } from './sign-in.use-case'; +export { SignOutUseCase } from './sign-out.use-case'; +export { SignUpUseCase } from './sign-up.use-case'; diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts new file mode 100644 index 0000000..32dc367 --- /dev/null +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -0,0 +1,71 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; + +@Injectable() +export class RefreshTokensUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(token: string, metadata: DeviceMetadata) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + 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 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 BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + await this.sessionRepo.revoke(session.id); + + const newSession = await this.sessionRepo.create({ + userId: user.id, + ...metadata, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); + + return { + tokens: { access, refresh }, + success: true, + message: 'Токены успешно обновлены', + }; + } +} diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts new file mode 100644 index 0000000..82046f8 --- /dev/null +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -0,0 +1,67 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; +import { ResetPasswordEvent } from '../../domain/events'; +import { ResetPasswordDto } from '../dtos'; + +@Injectable() +export class ResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: ResetPasswordDto) { + const entity = await this.findUserCommand.execute({ email: dto.email }); + + if (!entity.user) { + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Пользователь с таким email не найден', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + const resetPayload = { + email: entity.user.email, + otp: { secret, token }, + isVerified: false, + }; + + await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); + + const event = new ResetPasswordEvent(dto.email, token); + await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts new file mode 100644 index 0000000..30ff23d --- /dev/null +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -0,0 +1,62 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { SignInDto } from '../dtos'; + +@Injectable() +export class SignInUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserCommand.execute({ email: dto.email }); + + if (!entities?.user || !entities?.security) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { security, user } = entities; + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new BaseException( + { + code: 'INVALID_CREDENTIALS', + message: 'Неверный email или пароль', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { id } = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + + const { access, refresh } = await this.tokenService.generateTokens(user, id); + + return { + success: true, + tokens: { + access, + refresh, + }, + message: 'Вы успешно вошли в систему', + }; + } +} diff --git a/src/auth/application/use-cases/sign-out.use-case.ts b/src/auth/application/use-cases/sign-out.use-case.ts new file mode 100644 index 0000000..23d85fc --- /dev/null +++ b/src/auth/application/use-cases/sign-out.use-case.ts @@ -0,0 +1,46 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; + +@Injectable() +export class SignOutUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(token: string) { + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: 'SESSION_EXPIRED', + message: 'Сессия уже истекла', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + 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, + ); + } + } + + return { success: true, message: 'Успешно вышли из системы!' }; + } +} diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts new file mode 100644 index 0000000..9afb00c --- /dev/null +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -0,0 +1,81 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { CreateUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { ISessionRepository } from '../../domain/repository'; +import { TokenService } from '../../infrastructure/security'; +import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { VerifyDto } from '../dtos'; + +@Injectable() +export class SignUpVerifyUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly createUserCommand: CreateUserCommand, + ) {} + + async execute(dto: VerifyDto, meta: DeviceMetadata) { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'REGISTRATION_EXPIRED', + message: 'Срок регистрации истек или email не найден. Попробуйте снова.', + }, + HttpStatus.GONE, + ); + } + + const userData = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + afterTimeStep: 1, + }); + + if (!verifyResult.valid) { + 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({ + ...userData.user, + password: userData.password, + }); + + const session = await this.sessionRepo.create({ + userId: user.id, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + ...meta, + }); + const { access, refresh } = await this.tokenService.generateTokens(user, session.id); + + await this.redis.del(redisKey); + + return { + success: true, + tokens: { access, refresh }, + message: 'Аккаунт успешно подтвержден', + }; + } +} diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts new file mode 100644 index 0000000..1217fd6 --- /dev/null +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -0,0 +1,86 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { generate, generateSecret } from 'otplib'; +import { FindOneUserCommand } from '@core/modules/user'; +import { BaseException } from '@shared/error'; +import { AuthQueues, AuthMailJobs } from '../../domain/enums'; +import { RegisterCodeEvent } from '../../domain/events'; +import { SignUpDto } from '../dtos'; + +@Injectable() +export class SignUpUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserCommand: FindOneUserCommand, + ) {} + + async execute(dto: SignUpDto) { + const redisKey = `reg:${dto.email}`; + + const cachedData = await this.redis.get(redisKey); + + if (cachedData) { + 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 BaseException( + { + code: 'USER_ALREADY_EXISTS', + message: 'Email уже занят другим аккаунтом', + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + }); + + const data = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + } +} diff --git a/src/auth/application/use-cases/verify-reset-password.use-case.ts b/src/auth/application/use-cases/verify-reset-password.use-case.ts new file mode 100644 index 0000000..842ae33 --- /dev/null +++ b/src/auth/application/use-cases/verify-reset-password.use-case.ts @@ -0,0 +1,63 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { BaseException } from '@shared/error'; +import { VerifyResetCodeDto } from '../dtos'; + +@Injectable() +export class VerifyResetPasswordUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(dto: VerifyResetCodeDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.redis.get(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: 'RESET_SESSION_EXPIRED', + message: + 'Время подтверждения истекло или запрос не найден. Запросите код снова.', + }, + HttpStatus.GONE, + ); + } + + const resetSession = JSON.parse(cachedData); + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: resetSession.otp.secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: 'INVALID_VERIFICATION_CODE', + message: 'Неверный или истекший код подтверждения', + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.redis.set( + redisKey, + JSON.stringify({ ...resetSession, isVerified: true }), + 'EX', + 600, + ); + + return { + success: true, + message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + } +} diff --git a/src/modules/auth/auth.module.ts b/src/auth/auth.module.ts similarity index 66% rename from src/modules/auth/auth.module.ts rename to src/auth/auth.module.ts index 1ea71d4..98ac4ac 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,16 +1,42 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { UserModule } from '../user'; -import { AuthController, AuthRecoveryController } from './controller'; -import { AuthRecoveryService, AuthService, TokenService } from './services'; -import { JwtModule } from '@nestjs/jwt'; -import { ConfigService } from '@nestjs/config'; -import { RedisModule } from '@nestjs-modules/ioredis'; -import { SessionRepository } from './repository'; -import { BearerStrategy, CookieStrategy } from './strategies'; -import { BullModule } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { BullModule } from '@nestjs/bullmq'; +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { UserModule } from '@core/modules/user'; +import { AuthController, AuthRecoveryController } from './application/controller'; +import { AuthFacade } from './application/auth.facade'; +import { + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +} from './application/use-cases'; +import { AuthQueues } from './domain/enums'; +import { SessionRepository } from './infrastructure/persistence/repositories'; +import { TokenService } from './infrastructure/security'; +import { BearerStrategy, CookieStrategy } from './infrastructure/strategies'; +import { MailProcessor } from './infrastructure/workers'; +import { MailAdapter } from '@shared/adapters/mail'; + +const USE_CASES = [ + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +]; + +const WORKERS = [MailProcessor]; const REPOSITORY = { provide: 'ISessionRepository', @@ -61,22 +87,28 @@ const REPOSITORY = { }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: AuthQueues.AUTH_MAIL, adapter: BullMQAdapter, }), forwardRef(() => UserModule), ], controllers: [AuthController, AuthRecoveryController], providers: [ - REPOSITORY, - AuthService, + // TOOD: FIX PROVIDER + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ...WORKERS, TokenService, CookieStrategy, BearerStrategy, - AuthRecoveryService, + REPOSITORY, + ...USE_CASES, + AuthFacade, ], exports: [], }) diff --git a/src/auth/domain/domain/.gitkeep b/src/auth/domain/domain/.gitkeep new file mode 100644 index 0000000..42e6429 --- /dev/null +++ b/src/auth/domain/domain/.gitkeep @@ -0,0 +1 @@ +# feature added entity class \ No newline at end of file diff --git a/src/auth/domain/enums/index.ts b/src/auth/domain/enums/index.ts new file mode 100644 index 0000000..a2f814f --- /dev/null +++ b/src/auth/domain/enums/index.ts @@ -0,0 +1 @@ +export { AuthMailJobs, AuthQueues } from './mail-jobs.enum'; diff --git a/src/auth/domain/enums/mail-jobs.enum.ts b/src/auth/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..e9ff5ab --- /dev/null +++ b/src/auth/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,9 @@ +export enum AuthQueues { + AUTH_MAIL = 'AUTH_MAIL_QUEUE', +} + +export enum AuthMailJobs { + SEND_REGISTER_CODE = 'AUTH_SEND_REGISTER_CODE', + SEND_RESET_PASSWORD = 'AUTH_SEND_RESET_PASSWORD', + SEND_CHANGE_EMAIL = 'AUTH_SEND_CHANGE_EMAIL', +} diff --git a/src/auth/domain/events/index.ts b/src/auth/domain/events/index.ts new file mode 100644 index 0000000..61a6360 --- /dev/null +++ b/src/auth/domain/events/index.ts @@ -0,0 +1,2 @@ +export { RegisterCodeEvent } from './register-code.event'; +export { ResetPasswordEvent } from './reset-password.event'; diff --git a/src/shared/workers/events/register-code.event.ts b/src/auth/domain/events/register-code.event.ts similarity index 100% rename from src/shared/workers/events/register-code.event.ts rename to src/auth/domain/events/register-code.event.ts diff --git a/src/shared/workers/events/reset-password.event.ts b/src/auth/domain/events/reset-password.event.ts similarity index 100% rename from src/shared/workers/events/reset-password.event.ts rename to src/auth/domain/events/reset-password.event.ts diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts new file mode 100644 index 0000000..298c188 --- /dev/null +++ b/src/auth/domain/repository/index.ts @@ -0,0 +1 @@ +export * from './session.repository.interface'; diff --git a/src/modules/auth/repository/session.repository.interface.ts b/src/auth/domain/repository/session.repository.interface.ts similarity index 85% rename from src/modules/auth/repository/session.repository.interface.ts rename to src/auth/domain/repository/session.repository.interface.ts index cde6762..e83a682 100644 --- a/src/modules/auth/repository/session.repository.interface.ts +++ b/src/auth/domain/repository/session.repository.interface.ts @@ -1,4 +1,4 @@ -import { sessions } from '../entities'; +import { sessions } from '../../infrastructure/persistence/models/session.model'; export type SessionInsert = typeof sessions.$inferInsert; export type SessionSelect = typeof sessions.$inferSelect; diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..9b52ede --- /dev/null +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -0,0 +1 @@ +export { sessions } from './session.model'; diff --git a/src/modules/auth/entities/session.entity.ts b/src/auth/infrastructure/persistence/models/session.model.ts similarity index 92% rename from src/modules/auth/entities/session.entity.ts rename to src/auth/infrastructure/persistence/models/session.model.ts index e3a1492..db788ae 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -1,8 +1,7 @@ import { createId } from '@paralleldrive/cuid2'; import { text, timestamp, varchar } from 'drizzle-orm/pg-core'; import { boolean } from 'drizzle-orm/pg-core'; -import { baseSchema } from '@shared/entities'; -import { users } from '../../user/entities'; +import { baseSchema, users } from '@shared/entities'; export const sessions = baseSchema.table('sessions', { id: text('id') diff --git a/src/modules/auth/repository/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts similarity index 54% rename from src/modules/auth/repository/index.ts rename to src/auth/infrastructure/persistence/repositories/index.ts index f1ead53..d223cdb 100644 --- a/src/modules/auth/repository/index.ts +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -1,2 +1 @@ -export * from './session.repository.interface'; export { SessionRepository } from './session.repository'; diff --git a/src/modules/auth/repository/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts similarity index 93% rename from src/modules/auth/repository/session.repository.ts rename to src/auth/infrastructure/persistence/repositories/session.repository.ts index 43510a0..709593a 100644 --- a/src/modules/auth/repository/session.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { eq, and, ne, lt, desc } from 'drizzle-orm'; -import * as schema from '../entities'; +import * as schema from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { ISessionRepository, type SessionInsert } from './session.repository.interface'; +import { ISessionRepository, type SessionInsert } from '../../../domain/repository'; @Injectable() export class SessionRepository implements ISessionRepository { diff --git a/src/auth/infrastructure/security/index.ts b/src/auth/infrastructure/security/index.ts new file mode 100644 index 0000000..0b27e01 --- /dev/null +++ b/src/auth/infrastructure/security/index.ts @@ -0,0 +1 @@ +export { TokenService } from './token.service'; diff --git a/src/modules/auth/services/token.service.ts b/src/auth/infrastructure/security/token.service.ts similarity index 100% rename from src/modules/auth/services/token.service.ts rename to src/auth/infrastructure/security/token.service.ts diff --git a/src/modules/auth/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts similarity index 100% rename from src/modules/auth/strategies/bearer.strategy.ts rename to src/auth/infrastructure/strategies/bearer.strategy.ts diff --git a/src/modules/auth/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts similarity index 100% rename from src/modules/auth/strategies/cookie.strategy.ts rename to src/auth/infrastructure/strategies/cookie.strategy.ts diff --git a/src/modules/auth/strategies/index.ts b/src/auth/infrastructure/strategies/index.ts similarity index 100% rename from src/modules/auth/strategies/index.ts rename to src/auth/infrastructure/strategies/index.ts diff --git a/src/modules/auth/helpers/get-device-meta.ts b/src/auth/infrastructure/utils/get-device-meta.ts similarity index 100% rename from src/modules/auth/helpers/get-device-meta.ts rename to src/auth/infrastructure/utils/get-device-meta.ts diff --git a/src/auth/infrastructure/workers/index.ts b/src/auth/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/auth/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/auth/infrastructure/workers/mail.processor.ts b/src/auth/infrastructure/workers/mail.processor.ts new file mode 100644 index 0000000..3e4a926 --- /dev/null +++ b/src/auth/infrastructure/workers/mail.processor.ts @@ -0,0 +1,72 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { IMailPort } from '@shared/adapters/mail'; +import { Inject } from '@nestjs/common'; +import { RegisterCodeEvent, ResetPasswordEvent } from '../../domain/events'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; + +@Processor(AuthQueues.AUTH_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise; + async process(job: Job): Promise; + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case AuthMailJobs.SEND_REGISTER_CODE: + await this.sendRegisterCode(job); + break; + case AuthMailJobs.SEND_RESET_PASSWORD: + await this.sendResetPassCode(job); + break; + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private sendRegisterCode = async (job: Job) => { + const { email, name, otp } = job.data; + + await job.log(`Sending registration code to: ${email}`); + await job.updateProgress(20); + + await this.mailAdapter.sendRegistrationCode(email, name, otp); + + await job.log(`Successfully sent to ${email}`); + await job.updateProgress(100); + }; + + private sendResetPassCode = async (job: Job) => { + const { email, otp } = job.data; + + await job.log(`Sending password reset to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendResetPasswordCode(email, otp); + + await job.log(`Reset link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/modules/auth/controller/index.ts b/src/modules/auth/controller/index.ts deleted file mode 100644 index c9ed49f..0000000 --- a/src/modules/auth/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AuthController } from './auth.controller'; -export { AuthRecoveryController } from './recovery.controller'; diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts deleted file mode 100644 index 5330080..0000000 --- a/src/modules/auth/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { sessions } from './session.entity'; diff --git a/src/modules/auth/helpers/index.ts b/src/modules/auth/helpers/index.ts deleted file mode 100644 index 1740a4d..0000000 --- a/src/modules/auth/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type DeviceMetadata, getDeviceMeta } from './get-device-meta'; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts deleted file mode 100644 index faa5c33..0000000 --- a/src/modules/auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthModule } from './auth.module'; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts deleted file mode 100644 index 488ace6..0000000 --- a/src/modules/auth/services/auth.service.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { SignInDto, SignUpDto, VerifyDto } from '../dtos'; -import { generate, generateSecret, verify as verifyOTP } from 'otplib'; -import * as argon from 'argon2'; -import { CreateUserCommand, FindOneUserCommand } from '../../user'; -import { TokenService } from './token.service'; -import { ISessionRepository } from '../repository'; -import { DeviceMetadata } from '../helpers'; -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 { - constructor( - @InjectRedis() - private readonly redis: Redis, - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly tokenService: TokenService, - private readonly findUserCommand: FindOneUserCommand, - private readonly createUserCommand: CreateUserCommand, - ) {} - - public signUp = async (dto: SignUpDto) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (cachedData) { - 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 BaseException( - { - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.CONFLICT, - ); - } - - const hashPass = await argon.hash(dto.password); - - const secret = generateSecret(); - const token = await generate({ - secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - }); - - const data = { - user: dto, - password: hashPass, - otp: { token, secret }, - }; - - await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); - - const event = new RegisterCodeEvent(dto.email, dto.firstName, token); - await this.mailQueue.add(MailJobs.SEND_REGISTER_CODE, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код подтверждения отправлен на вашу почту', - }; - }; - - public verify = async (dto: VerifyDto, meta: DeviceMetadata) => { - const redisKey = `reg:${dto.email}`; - - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }, - HttpStatus.GONE, - ); - } - - const userData = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: userData.otp.secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - afterTimeStep: 1, - }); - - if (!verifyResult.valid) { - 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({ - ...userData.user, - password: userData.password, - }); - - const session = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - const { access, refresh } = await this.tokenService.generateTokens(user, session.id); - - await this.redis.del(redisKey); - - return { - success: true, - tokens: { access, refresh }, - message: 'Аккаунт успешно подтвержден', - }; - }; - - public signIn = async (dto: SignInDto, meta: DeviceMetadata) => { - const entities = await this.findUserCommand.execute({ email: dto.email }); - - if (!entities?.user || !entities?.security) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { security, user } = entities; - const isPasswordValid = await argon.verify(security.passwordHash, dto.password); - - if (!isPasswordValid) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { id } = await this.sessionRepo.create({ - userId: user.id, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - ...meta, - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, id); - - return { - success: true, - tokens: { - access, - refresh, - }, - message: 'Вы успешно вошли в систему', - }; - }; - - public refresh = async (token: string, metadata: DeviceMetadata) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - 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 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 BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - await this.sessionRepo.revoke(session.id); - - const newSession = await this.sessionRepo.create({ - userId: user.id, - ...metadata, - expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), - }); - - const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); - - return { - tokens: { access, refresh }, - success: true, - message: 'Токены успешно обновлены', - }; - }; - - public signOut = async (token: string) => { - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'SESSION_EXPIRED', - message: 'Сессия уже истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - 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, - ); - } - } - - return { success: true, message: 'Успешно вышли из системы!' }; - }; -} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts deleted file mode 100644 index efc6350..0000000 --- a/src/modules/auth/services/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AuthService } from './auth.service'; -export { TokenService } from './token.service'; -export { AuthRecoveryService } from './recovery.service'; diff --git a/src/modules/auth/services/recovery.service.ts b/src/modules/auth/services/recovery.service.ts deleted file mode 100644 index c579cc9..0000000 --- a/src/modules/auth/services/recovery.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../dtos'; -import { generate, generateSecret, verify as verifyOTP } from 'otplib'; -import * as argon from 'argon2'; -import { FindOneUserCommand, UpdatePassUserCommand } from '../../user'; -import { InjectQueue } from '@nestjs/bullmq'; -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 { - constructor( - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly findUserCommand: FindOneUserCommand, - private readonly updateUserPass: UpdatePassUserCommand, - ) {} - - public resetPass = async (dto: ResetPasswordDto) => { - const entity = await this.findUserCommand.execute({ email: dto.email }); - - if (!entity.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.NOT_FOUND, - ); - } - - const secret = generateSecret(); - const token = await generate({ - secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - const resetPayload = { - email: entity.user.email, - otp: { secret, token }, - isVerified: false, - }; - - await this.redis.set(`pass:reset:${dto.email}`, JSON.stringify(resetPayload), 'EX', 900); - - const event = new ResetPasswordEvent(dto.email, token); - await this.mailQueue.add(MailJobs.SEND_RESET_PASSWORD, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код для восстановления пароля отправлен на вашу почту', - }; - }; - - public verifyResetPassword = async (dto: VerifyResetCodeDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_EXPIRED', - message: - 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }, - HttpStatus.GONE, - ); - } - - const resetSession = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: resetSession.otp.secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - if (!verifyResult.valid) { - throw new BaseException( - { - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'The provided OTP is incorrect' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - await this.redis.set( - redisKey, - JSON.stringify({ ...resetSession, isVerified: true }), - 'EX', - 600, - ); - - return { - success: true, - message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', - }; - }; - - public confirmResetPass = async (dto: PasswordResetConfirmDto) => { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_NOT_FOUND', - message: - 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - const resetSession = JSON.parse(cachedData); - - if (!resetSession.isVerified) { - 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 BaseException( - { - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - await this.redis.del(redisKey); - - return { - success: true, - message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', - }; - }; -} diff --git a/src/shared/adapters/mail/index.ts b/src/shared/adapters/mail/index.ts index f798bbb..e132652 100644 --- a/src/shared/adapters/mail/index.ts +++ b/src/shared/adapters/mail/index.ts @@ -1,2 +1,3 @@ export { MailAdapter } from './adapter'; export { IMailPort } from './port'; +export { MailModule } from './module'; diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts new file mode 100644 index 0000000..50174b7 --- /dev/null +++ b/src/shared/adapters/mail/module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { MailAdapter } from './adapter'; +import { MailProcessor } from '@shared/workers'; + +@Global() +@Module({ + providers: [ + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + MailProcessor, + ], + exports: ['IMailPort'], +}) +export class MailModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 676f897..b618226 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; export * from '../../modules/user/entities'; -export * from '../../modules/auth/entities'; +export * from '../../auth/infrastructure/persistence/models'; export * from '../../modules/teams/entities'; export * from '../../modules/projects/entities'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts index 863d67a..433d08a 100644 --- a/src/shared/workers/enum.ts +++ b/src/shared/workers/enum.ts @@ -3,8 +3,5 @@ export enum Queues { } export enum MailJobs { - SEND_REGISTER_CODE = 'SEND_REGISTER_CODE', - SEND_RESET_PASSWORD = 'SEND_RESET_PASSWORD', - SEND_CHANGE_EMAIL = 'SEND_CHANGE_EMAIL', SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', } diff --git a/src/shared/workers/events/index.ts b/src/shared/workers/events/index.ts index 6430cb9..f0cfd4e 100644 --- a/src/shared/workers/events/index.ts +++ b/src/shared/workers/events/index.ts @@ -1,3 +1 @@ -export { RegisterCodeEvent } from './register-code.event'; -export { ResetPasswordEvent } from './reset-password.event'; export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts index 2111275..c14cbc2 100644 --- a/src/shared/workers/index.ts +++ b/src/shared/workers/index.ts @@ -1,3 +1,2 @@ export { MailJobs, Queues } from './enum'; -export { RegisterCodeEvent } from './events'; export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/worker.ts b/src/shared/workers/mail/worker.ts index 3487606..3fe4d34 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/shared/workers/mail/worker.ts @@ -3,7 +3,7 @@ import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; +import { TeamInvitationEvent } from '../events'; @Processor(Queues.MAIL) export class MailProcessor extends WorkerHost { @@ -14,20 +14,12 @@ export class MailProcessor extends WorkerHost { super(); } - async process(job: Job): Promise; - async process(job: Job): Promise; async process(job: Job): Promise; async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { switch (job.name) { - case MailJobs.SEND_REGISTER_CODE: - await this.sendRegisterCode(job); - break; - case MailJobs.SEND_RESET_PASSWORD: - await this.sendResetPassCode(job); - break; case MailJobs.SEND_TEAM_INVITATION: await this.sendTeamInvitation(job); break; @@ -50,30 +42,6 @@ export class MailProcessor extends WorkerHost { } } - private sendRegisterCode = async (job: Job) => { - const { email, name, otp } = job.data; - - await job.log(`Sending registration code to: ${email}`); - await job.updateProgress(20); - - await this.mailAdapter.sendRegistrationCode(email, name, otp); - - await job.log(`Successfully sent to ${email}`); - await job.updateProgress(100); - }; - - private sendResetPassCode = async (job: Job) => { - const { email, otp } = job.data; - - await job.log(`Sending password reset to: ${email}`); - await job.updateProgress(30); - - await this.mailAdapter.sendResetPasswordCode(email, otp); - - await job.log(`Reset link delivered to ${email}`); - await job.updateProgress(100); - }; - private sendTeamInvitation = async (job: Job) => { const { email, teamName, inviteUrl } = job.data; From 8daf25698834ecc65cbe010795e6abec37a1c5d2 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:28:55 +0300 Subject: [PATCH 3/9] =?UTF-8?q?refactor(user):=20migrate=20to=20DDD=20hexa?= =?UTF-8?q?gonal=20architecture=20and=20update=20cros=E2=80=A6=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(user): migrate to DDD hexagonal architecture and update cross-module imports * refactor(tests): resolve conflicts test e2e --- src/app.module.ts | 2 +- .../confirm-reset-password.use-case.ts | 6 +- .../use-cases/refresh-tokens.use-case.ts | 6 +- .../use-cases/reset-password.use-case.ts | 6 +- .../application/use-cases/sign-in.use-case.ts | 6 +- .../use-cases/sign-up-verify.use-case.ts | 6 +- .../application/use-cases/sign-up.use-case.ts | 6 +- src/auth/auth.module.ts | 2 +- .../infrastructure/security/token.service.ts | 2 +- .../teams/repository/teams.repository.ts | 2 +- src/modules/user/commands/find-one.command.ts | 34 ----- src/modules/user/commands/index.ts | 3 - src/modules/user/controller/index.ts | 2 - .../user/controller/settings.controller.ts | 18 --- src/modules/user/index.ts | 4 - src/modules/user/repository/index.ts | 1 - src/modules/user/services/index.ts | 2 - src/modules/user/services/user.service.ts | 123 ------------------ src/modules/user/user.module.ts | 22 ---- src/shared/entities/index.ts | 2 +- src/user/application/controller/index.ts | 2 + .../controller/settings/controller.ts | 16 +++ .../controller/settings/swagger.ts | 23 ++++ .../controller/user/controller.ts} | 25 ++-- .../application/controller/user/swagger.ts} | 20 +-- .../user => user/application}/dtos/index.ts | 0 .../application}/dtos/user.dto.ts | 0 .../use-cases/find-profile.query.ts | 25 ++++ .../application/use-cases/find-user.query.ts | 24 ++++ .../use-cases/get-activity.query.ts | 30 +++++ src/user/application/use-cases/index.ts | 8 ++ .../use-cases/register-user.use-case.ts} | 29 ++--- .../update-notifications.use-case.ts} | 37 +++--- .../use-cases/update-password.use-case.ts} | 23 ++-- .../use-cases/update-profile.use-case.ts | 32 +++++ .../use-cases/upload-avatar.use-case.ts | 29 +++++ src/user/application/user.facade.ts | 41 ++++++ src/user/domain/entities/index.ts | 1 + .../domain}/entities/user.domain.ts | 7 +- src/user/domain/repository/index.ts | 1 + .../repository/user.repository.interface.ts | 0 src/user/index.ts | 3 + .../persistence/models}/index.ts | 0 .../persistence/models}/user.entity.ts | 0 .../persistence/repositories/index.ts | 1 + .../repositories}/user.repository.ts | 6 +- src/user/user.module.ts | 38 ++++++ test/app.e2e-spec.ts | 2 +- 48 files changed, 355 insertions(+), 323 deletions(-) delete mode 100644 src/modules/user/commands/find-one.command.ts delete mode 100644 src/modules/user/commands/index.ts delete mode 100644 src/modules/user/controller/index.ts delete mode 100644 src/modules/user/controller/settings.controller.ts delete mode 100644 src/modules/user/index.ts delete mode 100644 src/modules/user/repository/index.ts delete mode 100644 src/modules/user/services/index.ts delete mode 100644 src/modules/user/services/user.service.ts delete mode 100644 src/modules/user/user.module.ts create mode 100644 src/user/application/controller/index.ts create mode 100644 src/user/application/controller/settings/controller.ts create mode 100644 src/user/application/controller/settings/swagger.ts rename src/{modules/user/controller/user.controller.ts => user/application/controller/user/controller.ts} (57%) rename src/{modules/user/controller/user.swagger.ts => user/application/controller/user/swagger.ts} (84%) rename src/{modules/user => user/application}/dtos/index.ts (100%) rename src/{modules/user => user/application}/dtos/user.dto.ts (100%) create mode 100644 src/user/application/use-cases/find-profile.query.ts create mode 100644 src/user/application/use-cases/find-user.query.ts create mode 100644 src/user/application/use-cases/get-activity.query.ts create mode 100644 src/user/application/use-cases/index.ts rename src/{modules/user/commands/create.command.ts => user/application/use-cases/register-user.use-case.ts} (65%) rename src/{modules/user/services/settings.service.ts => user/application/use-cases/update-notifications.use-case.ts} (67%) rename src/{modules/user/commands/update-pass.command.ts => user/application/use-cases/update-password.use-case.ts} (57%) create mode 100644 src/user/application/use-cases/update-profile.use-case.ts create mode 100644 src/user/application/use-cases/upload-avatar.use-case.ts create mode 100644 src/user/application/user.facade.ts create mode 100644 src/user/domain/entities/index.ts rename src/{modules/user => user/domain}/entities/user.domain.ts (86%) create mode 100644 src/user/domain/repository/index.ts rename src/{modules/user => user/domain}/repository/user.repository.interface.ts (100%) create mode 100644 src/user/index.ts rename src/{modules/user/entities => user/infrastructure/persistence/models}/index.ts (100%) rename src/{modules/user/entities => user/infrastructure/persistence/models}/user.entity.ts (100%) create mode 100644 src/user/infrastructure/persistence/repositories/index.ts rename src/{modules/user/repository => user/infrastructure/persistence/repositories}/user.repository.ts (97%) create mode 100644 src/user/user.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 696bb75..404fdf8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,7 +7,7 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { HealthModule } from '@libs/health'; -import { UserModule } from './modules/user'; +import { UserModule } from './user'; import { GlobalExceptionFilter } from '@shared/error'; import { AuthModule } from './auth/auth.module'; import { BullBoardModule } from '@bull-board/nestjs'; diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts index b6be75a..cdbf3b6 100644 --- a/src/auth/application/use-cases/confirm-reset-password.use-case.ts +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -2,16 +2,16 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Injectable } from '@nestjs/common'; import * as argon from 'argon2'; import Redis from 'ioredis'; -import { UpdatePassUserCommand } from '@core/modules/user'; import { BaseException } from '@shared/error'; import { PasswordResetConfirmDto } from '../dtos'; +import { UpdatePasswordUseCase } from '@core/user'; @Injectable() export class ConfirmResetPasswordUseCase { constructor( @InjectRedis() private readonly redis: Redis, - private readonly updateUserPass: UpdatePassUserCommand, + private readonly updatePasswordUserUseCase: UpdatePasswordUseCase, ) {} async execute(dto: PasswordResetConfirmDto) { @@ -43,7 +43,7 @@ export class ConfirmResetPasswordUseCase { } const hashed = await argon.hash(dto.password); - const isUpdated = await this.updateUserPass.execute(dto.email, hashed); + const isUpdated = await this.updatePasswordUserUseCase.execute(dto.email, hashed); if (!isUpdated) { throw new BaseException( diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts index 32dc367..390c752 100644 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -1,9 +1,9 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { FindOneUserCommand } from '@core/modules/user'; import { BaseException } from '@shared/error'; import { ISessionRepository } from '../../domain/repository'; import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; +import { FindUserQuery } from '@core/user'; @Injectable() export class RefreshTokensUseCase { @@ -11,7 +11,7 @@ export class RefreshTokensUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly findUserCommand: FindOneUserCommand, + private readonly findUserQuery: FindUserQuery, ) {} async execute(token: string, metadata: DeviceMetadata) { @@ -39,7 +39,7 @@ export class RefreshTokensUseCase { ); } - const { user } = await this.findUserCommand.execute({ id: session.userId }); + const { user } = await this.findUserQuery.execute({ id: session.userId }); if (!user) { await this.sessionRepo.revoke(session.id); diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts index 82046f8..f930b56 100644 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -4,11 +4,11 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { Queue } from 'bullmq'; import Redis from 'ioredis'; import { generate, generateSecret } from 'otplib'; -import { FindOneUserCommand } from '@core/modules/user'; import { BaseException } from '@shared/error'; import { AuthMailJobs, AuthQueues } from '../../domain/enums'; import { ResetPasswordEvent } from '../../domain/events'; import { ResetPasswordDto } from '../dtos'; +import { FindUserQuery } from '@core/user'; @Injectable() export class ResetPasswordUseCase { @@ -17,11 +17,11 @@ export class ResetPasswordUseCase { private readonly redis: Redis, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, - private readonly findUserCommand: FindOneUserCommand, + private readonly findUserQuery: FindUserQuery, ) {} async execute(dto: ResetPasswordDto) { - const entity = await this.findUserCommand.execute({ email: dto.email }); + const entity = await this.findUserQuery.execute({ email: dto.email }); if (!entity.user) { throw new BaseException( diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts index 30ff23d..05c4d04 100644 --- a/src/auth/application/use-cases/sign-in.use-case.ts +++ b/src/auth/application/use-cases/sign-in.use-case.ts @@ -1,11 +1,11 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import * as argon from 'argon2'; -import { FindOneUserCommand } from '@core/modules/user'; import { BaseException } from '@shared/error'; import { ISessionRepository } from '../../domain/repository'; import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; import { SignInDto } from '../dtos'; +import { FindUserQuery } from '@core/user'; @Injectable() export class SignInUseCase { @@ -13,11 +13,11 @@ export class SignInUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly findUserCommand: FindOneUserCommand, + private readonly findUserQuery: FindUserQuery, ) {} async execute(dto: SignInDto, meta: DeviceMetadata) { - const entities = await this.findUserCommand.execute({ email: dto.email }); + const entities = await this.findUserQuery.execute({ email: dto.email }); if (!entities?.user || !entities?.security) { throw new BaseException( diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts index 9afb00c..6046c73 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -2,7 +2,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { verify as verifyOTP } from 'otplib'; -import { CreateUserCommand } from '@core/modules/user'; +import { RegisterUserUseCase } from '@core/user'; import { BaseException } from '@shared/error'; import { ISessionRepository } from '../../domain/repository'; import { TokenService } from '../../infrastructure/security'; @@ -17,7 +17,7 @@ export class SignUpVerifyUseCase { @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, - private readonly createUserCommand: CreateUserCommand, + private readonly registerUserUseCase: RegisterUserUseCase, ) {} async execute(dto: VerifyDto, meta: DeviceMetadata) { @@ -58,7 +58,7 @@ export class SignUpVerifyUseCase { ); } - const user = await this.createUserCommand.execute({ + const user = await this.registerUserUseCase.execute({ ...userData.user, password: userData.password, }); diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts index 1217fd6..5550c06 100644 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -5,7 +5,7 @@ import * as argon from 'argon2'; import { Queue } from 'bullmq'; import Redis from 'ioredis'; import { generate, generateSecret } from 'otplib'; -import { FindOneUserCommand } from '@core/modules/user'; +import { FindUserQuery } from '@core/user'; import { BaseException } from '@shared/error'; import { AuthQueues, AuthMailJobs } from '../../domain/enums'; import { RegisterCodeEvent } from '../../domain/events'; @@ -18,7 +18,7 @@ export class SignUpUseCase { private readonly redis: Redis, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, - private readonly findUserCommand: FindOneUserCommand, + private readonly findUserQuery: FindUserQuery, ) {} async execute(dto: SignUpDto) { @@ -37,7 +37,7 @@ export class SignUpUseCase { ); } - const isExists = await this.findUserCommand.execute({ email: dto.email }); + const isExists = await this.findUserQuery.execute({ email: dto.email }); if (isExists) { throw new BaseException( diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 98ac4ac..e79f42a 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,7 +5,7 @@ import { BullModule } from '@nestjs/bullmq'; import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; -import { UserModule } from '@core/modules/user'; +import { UserModule } from '@core/user'; import { AuthController, AuthRecoveryController } from './application/controller'; import { AuthFacade } from './application/auth.facade'; import { diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index 72930b1..a3f2480 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import type { JwtPayload } from '@shared/types'; -import type { User } from '@core/modules/user'; +import type { User } from '@core/user'; @Injectable() export class TokenService { diff --git a/src/modules/teams/repository/teams.repository.ts b/src/modules/teams/repository/teams.repository.ts index b880554..59f078b 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/modules/teams/repository/teams.repository.ts @@ -2,7 +2,7 @@ import { Inject, Logger } from '@nestjs/common'; import { ITeamsRepository } from './teams.repository.interface'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import * as schema from '../entities'; -import * as scUsers from '@core/modules/user/entities'; +import * as scUsers from '@core/user/infrastructure/persistence/models'; import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; export class TeamsRepository implements ITeamsRepository { diff --git a/src/modules/user/commands/find-one.command.ts b/src/modules/user/commands/find-one.command.ts deleted file mode 100644 index 8a78e1f..0000000 --- a/src/modules/user/commands/find-one.command.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { - constructor( - @Inject('IUserRepository') - private readonly repository: IUserRepository, - ) {} - - async execute(params: { email: string }): Promise; - async execute(params: { id: string }): Promise; - async execute(params: { email?: string; id?: string }): Promise { - const { email, id } = params; - - if (email) { - return this.repository.findByEmail(email); - } - - if (id) { - return this.repository.findById(id); - } - - throw new BaseException( - { - code: 'COMMAND_PARAMS_MISSING', - message: 'Критическая ошибка: не указаны параметры поиска пользователя', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } -} diff --git a/src/modules/user/commands/index.ts b/src/modules/user/commands/index.ts deleted file mode 100644 index 7a59139..0000000 --- a/src/modules/user/commands/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { CreateUserCommand } from './create.command'; -export { FindOneUserCommand } from './find-one.command'; -export { UpdatePassUserCommand } from './update-pass.command'; diff --git a/src/modules/user/controller/index.ts b/src/modules/user/controller/index.ts deleted file mode 100644 index beaad40..0000000 --- a/src/modules/user/controller/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { UserController } from './user.controller'; -export { UserSettingsController } from './settings.controller'; diff --git a/src/modules/user/controller/settings.controller.ts b/src/modules/user/controller/settings.controller.ts deleted file mode 100644 index e5aa8f4..0000000 --- a/src/modules/user/controller/settings.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Body, Patch, UseGuards } from '@nestjs/common'; -import { UserSettingsService } from '../services'; -import { PatchMeNotificationsSwagger } from './user.swagger'; -import type { UpdateNotificationsDto } from '../dtos'; -import { ApiBaseController, GetUserId } from '../../../shared/decorators'; -import { BearerAuthGuard } from '@shared/guards'; - -@ApiBaseController('users/me', 'Account Settings') -@UseGuards(BearerAuthGuard) -export class UserSettingsController { - constructor(private readonly facade: UserSettingsService) {} - - @Patch('notifications') - @PatchMeNotificationsSwagger() - async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { - return this.facade.updateNotifications(id, settings); - } -} diff --git a/src/modules/user/index.ts b/src/modules/user/index.ts deleted file mode 100644 index 9871038..0000000 --- a/src/modules/user/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { UserModule } from './user.module'; -export { UserRepository } from './repository/user.repository'; -export { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; -export { User } from './entities/user.domain'; diff --git a/src/modules/user/repository/index.ts b/src/modules/user/repository/index.ts deleted file mode 100644 index 3e89261..0000000 --- a/src/modules/user/repository/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} from './user.repository'; diff --git a/src/modules/user/services/index.ts b/src/modules/user/services/index.ts deleted file mode 100644 index b547819..0000000 --- a/src/modules/user/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { UserSettingsService } from './settings.service'; -export { UserService } from './user.service'; diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts deleted file mode 100644 index 2d95e6d..0000000 --- a/src/modules/user/services/user.service.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 { - constructor( - @Inject('IUserRepository') - private readonly userRepo: IUserRepository, - @Inject(USER_MEDIA_TOKEN) - private readonly mediaService: IUserMedia, - ) {} - - private throwUserNotFound() { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }, - HttpStatus.NOT_FOUND, - ); - } - - public getProfile = async (userId: string) => { - const { user, notifications, security } = await this.userRepo.findProfile(userId); - if (!user) this.throwUserNotFound(); - const { id, email, ...profile } = user; - - return { - id, - email, - profile, - security, - notifications, - }; - }; - - public updateProfile = async (id: string, dto: UpdateProfileDto) => { - try { - const isUpdated = await this.userRepo.updateProfile(id, dto); - - if (!isUpdated) { - throw new BaseException( - { - code: 'PROFILE_UPDATE_FAILED', - message: 'Не удалось обновить данные профиля', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - await this.userRepo.logActivity({ - id: createId(), - userId: id, - eventType: 'PROFILE_UPDATED', - }); - - return { - success: true, - message: 'Профиль успешно обновлен', - }; - } catch (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, - ); - } - }; - - public getActivity = async (id: string, page: number, limit: number) => { - const safeLimit = Math.min(limit, 50); - const offset = (page - 1) * safeLimit; - - const { items, total } = await this.userRepo.findActivityByUser(id, { - limit: safeLimit, - offset, - }); - - return { - items, - meta: { - total, - page, - limit: safeLimit, - totalPages: Math.ceil(total / safeLimit), - }, - }; - }; - - public uploadAvatar = async (userId: string, fileDto: FileUploadDto) => { - const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => - this.userRepo.updateAvatar(userId, url), - ); - - await this.userRepo.logActivity({ - id: createId(), - userId, - eventType: 'AVATAR_CHANGED', - metadata: { url }, - }); - - return { - success: true, - url, - }; - }; -} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts deleted file mode 100644 index cfaef81..0000000 --- a/src/modules/user/user.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UserController, UserSettingsController } from './controller'; -import { UserService } from './services/user.service'; -import { UserRepository } from './repository/user.repository'; -import { CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand } from './commands'; -import { MediaModule } from '../media'; -import { UserSettingsService } from './services'; - -const REPOSITORY = { - provide: 'IUserRepository', - useClass: UserRepository, -}; - -const COMMANDS = [CreateUserCommand, FindOneUserCommand, UpdatePassUserCommand]; - -@Module({ - imports: [MediaModule], - controllers: [UserController, UserSettingsController], - providers: [...COMMANDS, REPOSITORY, UserService, UserSettingsService], - exports: [...COMMANDS], -}) -export class UserModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index b618226..61f7880 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; -export * from '../../modules/user/entities'; +export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../modules/teams/entities'; export * from '../../modules/projects/entities'; diff --git a/src/user/application/controller/index.ts b/src/user/application/controller/index.ts new file mode 100644 index 0000000..0ea2e27 --- /dev/null +++ b/src/user/application/controller/index.ts @@ -0,0 +1,2 @@ +export { UserController } from './user/controller'; +export { UserSettingsController } from './settings/controller'; diff --git a/src/user/application/controller/settings/controller.ts b/src/user/application/controller/settings/controller.ts new file mode 100644 index 0000000..66a174c --- /dev/null +++ b/src/user/application/controller/settings/controller.ts @@ -0,0 +1,16 @@ +import { Body, Patch } from '@nestjs/common'; +import { UserFacade } from '../../user.facade'; +import { PatchMeNotificationsSwagger } from './swagger'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { UpdateNotificationsDto } from '../../dtos'; + +@ApiBaseController('users/me', 'Account Settings', true) +export class UserSettingsController { + constructor(private readonly facade: UserFacade) {} + + @Patch('notifications') + @PatchMeNotificationsSwagger() + async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { + return this.facade.updateNotifications(id, settings); + } +} diff --git a/src/user/application/controller/settings/swagger.ts b/src/user/application/controller/settings/swagger.ts new file mode 100644 index 0000000..956284f --- /dev/null +++ b/src/user/application/controller/settings/swagger.ts @@ -0,0 +1,23 @@ +import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { applyDecorators } from '@nestjs/common'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ActionResponse } from '@shared/dtos'; +import { UpdateNotificationsDto } from '../../dtos'; + +export const PatchMeNotificationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки уведомлений', + description: 'Частичное обновление настроек email и push уведомлений.', + }), + ApiBody({ + type: UpdateNotificationsDto.Output, + }), + ApiResponse({ + status: 200, + description: 'Настройки успешно сохранены.', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат настроек'), + ApiUnauthorized(), + ); diff --git a/src/modules/user/controller/user.controller.ts b/src/user/application/controller/user/controller.ts similarity index 57% rename from src/modules/user/controller/user.controller.ts rename to src/user/application/controller/user/controller.ts index 29dfde7..ec6fd3d 100644 --- a/src/modules/user/controller/user.controller.ts +++ b/src/user/application/controller/user/controller.ts @@ -1,21 +1,14 @@ -import { Body, Get, Patch, Post, Query, UseGuards } from '@nestjs/common'; -import { UserService } from '../services'; -import { - GetMeActivitySwagger, - GetMeSwagger, - PatchMeSwagger, - PostMeAvatarSwagger, -} from './user.swagger'; -import type { UpdateProfileDto } from '../dtos'; -import { ApiBaseController, ExtractFastifyFile, GetUserId } from '../../../shared/decorators'; -import { BearerAuthGuard } from '@shared/guards'; -import type { PaginationDto } from '../../../shared/dtos'; -import type { FileUploadDto } from '../../media'; +import { Body, Get, Patch, Post, Query } from '@nestjs/common'; +import { GetMeActivitySwagger, GetMeSwagger, PatchMeSwagger, PostMeAvatarSwagger } from './swagger'; +import { UpdateProfileDto } from '../../dtos'; +import { ApiBaseController, ExtractFastifyFile, GetUserId } from '@shared/decorators'; +import { UserFacade } from '../../user.facade'; +import { PaginationDto } from '@shared/dtos'; +import { FileUploadDto } from '@core/modules/media'; -@ApiBaseController('users/me', 'Account Profile') -@UseGuards(BearerAuthGuard) +@ApiBaseController('users/me', 'Account Profile', true) export class UserController { - constructor(private readonly facade: UserService) {} + constructor(private readonly facade: UserFacade) {} @Get() @GetMeSwagger() diff --git a/src/modules/user/controller/user.swagger.ts b/src/user/application/controller/user/swagger.ts similarity index 84% rename from src/modules/user/controller/user.swagger.ts rename to src/user/application/controller/user/swagger.ts index 2418daf..568fb14 100644 --- a/src/modules/user/controller/user.swagger.ts +++ b/src/user/application/controller/user/swagger.ts @@ -6,7 +6,7 @@ import { ApiQuery, ApiResponse, } from '@nestjs/swagger'; -import { UpdateNotificationsDto, UpdateProfileDto, UserResponse } from '../dtos'; +import { UpdateProfileDto, UserResponse } from '../../dtos'; import { applyDecorators } from '@nestjs/common'; import { ApiBadRequest, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ActionResponse } from '@shared/dtos'; @@ -49,24 +49,6 @@ export const PatchMeSwagger = () => ApiUnauthorized(), ); -export const PatchMeNotificationsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить настройки уведомлений', - description: 'Частичное обновление настроек email и push уведомлений.', - }), - ApiBody({ - type: UpdateNotificationsDto.Output, - }), - ApiResponse({ - status: 200, - description: 'Настройки успешно сохранены.', - type: ActionResponse.Output, - }), - ApiValidationError('Некорректный формат настроек'), - ApiUnauthorized(), - ); - export const GetMeActivitySwagger = () => applyDecorators( ApiOperation({ diff --git a/src/modules/user/dtos/index.ts b/src/user/application/dtos/index.ts similarity index 100% rename from src/modules/user/dtos/index.ts rename to src/user/application/dtos/index.ts diff --git a/src/modules/user/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts similarity index 100% rename from src/modules/user/dtos/user.dto.ts rename to src/user/application/dtos/user.dto.ts diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts new file mode 100644 index 0000000..df2972c --- /dev/null +++ b/src/user/application/use-cases/find-profile.query.ts @@ -0,0 +1,25 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class FindProfileQuery { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(userId: string) { + const { user, notifications, security } = await this.userRepo.findProfile(userId); + + if (!user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const { id, email, ...profile } = user; + return { id, email, profile, security, notifications }; + } +} diff --git a/src/user/application/use-cases/find-user.query.ts b/src/user/application/use-cases/find-user.query.ts new file mode 100644 index 0000000..a83ec65 --- /dev/null +++ b/src/user/application/use-cases/find-user.query.ts @@ -0,0 +1,24 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IUserRepository } from '@core/user/domain/repository'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class FindUserQuery { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(params: { email?: string; id?: string }) { + if (params.email) return this.repository.findByEmail(params.email); + if (params.id) return this.repository.findById(params.id); + + throw new BaseException( + { + code: 'QUERY_PARAMS_MISSING', + message: 'Не указаны параметры поиска', + }, + HttpStatus.BAD_REQUEST, + ); + } +} diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts new file mode 100644 index 0000000..5921bd6 --- /dev/null +++ b/src/user/application/use-cases/get-activity.query.ts @@ -0,0 +1,30 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetActivityQuery { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, page: number, limit: number) { + const safeLimit = Math.min(limit, 50); + const offset = (page - 1) * safeLimit; + + const { items, total } = await this.userRepo.findActivityByUser(id, { + limit: safeLimit, + offset, + }); + + return { + items, + meta: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; + } +} diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts new file mode 100644 index 0000000..fa08830 --- /dev/null +++ b/src/user/application/use-cases/index.ts @@ -0,0 +1,8 @@ +export { FindProfileQuery } from './find-profile.query'; +export { FindUserQuery } from './find-user.query'; +export { GetActivityQuery } from './get-activity.query'; +export { RegisterUserUseCase } from './register-user.use-case'; +export { UpdateNotificationsUseCase } from './update-notifications.use-case'; +export { UpdatePasswordUseCase } from './update-password.use-case'; +export { UpdateProfileUseCase } from './update-profile.use-case'; +export { UploadAvatarUseCase } from './upload-avatar.use-case'; diff --git a/src/modules/user/commands/create.command.ts b/src/user/application/use-cases/register-user.use-case.ts similarity index 65% rename from src/modules/user/commands/create.command.ts rename to src/user/application/use-cases/register-user.use-case.ts index 97861b4..7e150f8 100644 --- a/src/modules/user/commands/create.command.ts +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -1,11 +1,11 @@ +import type { NewUser } from '@core/user/domain/entities'; +import { IUserRepository } from '@core/user/domain/repository'; 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 { +export class RegisterUserUseCase { constructor( @Inject('IUserRepository') private readonly repository: IUserRepository, @@ -14,7 +14,7 @@ export class CreateUserCommand { async execute(dto: NewUser & { password: string }) { const existingUser = await this.repository.findByEmail(dto.email); - if (existingUser) { + if (existingUser?.user) { throw new BaseException( { code: 'USER_ALREADY_EXISTS', @@ -28,23 +28,22 @@ export class CreateUserCommand { 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); + await Promise.all([ + this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }), + 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' }, - ], + message: 'Не удалось завершить регистрацию', + details: [{ reason: error instanceof Error ? error.message : 'DB error' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/modules/user/services/settings.service.ts b/src/user/application/use-cases/update-notifications.use-case.ts similarity index 67% rename from src/modules/user/services/settings.service.ts rename to src/user/application/use-cases/update-notifications.use-case.ts index c4931c9..022e251 100644 --- a/src/modules/user/services/settings.service.ts +++ b/src/user/application/use-cases/update-notifications.use-case.ts @@ -1,29 +1,25 @@ +import { IUserRepository } from '@core/user/domain/repository'; 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'; +import { UpdateNotificationsDto } from '../dtos'; +import { createId } from '@paralleldrive/cuid2'; @Injectable() -export class UserSettingsService { +export class UpdateNotificationsUseCase { constructor( @Inject('IUserRepository') private readonly userRepo: IUserRepository, ) {} - private throwUserNotFound() { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден в системе', - }, - HttpStatus.NOT_FOUND, - ); - } - - public updateNotifications = async (id: string, dto: UpdateNotificationsDto) => { + async execute(id: string, dto: UpdateNotificationsDto) { const user = await this.userRepo.findById(id); - if (!user) this.throwUserNotFound(); + + if (!user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } try { const isUpdated = await this.userRepo.updateNotifications(id, { @@ -52,9 +48,7 @@ export class UserSettingsService { message: 'Настройки уведомлений обновлены', }; } catch (error) { - if (error instanceof BaseException) { - throw error; - } + if (error instanceof BaseException) throw error; throw new BaseException( { @@ -62,13 +56,12 @@ export class UserSettingsService { message: 'Ошибка при сохранении настроек пользователя', details: [ { - reason: - error instanceof Error ? error.message : 'Unknown database error', + reason: error instanceof Error ? error.message : 'Database error', }, ], }, HttpStatus.INTERNAL_SERVER_ERROR, ); } - }; + } } diff --git a/src/modules/user/commands/update-pass.command.ts b/src/user/application/use-cases/update-password.use-case.ts similarity index 57% rename from src/modules/user/commands/update-pass.command.ts rename to src/user/application/use-cases/update-password.use-case.ts index 6fc61dd..c718ae3 100644 --- a/src/modules/user/commands/update-pass.command.ts +++ b/src/user/application/use-cases/update-password.use-case.ts @@ -1,36 +1,35 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IUserRepository } from '../repository/user.repository.interface'; +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; import { BaseException } from '@shared/error'; @Injectable() -export class UpdatePassUserCommand { +export class UpdatePasswordUseCase { constructor( @Inject('IUserRepository') private readonly repository: IUserRepository, ) {} async execute(email: string, password: string) { - const { user } = await this.repository.findByEmail(email); + const result = await this.repository.findByEmail(email); - if (!user) { + if (!result?.user) { throw new BaseException( { code: 'USER_NOT_FOUND', message: 'Пользователь для обновления пароля не найден', - details: [{ target: 'email', value: email }], }, HttpStatus.NOT_FOUND, ); } try { - const isUpdated = await this.repository.updatePasswordHash(user.id, password); + const isUpdated = await this.repository.updatePasswordHash(result.user.id, password); if (!isUpdated) { throw new BaseException( { code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Запись не была изменена.', + message: 'Запись не была изменена', }, HttpStatus.INTERNAL_SERVER_ERROR, ); @@ -41,12 +40,8 @@ export class UpdatePassUserCommand { throw new BaseException( { code: 'DATABASE_ERROR', - message: 'Произошла критическая ошибка при работе с базой данных', - details: [ - { - reason: error instanceof Error ? error.message : 'Unknown DB error', - }, - ], + message: 'Ошибка при работе с БД', + details: [{ reason: error instanceof Error ? error.message : 'Unknown' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts new file mode 100644 index 0000000..4a43377 --- /dev/null +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -0,0 +1,32 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; +import { UpdateProfileDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class UpdateProfileUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, dto: UpdateProfileDto) { + const isUpdated = await this.userRepo.updateProfile(id, dto); + + if (!isUpdated) { + throw new BaseException( + { code: 'PROFILE_UPDATE_FAILED', message: 'Не удалось обновить данные' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + }); + + return { success: true }; + } +} diff --git a/src/user/application/use-cases/upload-avatar.use-case.ts b/src/user/application/use-cases/upload-avatar.use-case.ts new file mode 100644 index 0000000..12da3d0 --- /dev/null +++ b/src/user/application/use-cases/upload-avatar.use-case.ts @@ -0,0 +1,29 @@ +import { FileUploadDto, IUserMedia, USER_MEDIA_TOKEN } from '@core/modules/media'; +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; + +@Injectable() +export class UploadAvatarUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + @Inject(USER_MEDIA_TOKEN) + private readonly mediaService: IUserMedia, + ) {} + + async execute(userId: string, fileDto: FileUploadDto) { + const { url } = await this.mediaService.uploadUserAvatar(userId, fileDto, (url) => + this.userRepo.updateAvatar(userId, url), + ); + + await this.userRepo.logActivity({ + id: createId(), + userId, + eventType: 'AVATAR_CHANGED', + metadata: { url }, + }); + + return { success: true, url }; + } +} diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts new file mode 100644 index 0000000..e49cd8f --- /dev/null +++ b/src/user/application/user.facade.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { + FindProfileQuery, + GetActivityQuery, + UpdateNotificationsUseCase, + UpdateProfileUseCase, + UploadAvatarUseCase, +} from './use-cases'; +import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; +import { FileUploadDto } from '@core/modules/media'; + +@Injectable() +export class UserFacade { + constructor( + private readonly findProfileQuery: FindProfileQuery, + private readonly getActivityQuery: GetActivityQuery, + private readonly updateNotificationsUC: UpdateNotificationsUseCase, + private readonly updateProfileUC: UpdateProfileUseCase, + private readonly uploadAvatarUC: UploadAvatarUseCase, + ) {} + + public async getProfile(userId: string) { + return this.findProfileQuery.execute(userId); + } + + public async getActivity(userId: string, page: number, limit: number) { + return this.getActivityQuery.execute(userId, page, limit); + } + + public async updateProfile(userId: string, dto: UpdateProfileDto) { + return this.updateProfileUC.execute(userId, dto); + } + + public async updateNotifications(userId: string, dto: UpdateNotificationsDto) { + return this.updateNotificationsUC.execute(userId, dto); + } + + public async uploadAvatar(userId: string, file: FileUploadDto) { + return this.uploadAvatarUC.execute(userId, file); + } +} diff --git a/src/user/domain/entities/index.ts b/src/user/domain/entities/index.ts new file mode 100644 index 0000000..54f31af --- /dev/null +++ b/src/user/domain/entities/index.ts @@ -0,0 +1 @@ +export type * from './user.domain'; diff --git a/src/modules/user/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts similarity index 86% rename from src/modules/user/entities/user.domain.ts rename to src/user/domain/entities/user.domain.ts index 0721065..2bf467e 100644 --- a/src/modules/user/entities/user.domain.ts +++ b/src/user/domain/entities/user.domain.ts @@ -1,5 +1,10 @@ import { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -import { users, userSecurity, userNotifications, userActivity } from './user.entity'; +import { + users, + userSecurity, + userNotifications, + userActivity, +} from '../../infrastructure/persistence/models/user.entity'; export type User = InferSelectModel; export type NewUser = InferInsertModel; diff --git a/src/user/domain/repository/index.ts b/src/user/domain/repository/index.ts new file mode 100644 index 0000000..a9419f8 --- /dev/null +++ b/src/user/domain/repository/index.ts @@ -0,0 +1 @@ +export { IUserRepository } from './user.repository.interface'; diff --git a/src/modules/user/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts similarity index 100% rename from src/modules/user/repository/user.repository.interface.ts rename to src/user/domain/repository/user.repository.interface.ts diff --git a/src/user/index.ts b/src/user/index.ts new file mode 100644 index 0000000..4e472d9 --- /dev/null +++ b/src/user/index.ts @@ -0,0 +1,3 @@ +export { UserModule } from './user.module'; +export { RegisterUserUseCase, FindUserQuery, UpdatePasswordUseCase } from './application/use-cases'; +export { User } from './domain/entities/user.domain'; diff --git a/src/modules/user/entities/index.ts b/src/user/infrastructure/persistence/models/index.ts similarity index 100% rename from src/modules/user/entities/index.ts rename to src/user/infrastructure/persistence/models/index.ts diff --git a/src/modules/user/entities/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts similarity index 100% rename from src/modules/user/entities/user.entity.ts rename to src/user/infrastructure/persistence/models/user.entity.ts diff --git a/src/user/infrastructure/persistence/repositories/index.ts b/src/user/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..c9c59cf --- /dev/null +++ b/src/user/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { UserRepository } from './user.repository'; diff --git a/src/modules/user/repository/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts similarity index 97% rename from src/modules/user/repository/user.repository.ts rename to src/user/infrastructure/persistence/repositories/user.repository.ts index 757958b..b036891 100644 --- a/src/modules/user/repository/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -1,10 +1,10 @@ -import * as sc from '../entities'; +import { IUserRepository } from '@core/user/domain/repository'; +import * as sc from '../models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import { IUserRepository } from './user.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; -import type { NewUser, NewUserActivity, User, UserNotifications } from '../entities/user.domain'; import { createId } from '@paralleldrive/cuid2'; import { desc, eq, count } from 'drizzle-orm'; +import type { NewUser, NewUserActivity, User, UserNotifications } from '@core/user/domain/entities'; @Injectable() export class UserRepository implements IUserRepository { diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 0000000..df7d473 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { MediaModule } from '@core/modules/media'; +import { UserRepository } from './infrastructure/persistence/repositories'; +import { UserController, UserSettingsController } from './application/controller'; +import { UserFacade } from './application/user.facade'; +import { + FindProfileQuery, + FindUserQuery, + GetActivityQuery, + RegisterUserUseCase, + UpdateNotificationsUseCase, + UpdatePasswordUseCase, + UpdateProfileUseCase, + UploadAvatarUseCase, +} from './application/use-cases'; + +const REPOSITORY = { + provide: 'IUserRepository', + useClass: UserRepository, +}; + +const USE_CASES = [ + UploadAvatarUseCase, + UpdateProfileUseCase, + UpdateNotificationsUseCase, + FindProfileQuery, + GetActivityQuery, +]; + +const EXTERNAL_USE_CASES = [RegisterUserUseCase, UpdatePasswordUseCase, FindUserQuery]; + +@Module({ + imports: [MediaModule], + controllers: [UserController, UserSettingsController], + providers: [...USE_CASES, ...EXTERNAL_USE_CASES, REPOSITORY, UserFacade], + exports: [...EXTERNAL_USE_CASES], +}) +export class UserModule {} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index cc08a04..733a1f5 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '../src/modules/app/app.module'; +import { AppModule } from '../src/app.module'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; describe('App (e2e)', () => { From 2bbc389f63fd2355a62e45c2e22502c27d29fbe1 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:32:19 +0300 Subject: [PATCH 4/9] refactor: apply DDD to teams module and update cross-module imports (#52) --- src/app.module.ts | 2 +- .../projects/commands/find-project.command.ts | 6 +- .../projects/mappers/projects.mapper.ts | 2 +- src/modules/projects/projects.module.ts | 2 +- .../projects/services/projects.service.ts | 10 +- src/modules/teams/commands/index.ts | 2 - src/modules/teams/controller/index.ts | 5 - src/modules/teams/controller/teams.swagger.ts | 386 ---------------- src/modules/teams/entities/index.ts | 3 - src/modules/teams/index.ts | 2 - src/modules/teams/services/index.ts | 5 - .../teams/services/invitations.service.ts | 412 ------------------ src/modules/teams/services/me.service.ts | 32 -- src/modules/teams/services/members.service.ts | 212 --------- .../teams/services/settings.service.ts | 82 ---- src/modules/teams/services/teams.service.ts | 217 --------- src/shared/adapters/mail/module.ts | 2 - src/shared/entities/index.ts | 2 +- src/shared/workers/enum.ts | 7 - src/shared/workers/index.ts | 2 - src/shared/workers/mail/index.ts | 1 - src/teams/application/controller/index.ts | 5 + .../controller/invitations/controller.ts} | 8 +- .../controller/invitations/swagger.ts | 154 +++++++ .../application/controller/me/controller.ts} | 8 +- .../application/controller/me/swagger.ts | 34 ++ .../controller/members/controller.ts} | 8 +- .../application/controller/members/swagger.ts | 89 ++++ .../controller/settings/controller.ts} | 18 +- .../controller/settings/swagger.ts | 74 ++++ .../controller/teams/controller.ts} | 22 +- .../application/controller/teams/swagger.ts | 87 ++++ .../teams => teams/application}/dtos/index.ts | 0 .../application}/dtos/invitation.dto.ts | 2 +- .../application}/dtos/member.dto.ts | 2 +- .../application}/dtos/team.dto.ts | 0 .../application}/mappers/index.ts | 0 .../application}/mappers/member.mapper.ts | 2 +- src/teams/application/team.facade.ts | 93 ++++ .../use-cases/accept-invitation.use-case.ts | 83 ++++ .../use-cases/check-team-slug.query.ts | 21 + .../use-cases/create-team.use-case.ts | 58 +++ .../use-cases/decline-invitation.use-case.ts | 88 ++++ .../use-cases/delete-team.use-case.ts | 61 +++ .../use-cases/find-team-member.query.ts} | 4 +- .../application/use-cases/find-team.query.ts} | 4 +- .../use-cases/get-all-tags.use-case.ts | 37 ++ .../use-cases/get-invitation.query.ts | 52 +++ .../use-cases/get-invitations.query.ts | 58 +++ .../use-cases/get-my-invites.use-case.ts | 24 + .../use-cases/get-my-teams.use-case.ts | 16 + .../use-cases/get-team-members.query.ts | 26 ++ .../use-cases/get-user-invites.use-case.ts | 24 + src/teams/application/use-cases/index.ts | 24 + .../use-cases/remove-team-member.use-case.ts | 82 ++++ .../use-cases/send-invitation.use-case.ts | 91 ++++ .../use-cases/sync-team-tags.use-case.ts | 45 ++ .../use-cases/update-invitation.use-case.ts | 80 ++++ .../use-cases/update-team-avatar.use-case.ts | 31 ++ .../use-cases/update-team-banner.use-case.ts | 31 ++ .../use-cases/update-team-member.use-case.ts | 100 +++++ .../use-cases/update-team.use-case.ts | 60 +++ src/teams/domain/entities/index.ts | 1 + .../domain}/entities/teams.domain.ts | 2 +- src/teams/domain/enums/index.ts | 1 + src/teams/domain/enums/mail-jobs.enum.ts | 7 + .../workers => teams/domain}/events/index.ts | 0 .../domain}/events/team-invitation.event.ts | 0 .../domain}/repository/index.ts | 1 - .../repository/teams.repository.interface.ts | 0 src/teams/index.ts | 2 + .../persistence/models}/enums.ts | 0 .../persistence/models/index.ts | 2 + .../persistence/models/teams.model.ts} | 0 .../persistence/repositories/index.ts | 1 + .../repositories}/teams.repository.ts | 21 +- src/teams/infrastructure/workers/index.ts | 1 + .../infrastructure/workers/mail.processor.ts} | 19 +- src/{modules => }/teams/teams.module.ts | 66 +-- 79 files changed, 1750 insertions(+), 1474 deletions(-) delete mode 100644 src/modules/teams/commands/index.ts delete mode 100644 src/modules/teams/controller/index.ts delete mode 100644 src/modules/teams/controller/teams.swagger.ts delete mode 100644 src/modules/teams/entities/index.ts delete mode 100644 src/modules/teams/index.ts delete mode 100644 src/modules/teams/services/index.ts delete mode 100644 src/modules/teams/services/invitations.service.ts delete mode 100644 src/modules/teams/services/me.service.ts delete mode 100644 src/modules/teams/services/members.service.ts delete mode 100644 src/modules/teams/services/settings.service.ts delete mode 100644 src/modules/teams/services/teams.service.ts delete mode 100644 src/shared/workers/enum.ts delete mode 100644 src/shared/workers/index.ts delete mode 100644 src/shared/workers/mail/index.ts create mode 100644 src/teams/application/controller/index.ts rename src/{modules/teams/controller/invitations.controller.ts => teams/application/controller/invitations/controller.ts} (90%) create mode 100644 src/teams/application/controller/invitations/swagger.ts rename src/{modules/teams/controller/me.controller.ts => teams/application/controller/me/controller.ts} (71%) create mode 100644 src/teams/application/controller/me/swagger.ts rename src/{modules/teams/controller/members.controller.ts => teams/application/controller/members/controller.ts} (84%) create mode 100644 src/teams/application/controller/members/swagger.ts rename src/{modules/teams/controller/settings.controller.ts => teams/application/controller/settings/controller.ts} (63%) create mode 100644 src/teams/application/controller/settings/swagger.ts rename src/{modules/teams/controller/teams.controller.ts => teams/application/controller/teams/controller.ts} (64%) create mode 100644 src/teams/application/controller/teams/swagger.ts rename src/{modules/teams => teams/application}/dtos/index.ts (100%) rename src/{modules/teams => teams/application}/dtos/invitation.dto.ts (95%) rename src/{modules/teams => teams/application}/dtos/member.dto.ts (97%) rename src/{modules/teams => teams/application}/dtos/team.dto.ts (100%) rename src/{modules/teams => teams/application}/mappers/index.ts (100%) rename src/{modules/teams => teams/application}/mappers/member.mapper.ts (96%) create mode 100644 src/teams/application/team.facade.ts create mode 100644 src/teams/application/use-cases/accept-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/check-team-slug.query.ts create mode 100644 src/teams/application/use-cases/create-team.use-case.ts create mode 100644 src/teams/application/use-cases/decline-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/delete-team.use-case.ts rename src/{modules/teams/commands/find-member.command.ts => teams/application/use-cases/find-team-member.query.ts} (76%) rename src/{modules/teams/commands/find-team.command.ts => teams/application/use-cases/find-team.query.ts} (75%) create mode 100644 src/teams/application/use-cases/get-all-tags.use-case.ts create mode 100644 src/teams/application/use-cases/get-invitation.query.ts create mode 100644 src/teams/application/use-cases/get-invitations.query.ts create mode 100644 src/teams/application/use-cases/get-my-invites.use-case.ts create mode 100644 src/teams/application/use-cases/get-my-teams.use-case.ts create mode 100644 src/teams/application/use-cases/get-team-members.query.ts create mode 100644 src/teams/application/use-cases/get-user-invites.use-case.ts create mode 100644 src/teams/application/use-cases/index.ts create mode 100644 src/teams/application/use-cases/remove-team-member.use-case.ts create mode 100644 src/teams/application/use-cases/send-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/sync-team-tags.use-case.ts create mode 100644 src/teams/application/use-cases/update-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-avatar.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-banner.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-member.use-case.ts create mode 100644 src/teams/application/use-cases/update-team.use-case.ts create mode 100644 src/teams/domain/entities/index.ts rename src/{modules/teams => teams/domain}/entities/teams.domain.ts (87%) create mode 100644 src/teams/domain/enums/index.ts create mode 100644 src/teams/domain/enums/mail-jobs.enum.ts rename src/{shared/workers => teams/domain}/events/index.ts (100%) rename src/{shared/workers => teams/domain}/events/team-invitation.event.ts (100%) rename src/{modules/teams => teams/domain}/repository/index.ts (68%) rename src/{modules/teams => teams/domain}/repository/teams.repository.interface.ts (100%) create mode 100644 src/teams/index.ts rename src/{modules/teams/entities => teams/infrastructure/persistence/models}/enums.ts (100%) create mode 100644 src/teams/infrastructure/persistence/models/index.ts rename src/{modules/teams/entities/teams.entity.ts => teams/infrastructure/persistence/models/teams.model.ts} (100%) create mode 100644 src/teams/infrastructure/persistence/repositories/index.ts rename src/{modules/teams/repository => teams/infrastructure/persistence/repositories}/teams.repository.ts (93%) create mode 100644 src/teams/infrastructure/workers/index.ts rename src/{shared/workers/mail/worker.ts => teams/infrastructure/workers/mail.processor.ts} (70%) rename src/{modules => }/teams/teams.module.ts (57%) diff --git a/src/app.module.ts b/src/app.module.ts index 404fdf8..0d82338 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,7 @@ import { FastifyAdapter } from '@bull-board/fastify'; import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; -import { TeamsModule } from './modules/teams'; +import { TeamsModule } from './teams'; import { ProjectsModule } from './modules/projects'; @Module({ diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts index 099e8eb..a1d358b 100644 --- a/src/modules/projects/commands/find-project.command.ts +++ b/src/modules/projects/commands/find-project.command.ts @@ -1,6 +1,6 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; -import { FindTeamMemberCommand } from '@core/modules/teams'; +import { FindTeamMemberQuery } from '@core/teams'; import { createHash } from 'crypto'; import type { Project } from '../entities'; import { BaseException } from '@shared/error'; @@ -10,7 +10,7 @@ export class FindProjectCommand { constructor( @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, - private readonly findTeamMemberCommand: FindTeamMemberCommand, + private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} public async execute(projectId: string, userId?: string, shareToken?: string) { @@ -45,7 +45,7 @@ export class FindProjectCommand { ); } - const member = await this.findTeamMemberCommand.execute(project.teamId, userId); + const member = await this.findTeamMemberQ.execute(project.teamId, userId); if (!member) { throw new BaseException( diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/modules/projects/mappers/projects.mapper.ts index e63220e..708c1a2 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/modules/projects/mappers/projects.mapper.ts @@ -1,6 +1,6 @@ -import type { RawMemberRow } from '@core/modules/teams/repository'; import type { Project } from '@shared/entities'; import { ROLE_PRIORITY } from '@shared/constants'; +import { RawMemberRow } from '@core/teams/domain/repository'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index daaeac6..fb78d1b 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -2,7 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { ProjectsService } from './services'; import { ProjectsController } from './controller'; import { ProjectsRepository } from './repository'; -import { TeamsModule } from '../teams'; +import { TeamsModule } from '../../teams'; import { FindProjectCommand } from './commands'; const REPOSITORY = { diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 4ea0667..f15f6ce 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,20 +1,20 @@ 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 '@shared/constants'; import { ProjectStatus } from '../entities'; import { ProjectsMapper } from '../mappers'; import { createHash, randomBytes } from 'crypto'; import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; @Injectable() export class ProjectsService { constructor( @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, - private readonly findTeamCommand: FindTeamCommand, - private readonly findTeamMemberCommand: FindTeamMemberCommand, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} public create = async (userId: string, slug: string, dto: CreateProjectDto) => { @@ -254,7 +254,7 @@ export class ProjectsService { userId: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', ) { - const team = await this.findTeamCommand.execute(slug); + const team = await this.findTeamQ.execute(slug); if (!team) { throw new BaseException( { @@ -265,7 +265,7 @@ export class ProjectsService { ); } - const member = await this.findTeamMemberCommand.execute(team.id, userId); + const member = await this.findTeamMemberQ.execute(team.id, userId); if (!member) { throw new BaseException( { diff --git a/src/modules/teams/commands/index.ts b/src/modules/teams/commands/index.ts deleted file mode 100644 index 2292e4a..0000000 --- a/src/modules/teams/commands/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FindTeamMemberCommand } from './find-member.command'; -export { FindTeamCommand } from './find-team.command'; diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts deleted file mode 100644 index ac78b0a..0000000 --- a/src/modules/teams/controller/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeController } from './me.controller'; -export { TeamsController } from './teams.controller'; -export { TeamsMembersController } from './members.controller'; -export { TeamsSettingsController } from './settings.controller'; -export { TeamsInvitationsController } from './invitations.controller'; diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts deleted file mode 100644 index c770e62..0000000 --- a/src/modules/teams/controller/teams.swagger.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; -import { - ApiBadRequest, - ApiConflict, - ApiForbidden, - ApiNotFound, - ApiUnauthorized, - ApiValidationError, -} from '@shared/error'; -import { - CreateTeamDto, - InviteMemberDto, - TeamInvitationResponse, - SyncTagsDto, - UpdateTeamDto, - TagResponse, - TeamMemberResponse, - CheckSlugResponse, - UpdateMemberDto, - UpdateInvitationDto, - UserTeamResponse, - UserInviteResponse, -} from '../dtos'; -import { FileUploadResponse } from '../../media/dtos'; - -export const CreateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Создать новую команду' }), - ApiBody({ type: CreateTeamDto.Output }), - ApiResponse({ - status: 201, - description: 'Команда успешно создана', - type: ActionResponse.Output, - }), - ApiConflict('Команда с таким slug уже существует'), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const CheckSlugSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Проверить доступность слага', - description: 'Проверяет, свободен ли уникальный адрес команды для использования.', - }), - ApiParam({ - name: 'slug', - description: 'Желаемый слаг команды', - example: 'my-super-team', - }), - ApiResponse({ - status: 200, - description: 'Результат проверки доступности', - type: CheckSlugResponse.Output, - }), - ApiUnauthorized(), - ); - -export const FindTeamsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список команд пользователя', - description: - 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', - }), - ApiResponse({ - status: 200, - description: 'Список команд получен', - type: [UserTeamResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindInvitesSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список входящих приглашений', - description: - 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', - }), - ApiResponse({ - status: 200, - description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindOneTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), - ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), - ApiResponse({ - status: 200, - description: 'Данные команды получены', - type: Object, - }), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ); - -export const UpdateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Обновить данные команды' }), - ApiBody({ type: UpdateTeamDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), - ApiResponse({ - status: 200, - description: 'Команда успешно обновлена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const RemoveTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить команду' }), - ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), - ApiResponse({ - status: 200, - description: 'Команда успешно удалена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const SyncTeamTagsSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Синхронизировать теги команды' }), - ApiBody({ type: SyncTagsDto.Output }), - ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const GetAllTagsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех тегов с пагинацией', - description: - 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', - }), - ApiResponse({ - status: 200, - description: 'Список тегов успешно получен', - type: TagResponse.Output, - }), - ApiUnauthorized(), - ); - -export const GetMembersSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить список всех участников команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список участников получен', - type: [TeamMemberResponse.Output], - }), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const InviteMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Пригласить пользователя в команду по Email', - description: - 'Создает запись об участнике со статусом "pending".' + - ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + - ' Если нет — ему уйдет письмо на указанный Email.', - }), - ApiBody({ type: InviteMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), - ApiResponse({ - status: 201, - description: 'Инвайт создан и отправлен', - type: ActionResponse.Output, - }), - ApiValidationError('Некорректный формат Email или роль не поддерживается'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const UpdateMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Изменить роль или статус участника', - description: - 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + - ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', - }), - ApiBody({ type: UpdateMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), - ApiResponse({ - status: 200, - description: 'Данные участника обновлены', - type: ActionResponse.Output, - }), - ApiNotFound('Участник или команда не найдены'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const RemoveMemberSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить участника из команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя' }), - ApiResponse({ - status: 200, - type: ActionResponse.Output, - description: 'Участник успешно удален', - }), - ApiNotFound(), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamAvatarSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить аватар команды', - description: 'Загрузка файла изображения для профиля команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Аватар команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamBannerSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить баннер команды', - description: 'Загрузка файла изображения для обложки (баннера) команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Баннер команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const AcceptInviteSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Принять приглашение в команду', - description: - 'Активирует участие пользователя в команде по уникальному коду приглашения.' + - ' После успешного принятия статус участника меняется с "pending" на "active".' + - ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', - }), - ApiParam({ - name: 'code', - description: 'Уникальный код/токен приглашения (из ссылки или письма)', - example: '7df1-4a2b-9e8c', - }), - ApiResponse({ - status: 200, - description: 'Приглашение успешно принято. Пользователь теперь участник команды.', - type: ActionResponse.Output, - }), - ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), - ApiNotFound('Приглашение с таким кодом не найдено'), - ApiConflict('Пользователь уже является участником этой команды'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех приглашений в команду', - description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список приглашений команды', - type: [TeamInvitationResponse.Output], - }), - ApiNotFound('Команда не найдена'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить приглашение по коду', - description: - 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт найден', - type: TeamInvitationResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const UpdateTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить приглашение (только роль)', - description: - 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiBody({ type: UpdateInvitationDto.Output }), - ApiResponse({ - status: 200, - description: 'Инвайт обновлён', - type: TeamInvitationResponse.Output, - }), - ApiValidationError(), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const DeleteTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Удалить приглашение', - description: - 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт удалён', - type: ActionResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts deleted file mode 100644 index f996b3f..0000000 --- a/src/modules/teams/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; -export { roleEnum, statusEnum } from './enums'; -export * from './teams.domain'; diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts deleted file mode 100644 index 7f616ae..0000000 --- a/src/modules/teams/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TeamsModule } from './teams.module'; -export { FindTeamCommand, FindTeamMemberCommand } from './commands'; diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts deleted file mode 100644 index 1e5ca81..0000000 --- a/src/modules/teams/services/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeService } from './me.service'; -export { TeamsService } from './teams.service'; -export { TeamMembersService } from './members.service'; -export { TeamsSettingsService } from './settings.service'; -export { TeamInvitationsService } from './invitations.service'; diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts deleted file mode 100644 index 75bf93a..0000000 --- a/src/modules/teams/services/invitations.service.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { generateSecret } from 'otplib'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { InjectQueue } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '@shared/workers'; -import { Queue } from 'bullmq'; -import { TeamInvitationEvent } from '@shared/workers/events'; -import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; -import { ConfigService } from '@nestjs/config'; -import { BaseException } from '@shared/error'; -import { TeamInvite } from '@core/modules/teams/dtos/invitation.dto'; - -@Injectable() -export class TeamInvitationsService { - private readonly INVITE_TTL = 86400; - private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; - private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; - private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; - - private assertCanManageInvites = async (teamId: string, userId: string) => { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав управлять приглашениями в этой команде', - }, - HttpStatus.FORBIDDEN, - ); - } - return member; - }; - - private parseInvite = (raw: string, code?: string) => { - try { - const invite = JSON.parse(raw) as TeamInvite; - return code ? { code, ...invite } : invite; - } catch { - throw new BaseException( - { - code: 'INVITE_DATA_CORRUPTED', - message: 'Данные приглашения повреждены', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly cfg: ConfigService, - ) {} - - public getInvitations = async (slug: string, userId?: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (userId) { - await this.assertCanManageInvites(team.id, userId); - } - - const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); - if (!codes.length) return []; - - const keys = codes.map((c) => this.INVITES_KEY(c)); - const invitesRaw = await this.redis.mget(...keys); - - return invitesRaw - .map((raw, idx) => { - if (!raw) return null; - return this.parseInvite(raw, codes[idx]); - }) - .filter((v): v is TeamInvite => Boolean(v)); - }; - - public getInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw, code); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - return invite; - }; - - public updateInvitation = async ( - slug: string, - code: string, - userId: string, - dto: UpdateInvitationDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const key = this.INVITES_KEY(code); - const raw = await this.redis.get(key); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - const ttl = await this.redis.ttl(key); - if (ttl === -2) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - invite.role = dto.role; - - if (ttl > 0) { - await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); - } else { - await this.redis.set(key, JSON.stringify(invite)); - } - - return { code, ...invite }; - }; - - public declineInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.removeInvitation(team.id, code, invite.email); - - return { - success: true, - message: 'Приглашение удалено', - }; - }; - - public removeInvitation = async (teamId: string, code: string, email: string) => { - try { - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(teamId), code); - multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); - await multi.exec(); - } catch { - throw new BaseException( - { - code: 'REDIS_TRANSACTION_FAILED', - message: 'Не удалось удалить приглашение из системы', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { success: true }; - }; - - public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { - const team = await this.teamsRepo.findBySlug(slug); - 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 BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав приглашать новых участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - const code = generateSecret({ length: 8 }); - - const now = new Date(); - const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); - - const inviteData: TeamInvite = { - teamId: team.id, - teamName: team.name, - teamAvatar: team.avatarUrl, - email: dto.email, - role: dto.role || 'member', - inviterId, - inviterName: inviter.firstName, - createdAt: new Date().toISOString(), - expiresAt: expiresAt.toISOString(), - }; - - try { - const multi = this.redis.multi(); - multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); - multi.sadd(this.TEAM_INVITES_KEY(team.id), code); - multi.sadd(this.USER_INVITES_KEY(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]; - - const event = new TeamInvitationEvent( - dto.email, - team.name, - `${FRONTEND_URL}/invites/accept?code=${code}`, - ); - await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: `Приглашение отправлено на ${dto.email}`, - code, - }; - }; - - public acceptInvite = async (code: string, userId: string, email: string) => { - const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); - if (!inviteRaw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.GONE, - ); - } - - const invite = this.parseInvite(inviteRaw); - - if (invite.email.toLowerCase() !== email.toLowerCase()) { - 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 BaseException( - { - code: 'MEMBER_BANNED', - message: 'Вы заблокированы в этой команде', - }, - HttpStatus.FORBIDDEN, - ); - } - - if (member.status === 'active') { - throw new BaseException( - { - code: 'ALREADY_MEMBER', - message: 'Вы уже являетесь участником этой команды', - }, - HttpStatus.BAD_REQUEST, - ); - } - } - - try { - await this.teamsRepo.addMember({ - teamId: invite.teamId, - userId, - role: invite.role, - status: 'active', - joinedAt: new Date(), - }); - - await this.removeInvitation(invite.teamId, code, email); - - 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/me.service.ts b/src/modules/teams/services/me.service.ts deleted file mode 100644 index e0012b6..0000000 --- a/src/modules/teams/services/me.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { TeamMemberMapper } from '../mappers'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; - -@Injectable() -export class MeService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - ) {} - - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - }; - - public getAll = async (userId: string, pagination: Record) => { - const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); - }; -} diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts deleted file mode 100644 index 9fca6e9..0000000 --- a/src/modules/teams/services/members.service.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import type { UpdateMemberDto } from '../dtos'; -import { TeamMemberMapper } from '../mappers'; -import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; - -@Injectable() -export class TeamMembersService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - ) {} - - public getMembers = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const members = await this.teamsRepo.findMembers(team.id); - return TeamMemberMapper.toList(members); - }; - - public updateMember = async ( - slug: string, - currentUserId: string, - targetUserId: string, - dto: UpdateMemberDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - 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 BaseException( - { - code: 'MEMBER_NOT_FOUND', - message: 'Участник не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new BaseException( - { - code: 'ADMIN_ROLE_REQUIRED', - message: 'У вас нет прав на редактирование участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - // Нельзя менять роль тому, кто выше тебя или равен тебе по весу - if ( - currentUserId !== targetUserId && - ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] - ) { - 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 BaseException( - { - code: 'OWNER_PROTECTION_VIOLATION', - message: - 'Нельзя изменить роль владельца через это меню. Используйте передачу прав.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - // Нельзя назначить роль выше своей (Админ не может сделать кого-то Овнером) - if ( - dto.role && - ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && - currentUser.role !== 'owner' - ) { - throw new BaseException( - { - code: 'CANNOT_ASSIGN_HIGHER_ROLE', - message: 'Вы не можете назначить роль выше своей или равную своей', - }, - HttpStatus.FORBIDDEN, - ); - } - - 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 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 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 BaseException( - { - code: 'OWNER_CANNOT_LEAVE', - message: - 'Владелец не может покинуть команду. Передайте права или удалите команду.', - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; - const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; - - if (!hasAuthority || !canKick) { - throw new BaseException( - { - code: 'KICK_FORBIDDEN', - message: 'У вас недостаточно прав, чтобы исключить этого участника', - details: [ - { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, - ], - }, - HttpStatus.FORBIDDEN, - ); - } - } - - 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 deleted file mode 100644 index 15ee711..0000000 --- a/src/modules/teams/services/settings.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @Inject(TEAM_MEDIA_TOKEN) - private readonly mediaService: ITeamMedia, - ) {} - - public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => - this.teamsRepo.updateTeamAvatar(team.id, url), - ); - }; - - public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => - this.teamsRepo.updateTeamBanner(team.id, url), - ); - }; - - public syncTags = async (slug: string, tags: string[]) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - 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 BaseException( - { - code: 'TAGS_SYNC_FAILED', - message: 'Не удалось обновить теги команды. Попробуйте позже.', - details: [{ target: 'tags', count: normalizedTags.length }], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { - success: true, - message: 'Теги команды обновлены', - }; - }; -} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts deleted file mode 100644 index f56ce3f..0000000 --- a/src/modules/teams/services/teams.service.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Inject, Injectable, HttpStatus } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { FindTagsQuery } from '../dtos'; -import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; -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 { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - ) {} - - public checkSlug = async (slug: string) => { - const available = await this.teamsRepo.isSlugAvailable(slug); - return { available }; - }; - - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - }; - - public create = async (userId: string, dto: CreateTeamDto) => { - const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); - const existingTeam = await this.teamsRepo.findBySlug(baseSlug); - - if (existingTeam) { - throw new BaseException( - { - code: 'SLUG_ALREADY_EXISTS', - message: `Ссылка "${baseSlug}" уже занята другой командой`, - details: [{ target: 'slug', value: baseSlug }], - }, - HttpStatus.CONFLICT, - ); - } - - const { tags, ...teamData } = dto; - const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; - - try { - const result = await this.teamsRepo.create( - userId, - { - ...teamData, - slug: baseSlug, - }, - uniqueTags, - ); - - return { - ...result, - slug: baseSlug, - message: 'Команда успешно создана', - }; - } catch (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 BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canEdit = member?.role === 'admin' || member?.role === 'owner'; - - if (!canEdit) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для редактирования этой команды', - details: [{ target: 'role', value: member?.role }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const { tags, ...data } = dto; - - try { - const result = await this.teamsRepo.update(team.id, data, tags); - - return { - ...result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_UPDATE_FAILED', - message: 'Ошибка при обновлении данных команды', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public remove = async (slug: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canDelete = team.ownerId === userId || member?.role === 'owner'; - - if (!canDelete) { - throw new BaseException( - { - code: 'ONLY_OWNER_CAN_DELETE', - message: 'Только владелец может удалить команду', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.remove(team.id, userId); - - return { - success: result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_DELETE_FAILED', - message: 'Не удалось удалить команду', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public getAllTags = async (query: FindTagsQuery) => { - const safePage = Math.max(query.page ?? 1, 1); - const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); - const offset = (safePage - 1) * safeLimit; - - const { data, total } = await this.teamsRepo.findAllTags({ - search: query.search, - limit: safeLimit, - offset, - }); - - const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); - return { - data, - meta: { - hasNextPage: safePage < totalPages, - hasPrevPage: safePage > 1, - total, - totalPages, - page: safePage, - limit: safeLimit, - }, - }; - }; - - public getAll = async (userId: string, pagination: Record) => { - const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); - }; - - public getOne = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - return team; - }; -} diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts index 50174b7..d70c47c 100644 --- a/src/shared/adapters/mail/module.ts +++ b/src/shared/adapters/mail/module.ts @@ -1,6 +1,5 @@ import { Global, Module } from '@nestjs/common'; import { MailAdapter } from './adapter'; -import { MailProcessor } from '@shared/workers'; @Global() @Module({ @@ -9,7 +8,6 @@ import { MailProcessor } from '@shared/workers'; provide: 'IMailPort', useClass: MailAdapter, }, - MailProcessor, ], exports: ['IMailPort'], }) diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 61f7880..119ec4c 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; -export * from '../../modules/teams/entities'; +export * from '../../teams/infrastructure/persistence/models'; export * from '../../modules/projects/entities'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts deleted file mode 100644 index 433d08a..0000000 --- a/src/shared/workers/enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum Queues { - MAIL = 'MAIL_QUEUE', -} - -export enum MailJobs { - SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', -} diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts deleted file mode 100644 index c14cbc2..0000000 --- a/src/shared/workers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MailJobs, Queues } from './enum'; -export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/index.ts b/src/shared/workers/mail/index.ts deleted file mode 100644 index a059e2b..0000000 --- a/src/shared/workers/mail/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MailProcessor } from './worker'; diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts new file mode 100644 index 0000000..5e1b219 --- /dev/null +++ b/src/teams/application/controller/index.ts @@ -0,0 +1,5 @@ +export { MeController } from './me/controller'; +export { TeamsController } from './teams/controller'; +export { TeamsMembersController } from './members/controller'; +export { TeamsSettingsController } from './settings/controller'; +export { TeamsInvitationsController } from './invitations/controller'; diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/teams/application/controller/invitations/controller.ts similarity index 90% rename from src/modules/teams/controller/invitations.controller.ts rename to src/teams/application/controller/invitations/controller.ts index a4df155..ac507ea 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/teams/application/controller/invitations/controller.ts @@ -1,6 +1,5 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamInvitationsService } from '../services'; import { AcceptInviteSwagger, DeleteTeamInvitationSwagger, @@ -8,13 +7,14 @@ import { GetTeamInvitationsSwagger, InviteMemberSwagger, UpdateTeamInvitationSwagger, -} from './teams.swagger'; +} from './swagger'; import type { JwtPayload } from '@shared/types'; -import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; +import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) export class TeamsInvitationsController { - constructor(private readonly facade: TeamInvitationsService) {} + constructor(private readonly facade: TeamsFacade) {} @Get() @GetTeamInvitationsSwagger() diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/teams/application/controller/invitations/swagger.ts new file mode 100644 index 0000000..30f5dca --- /dev/null +++ b/src/teams/application/controller/invitations/swagger.ts @@ -0,0 +1,154 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { + InviteMemberDto, + TeamInvitationResponse, + UpdateInvitationDto, + UserInviteResponse, +} from '../../dtos'; + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: [TeamInvitationResponse.Output], + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: TeamInvitationResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/me.controller.ts b/src/teams/application/controller/me/controller.ts similarity index 71% rename from src/modules/teams/controller/me.controller.ts rename to src/teams/application/controller/me/controller.ts index 9ec2f60..7c1098b 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/teams/application/controller/me/controller.ts @@ -1,18 +1,18 @@ import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { MeService } from '../services'; import { Get, Query } from '@nestjs/common'; -import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; +import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; import type { JwtPayload } from '@shared/types'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { - constructor(private readonly facade: MeService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('teams') @FindTeamsSwagger() // TODO: ADD TO QUERY DTO async findMyTeams(@GetUserId() userId: string, @Query() query: any) { - return this.facade.getAll(userId, query); + return this.facade.getMyTeams(userId, query); } @Get('invites') diff --git a/src/teams/application/controller/me/swagger.ts b/src/teams/application/controller/me/swagger.ts new file mode 100644 index 0000000..8081737 --- /dev/null +++ b/src/teams/application/controller/me/swagger.ts @@ -0,0 +1,34 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized } from '@shared/error'; +import { UserTeamResponse, UserInviteResponse } from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/members.controller.ts b/src/teams/application/controller/members/controller.ts similarity index 84% rename from src/modules/teams/controller/members.controller.ts rename to src/teams/application/controller/members/controller.ts index 1f908ad..b4e9b22 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/teams/application/controller/members/controller.ts @@ -1,12 +1,12 @@ import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { TeamMembersService } from '../services'; -import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './teams.swagger'; -import type { UpdateMemberDto } from '../dtos/member.dto'; +import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; +import type { UpdateMemberDto } from '../../dtos/member.dto'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug', 'Teams Members', true) export class TeamsMembersController { - constructor(private readonly facade: TeamMembersService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('members') @GetMembersSwagger() diff --git a/src/teams/application/controller/members/swagger.ts b/src/teams/application/controller/members/swagger.ts new file mode 100644 index 0000000..92c1dcf --- /dev/null +++ b/src/teams/application/controller/members/swagger.ts @@ -0,0 +1,89 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { + TeamMemberResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [TeamMemberResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/settings.controller.ts b/src/teams/application/controller/settings/controller.ts similarity index 63% rename from src/modules/teams/controller/settings.controller.ts rename to src/teams/application/controller/settings/controller.ts index 91484ab..16f8ac5 100644 --- a/src/modules/teams/controller/settings.controller.ts +++ b/src/teams/application/controller/settings/controller.ts @@ -1,17 +1,13 @@ import { Body, Param, Patch, Put } from '@nestjs/common'; import { ApiBaseController, ExtractFastifyFile } from '@shared/decorators'; -import { TeamsSettingsService } from '../services'; -import { - SyncTeamTagsSwagger, - PatchTeamAvatarSwagger, - PatchTeamBannerSwagger, -} from './teams.swagger'; -import type { FileUploadDto } from '../../media'; -import type { SyncTagsDto } from '../dtos'; +import { SyncTeamTagsSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger } from './swagger'; +import { SyncTagsDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; +import { FileUploadDto } from '@core/modules/media'; @ApiBaseController('teams/:slug', 'Teams Settings', true) export class TeamsSettingsController { - constructor(private readonly facade: TeamsSettingsService) {} + constructor(private readonly facade: TeamsFacade) {} @Put('tags') @SyncTeamTagsSwagger() @@ -25,7 +21,7 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamAvatar(slug, fileDto); + return this.facade.updateAvatar(slug, fileDto); } @Patch('banner') @@ -34,6 +30,6 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamBanner(slug, fileDto); + return this.facade.updateBanner(slug, fileDto); } } diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts new file mode 100644 index 0000000..19d90e3 --- /dev/null +++ b/src/teams/application/controller/settings/swagger.ts @@ -0,0 +1,74 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiBadRequest, ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { SyncTagsDto } from '../../dtos'; +import { FileUploadResponse } from '@core/modules/media'; + +export const SyncTeamTagsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Синхронизировать теги команды' }), + ApiBody({ type: SyncTagsDto.Output }), + ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const PatchTeamAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить аватар команды', + description: 'Загрузка файла изображения для профиля команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Аватар команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamBannerSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить баннер команды', + description: 'Загрузка файла изображения для обложки (баннера) команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Баннер команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/teams.controller.ts b/src/teams/application/controller/teams/controller.ts similarity index 64% rename from src/modules/teams/controller/teams.controller.ts rename to src/teams/application/controller/teams/controller.ts index 6d85b34..b4546e0 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/teams/application/controller/teams/controller.ts @@ -1,23 +1,23 @@ import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { TeamsService } from '../services'; import { CreateTeamSwagger, FindOneTeamSwagger, RemoveTeamSwagger, UpdateTeamSwagger, CheckSlugSwagger, -} from './teams.swagger'; -import { CreateTeamDto } from '../dtos'; +} from './swagger'; +import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { - constructor(private readonly facade: TeamsService) {} + constructor(private readonly facade: TeamsFacade) {} @Post() @CreateTeamSwagger() async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { - return this.facade.create(userId, dto); + return this.facade.createTeam(userId, dto); } @Get('check-slug/:slug') @@ -29,19 +29,23 @@ export class TeamsController { @Get(':slug') @FindOneTeamSwagger() async findOne(@Param('slug') slug: string) { - return this.facade.getOne(slug); + return this.facade.getTeamBySlug(slug); } @Patch(':slug') @UpdateTeamSwagger() - async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.update(slug, userId, dto); + async update( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: UpdateTeamDto, + ) { + return this.facade.updateTeam(slug, userId, dto); } @Delete(':slug') @RemoveTeamSwagger() @HttpCode(HttpStatus.OK) async remove(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.remove(slug, userId); + return this.facade.deleteTeam(slug, userId); } } diff --git a/src/teams/application/controller/teams/swagger.ts b/src/teams/application/controller/teams/swagger.ts new file mode 100644 index 0000000..fac00e5 --- /dev/null +++ b/src/teams/application/controller/teams/swagger.ts @@ -0,0 +1,87 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { CreateTeamDto, UpdateTeamDto, CheckSlugResponse } from '../../dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность слага', + description: 'Проверяет, свободен ли уникальный адрес команды для использования.', + }), + ApiParam({ + name: 'slug', + description: 'Желаемый слаг команды', + example: 'my-super-team', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки доступности', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), + ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: Object, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), + ApiResponse({ + status: 200, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/teams/application/dtos/index.ts similarity index 100% rename from src/modules/teams/dtos/index.ts rename to src/teams/application/dtos/index.ts diff --git a/src/modules/teams/dtos/invitation.dto.ts b/src/teams/application/dtos/invitation.dto.ts similarity index 95% rename from src/modules/teams/dtos/invitation.dto.ts rename to src/teams/application/dtos/invitation.dto.ts index d3908a9..9d7c2b8 100644 --- a/src/modules/teams/dtos/invitation.dto.ts +++ b/src/teams/application/dtos/invitation.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum, TeamRole } from '../entities/enums'; +import { roleEnum, TeamRole } from '../../infrastructure/persistence/models/enums'; export const UpdateInvitationSchema = z.object({ role: z diff --git a/src/modules/teams/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts similarity index 97% rename from src/modules/teams/dtos/member.dto.ts rename to src/teams/application/dtos/member.dto.ts index ac48ccd..2fe245d 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum } from '../entities'; +import { roleEnum } from '@core/teams/infrastructure/persistence/models'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), diff --git a/src/modules/teams/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts similarity index 100% rename from src/modules/teams/dtos/team.dto.ts rename to src/teams/application/dtos/team.dto.ts diff --git a/src/modules/teams/mappers/index.ts b/src/teams/application/mappers/index.ts similarity index 100% rename from src/modules/teams/mappers/index.ts rename to src/teams/application/mappers/index.ts diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/teams/application/mappers/member.mapper.ts similarity index 96% rename from src/modules/teams/mappers/member.mapper.ts rename to src/teams/application/mappers/member.mapper.ts index cf2f6f5..297f1e1 100644 --- a/src/modules/teams/mappers/member.mapper.ts +++ b/src/teams/application/mappers/member.mapper.ts @@ -1,4 +1,4 @@ -import type { RawMemberRow, RawMemberTeams } from '../repository'; +import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; export class TeamMemberMapper { public static toDetail(row: RawMemberRow) { diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts new file mode 100644 index 0000000..e2832ff --- /dev/null +++ b/src/teams/application/team.facade.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import * as UC from './use-cases'; +import type { + CreateTeamDto, + InviteMemberDto, + UpdateInvitationDto, + UpdateMemberDto, + UpdateTeamDto, +} from './dtos'; +import { FileUploadDto } from '@core/modules/media'; + +@Injectable() +export class TeamsFacade { + constructor( + private readonly findTeamQ: UC.FindTeamQuery, + private readonly getInvitationQ: UC.GetInvitationQuery, + private readonly getInvitationsQ: UC.GetInvitationsQuery, + private readonly getTeamMembersQ: UC.GetTeamMembersQuery, + private readonly checkSlugQ: UC.CheckTeamSlugQuery, + + private readonly createTeamUc: UC.CreateTeamUseCase, + private readonly deleteTeamUc: UC.DeleteTeamUseCase, + private readonly updateTeamUc: UC.UpdateTeamUseCase, + private readonly syncTagsUc: UC.SyncTeamTagsUseCase, + private readonly updateAvatarUc: UC.UpdateTeamAvatarUseCase, + private readonly updateBannerUc: UC.UpdateTeamBannerUseCase, + + private readonly updateMemberUc: UC.UpdateTeamMemberUseCase, + private readonly removeMemberUc: UC.RemoveTeamMemberUseCase, + private readonly sendInviteUc: UC.SendInvitationUseCase, + private readonly acceptInviteUc: UC.AcceptInvitationUseCase, + private readonly updateInvitationUc: UC.UpdateInvitationUseCase, + private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + + private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, + private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, + ) {} + + public checkSlug = (slug: string) => this.checkSlugQ.execute(slug); + + public getTeamBySlug = (slug: string) => this.findTeamQ.execute(slug); + + public getInvitation = (slug: string, code: string, userId: string) => + this.getInvitationQ.execute(slug, code, userId); + + public createTeam = (ownerId: string, dto: CreateTeamDto) => + this.createTeamUc.execute(ownerId, dto); + + public updateTeam = (slug: string, userId: string, dto: UpdateTeamDto) => + this.updateTeamUc.execute(slug, userId, dto); + + public deleteTeam = (slug: string, userId: string) => this.deleteTeamUc.execute(slug, userId); + + public getMembers = (slug: string) => this.getTeamMembersQ.execute(slug); + + public updateMember = (slug: string, curr: string, target: string, dto: UpdateMemberDto) => + this.updateMemberUc.execute(slug, curr, target, dto); + + public removeMember = (slug: string, curr: string, target: string) => + this.removeMemberUc.execute(slug, curr, target); + + public getInvitations = (slug: string, userId?: string) => + this.getInvitationsQ.execute(slug, userId); + + public invite = (slug: string, inviterId: string, dto: InviteMemberDto) => + this.sendInviteUc.execute(slug, inviterId, dto); + + public acceptInvite = (code: string, userId: string, email: string) => + this.acceptInviteUc.execute(code, userId, email); + + public declineInvitation = (slug: string, code: string, userId: string) => + this.declineInvitationUc.execute(slug, code, userId); + + public updateInvitation = ( + slug: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => this.updateInvitationUc.execute(slug, code, userId, dto); + + public updateAvatar = (slug: string, file: FileUploadDto) => + this.updateAvatarUc.execute(slug, file); + + public updateBanner = (slug: string, file: FileUploadDto) => + this.updateBannerUc.execute(slug, file); + + public syncTags = (slug: string, tags: string[]) => this.syncTagsUc.execute(slug, tags); + + public getMyTeams = (userId: string, pagination: any) => + this.getMyTeamsUc.execute(userId, pagination); + + public getMyInvites = (email: string) => this.getMyInvitesUc.execute(email); +} diff --git a/src/teams/application/use-cases/accept-invitation.use-case.ts b/src/teams/application/use-cases/accept-invitation.use-case.ts new file mode 100644 index 0000000..46fabe2 --- /dev/null +++ b/src/teams/application/use-cases/accept-invitation.use-case.ts @@ -0,0 +1,83 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import type { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class AcceptInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(code: string, userId: string, email: string) { + const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); + if (!inviteRaw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'The invitation link has expired or is no longer valid.', + }, + HttpStatus.GONE, + ); + } + + const invite = JSON.parse(inviteRaw) as TeamInvite; + if (invite.email.toLowerCase() !== email.toLowerCase()) { + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'This invitation was sent to a different email address.', + }, + HttpStatus.FORBIDDEN, + ); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + if (member) { + if (member.status === 'banned') { + throw new BaseException( + { code: 'MEMBER_BANNED', message: 'You are banned from this team.' }, + HttpStatus.FORBIDDEN, + ); + } + if (member.status === 'active') { + await this.cleanupInvite(code, invite.teamId, email); + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'You are already a member of this team.' }, + 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(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(invite.teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + + return { success: true, message: 'Вы успешно присоединились к команде' }; + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + } +} diff --git a/src/teams/application/use-cases/check-team-slug.query.ts b/src/teams/application/use-cases/check-team-slug.query.ts new file mode 100644 index 0000000..2ad8ae8 --- /dev/null +++ b/src/teams/application/use-cases/check-team-slug.query.ts @@ -0,0 +1,21 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CheckTeamSlugQuery { + constructor(@Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository) {} + + async execute(slug: string) { + const normalizedSlug = slug.trim().toLowerCase(); + + const available = await this.teamsRepo.isSlugAvailable(normalizedSlug); + + return { + available, + message: available + ? `Slug "${normalizedSlug}" доступен для использования` + : `Slug "${normalizedSlug}" уже занят`, + details: { slug: normalizedSlug }, + }; + } +} diff --git a/src/teams/application/use-cases/create-team.use-case.ts b/src/teams/application/use-cases/create-team.use-case.ts new file mode 100644 index 0000000..d12ced0 --- /dev/null +++ b/src/teams/application/use-cases/create-team.use-case.ts @@ -0,0 +1,58 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CreateTeamDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { slugify } from 'transliteration'; + +@Injectable() +export class CreateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, dto: CreateTeamDto) { + const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); + const existingTeam = await this.teamsRepo.findBySlug(baseSlug); + + if (existingTeam) { + throw new BaseException( + { + code: 'SLUG_ALREADY_EXISTS', + message: `Ссылка "${baseSlug}" уже занята другой командой`, + details: [{ target: 'slug', value: baseSlug }], + }, + HttpStatus.CONFLICT, + ); + } + + const { tags, ...teamData } = dto; + const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; + + try { + const result = await this.teamsRepo.create( + userId, + { + ...teamData, + slug: baseSlug, + }, + uniqueTags, + ); + + return { + ...result, + slug: baseSlug, + message: 'Команда успешно создана', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/decline-invitation.use-case.ts b/src/teams/application/use-cases/decline-invitation.use-case.ts new file mode 100644 index 0000000..957ca1e --- /dev/null +++ b/src/teams/application/use-cases/decline-invitation.use-case.ts @@ -0,0 +1,88 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class DeclineInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Только администраторы могут удалять приглашения', + details: [{ userId }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const rawInvite = await this.redis.get(this.INVITES_KEY(code)); + if (!rawInvite) { + throw new BaseException( + { + code: 'INVITE_ALREADY_REMOVED', + message: 'Приглашение не найдено (возможно, оно уже было принято или удалено)', + details: [{ code }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(rawInvite) as TeamInvite; + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'ACCESS_DENIED', + message: 'Вы не можете удалить приглашение чужой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(team.id), code); + multi.srem(this.USER_INVITES_KEY(invite.email), code); + await multi.exec(); + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: 'INFRASTRUCTURE_ERROR', + message: 'Не удалось корректно удалить приглашение из системы', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Приглашение отозвано администратором', + }; + } +} diff --git a/src/teams/application/use-cases/delete-team.use-case.ts b/src/teams/application/use-cases/delete-team.use-case.ts new file mode 100644 index 0000000..b394542 --- /dev/null +++ b/src/teams/application/use-cases/delete-team.use-case.ts @@ -0,0 +1,61 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string) { + // 1. Ищем команду по слагу + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + // 2. Проверяем права (бизнес-логика удаления) + // Владелец определяется либо через ownerId в таблице команд, + // либо через роль 'owner' в таблице участников. + const member = await this.teamsRepo.findMember(team.id, userId); + const isOwner = team.ownerId === userId || member?.role === 'owner'; + + if (!isOwner) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + // 3. Выполняем удаление + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Команда успешно удалена', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/modules/teams/commands/find-member.command.ts b/src/teams/application/use-cases/find-team-member.query.ts similarity index 76% rename from src/modules/teams/commands/find-member.command.ts rename to src/teams/application/use-cases/find-team-member.query.ts index ee15c5e..ee38870 100644 --- a/src/modules/teams/commands/find-member.command.ts +++ b/src/teams/application/use-cases/find-team-member.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../domain/repository'; @Injectable() -export class FindTeamMemberCommand { +export class FindTeamMemberQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/modules/teams/commands/find-team.command.ts b/src/teams/application/use-cases/find-team.query.ts similarity index 75% rename from src/modules/teams/commands/find-team.command.ts rename to src/teams/application/use-cases/find-team.query.ts index f9d11a2..b7b7fa0 100644 --- a/src/modules/teams/commands/find-team.command.ts +++ b/src/teams/application/use-cases/find-team.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../domain/repository'; @Injectable() -export class FindTeamCommand { +export class FindTeamQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/teams/application/use-cases/get-all-tags.use-case.ts b/src/teams/application/use-cases/get-all-tags.use-case.ts new file mode 100644 index 0000000..4e7890f --- /dev/null +++ b/src/teams/application/use-cases/get-all-tags.use-case.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { FindTagsQuery } from '../dtos'; +import { ITeamsRepository } from '@core/teams/domain/repository'; + +@Injectable() +export class GetAllTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(query: FindTagsQuery) { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const offset = (safePage - 1) * safeLimit; + + const { data, total } = await this.teamsRepo.findAllTags({ + search: query.search, + limit: safeLimit, + offset, + }); + + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + + return { + data, + meta: { + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + total, + totalPages, + page: safePage, + limit: safeLimit, + }, + }; + } +} diff --git a/src/teams/application/use-cases/get-invitation.query.ts b/src/teams/application/use-cases/get-invitation.query.ts new file mode 100644 index 0000000..0abde02 --- /dev/null +++ b/src/teams/application/use-cases/get-invitation.query.ts @@ -0,0 +1,52 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationQuery { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, + HttpStatus.FORBIDDEN, + ); + } + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { code: 'INVITE_EXPIRED_OR_INVALID', message: 'Срок действия истек' }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(raw) as TeamInvite; + if (invite.teamId !== team.id) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + + return { code, ...invite }; + } +} diff --git a/src/teams/application/use-cases/get-invitations.query.ts b/src/teams/application/use-cases/get-invitations.query.ts new file mode 100644 index 0000000..5e7f50a --- /dev/null +++ b/src/teams/application/use-cases/get-invitations.query.ts @@ -0,0 +1,58 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationsQuery { + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, userId?: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + if (userId) { + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав управлять приглашениями', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); + if (!codes.length) return []; + + const keys = codes.map((c) => this.INVITES_KEY(c)); + const invitesRaw = await this.redis.mget(...keys); + + return invitesRaw + .map((raw, idx) => { + if (!raw) return null; + try { + const invite = JSON.parse(raw) as TeamInvite; + return { code: codes[idx], ...invite }; + } catch { + return null; + } + }) + .filter((v): v is TeamInvite & { code: string } => v !== null); + } +} diff --git a/src/teams/application/use-cases/get-my-invites.use-case.ts b/src/teams/application/use-cases/get-my-invites.use-case.ts new file mode 100644 index 0000000..58795c9 --- /dev/null +++ b/src/teams/application/use-cases/get-my-invites.use-case.ts @@ -0,0 +1,24 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class GetMyInvitesUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(email: string) { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + } +} diff --git a/src/teams/application/use-cases/get-my-teams.use-case.ts b/src/teams/application/use-cases/get-my-teams.use-case.ts new file mode 100644 index 0000000..e7755f3 --- /dev/null +++ b/src/teams/application/use-cases/get-my-teams.use-case.ts @@ -0,0 +1,16 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetMyTeamsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, pagination: Record) { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + } +} diff --git a/src/teams/application/use-cases/get-team-members.query.ts b/src/teams/application/use-cases/get-team-members.query.ts new file mode 100644 index 0000000..b44572f --- /dev/null +++ b/src/teams/application/use-cases/get-team-members.query.ts @@ -0,0 +1,26 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetTeamMembersQuery { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${slug} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); + } +} diff --git a/src/teams/application/use-cases/get-user-invites.use-case.ts b/src/teams/application/use-cases/get-user-invites.use-case.ts new file mode 100644 index 0000000..9937531 --- /dev/null +++ b/src/teams/application/use-cases/get-user-invites.use-case.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; + +@Injectable() +export class GetUserInvitesUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(email: string) { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + } +} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts new file mode 100644 index 0000000..d6624d6 --- /dev/null +++ b/src/teams/application/use-cases/index.ts @@ -0,0 +1,24 @@ +export { CheckTeamSlugQuery } from './check-team-slug.query'; +export { FindTeamQuery } from './find-team.query'; +export { FindTeamMemberQuery } from './find-team-member.query'; +export { GetInvitationQuery } from './get-invitation.query'; +export { GetInvitationsQuery } from './get-invitations.query'; +export { GetTeamMembersQuery } from './get-team-members.query'; + +export { AcceptInvitationUseCase } from './accept-invitation.use-case'; +export { CreateTeamUseCase } from './create-team.use-case'; +export { DeleteTeamUseCase } from './delete-team.use-case'; +export { GetAllTagsUseCase } from './get-all-tags.use-case'; +export { GetMyInvitesUseCase } from './get-my-invites.use-case'; +export { GetMyTeamsUseCase } from './get-my-teams.use-case'; +export { GetUserInvitesUseCase } from './get-user-invites.use-case'; +export { RemoveTeamMemberUseCase } from './remove-team-member.use-case'; +export { SendInvitationUseCase } from './send-invitation.use-case'; +export { SyncTeamTagsUseCase } from './sync-team-tags.use-case'; +export { UpdateTeamUseCase } from './update-team.use-case'; +export { UpdateTeamAvatarUseCase } from './update-team-avatar.use-case'; +export { UpdateTeamBannerUseCase } from './update-team-banner.use-case'; +export { UpdateTeamMemberUseCase } from './update-team-member.use-case'; + +export { UpdateInvitationUseCase } from './update-invitation.use-case'; +export { DeclineInvitationUseCase } from './decline-invitation.use-case'; diff --git a/src/teams/application/use-cases/remove-team-member.use-case.ts b/src/teams/application/use-cases/remove-team-member.use-case.ts new file mode 100644 index 0000000..835e3af --- /dev/null +++ b/src/teams/application/use-cases/remove-team-member.use-case.ts @@ -0,0 +1,82 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string) { + const team = await this.teamsRepo.findBySlug(slug); + 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 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 BaseException( + { code: 'OWNER_CANNOT_LEAVE', message: 'Владелец не может покинуть команду' }, + HttpStatus.BAD_REQUEST, + ); + } + } else { + const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; + const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; + + if (!hasAuthority || !canKick) { + throw new BaseException( + { + code: 'KICK_FORBIDDEN', + message: 'У вас недостаточно прав, чтобы исключить этого участника', + details: [ + { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, + ], + }, + HttpStatus.FORBIDDEN, + ); + } + } + + 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/teams/application/use-cases/send-invitation.use-case.ts b/src/teams/application/use-cases/send-invitation.use-case.ts new file mode 100644 index 0000000..b8e9c19 --- /dev/null +++ b/src/teams/application/use-cases/send-invitation.use-case.ts @@ -0,0 +1,91 @@ +import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { InviteMemberDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { generateSecret } from 'otplib'; +import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; + +@Injectable() +export class SendInvitationUseCase { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + ) {} + + async execute(slug: string, inviterId: string, dto: InviteMemberDto) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Team does not exist', + }, + HttpStatus.NOT_FOUND, + ); + } + + const inviter = await this.teamsRepo.findMember(team.id, inviterId); + if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Only admins or owners can invite new members', + }, + HttpStatus.FORBIDDEN, + ); + } + + // TODO AVOID DUPLICATE INVITIONS + + const code = generateSecret({ length: 8 }); + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); + + const inviteData: TeamInvite = { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email, + role: dto.role || 'member', + inviterId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const multi = this.redis.multi(); + multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); + multi.sadd(this.TEAM_INVITES_KEY(team.id), code); + multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); + await multi.exec(); + + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); + const FRONTEND_URL = origins[0]; + const event = new TeamInvitationEvent( + dto.email, + team.name, + `${FRONTEND_URL}/invites/accept?code=${code}`, + ); + + await this.mailQueue.add(TeamMailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + + return { success: true, message: `Приглашение отправлено на ${dto.email}`, code }; + } +} diff --git a/src/teams/application/use-cases/sync-team-tags.use-case.ts b/src/teams/application/use-cases/sync-team-tags.use-case.ts new file mode 100644 index 0000000..5199ad0 --- /dev/null +++ b/src/teams/application/use-cases/sync-team-tags.use-case.ts @@ -0,0 +1,45 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SyncTeamTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, tags: string[]) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + const normalizedTags = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; + + const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); + + if (!isSynced) { + throw new BaseException( + { + code: 'TAGS_SYNC_FAILED', + message: 'Не удалось обновить теги команды', + details: [{ target: 'tags', count: normalizedTags.length }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Теги команды успешно обновлены', + }; + } +} diff --git a/src/teams/application/use-cases/update-invitation.use-case.ts b/src/teams/application/use-cases/update-invitation.use-case.ts new file mode 100644 index 0000000..c15f451 --- /dev/null +++ b/src/teams/application/use-cases/update-invitation.use-case.ts @@ -0,0 +1,80 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { UpdateInvitationDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class UpdateInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string, dto: UpdateInvitationDto) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав на редактирование приглашений в этой команде', + details: [{ requiredRoles: ['owner', 'admin'], currentRole: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const key = this.INVITES_KEY(code); + const [rawInvite, ttl] = await Promise.all([this.redis.get(key), this.redis.ttl(key)]); + + if (!rawInvite || ttl <= 0) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND_OR_EXPIRED', + message: 'Приглашение не найдено или его срок действия уже истек', + details: [{ code }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(rawInvite) as TeamInvite; + + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_TEAM_MISMATCH', + message: 'Это приглашение принадлежит другой команде', + details: [{ inviteTeamId: invite.teamId, requestTeamId: team.id }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + invite.role = dto.role; + + if (ttl > 0) { + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + } else { + await this.redis.set(key, JSON.stringify(invite)); + } + + return { + success: true, + message: 'Приглашение успешно обновлено', + details: { code, role: invite.role, email: invite.email }, + }; + } +} diff --git a/src/teams/application/use-cases/update-team-avatar.use-case.ts b/src/teams/application/use-cases/update-team-avatar.use-case.ts new file mode 100644 index 0000000..d1cf2f3 --- /dev/null +++ b/src/teams/application/use-cases/update-team-avatar.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamAvatarUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(slug: string, fileDto: FileUploadDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => + this.teamsRepo.updateTeamAvatar(team.id, url), + ); + } +} diff --git a/src/teams/application/use-cases/update-team-banner.use-case.ts b/src/teams/application/use-cases/update-team-banner.use-case.ts new file mode 100644 index 0000000..6bf4a52 --- /dev/null +++ b/src/teams/application/use-cases/update-team-banner.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamBannerUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(slug: string, fileDto: FileUploadDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => + this.teamsRepo.updateTeamBanner(team.id, url), + ); + } +} diff --git a/src/teams/application/use-cases/update-team-member.use-case.ts b/src/teams/application/use-cases/update-team-member.use-case.ts new file mode 100644 index 0000000..9ba21d4 --- /dev/null +++ b/src/teams/application/use-cases/update-team-member.use-case.ts @@ -0,0 +1,100 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { UpdateMemberDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string, dto: UpdateMemberDto) { + const team = await this.teamsRepo.findBySlug(slug); + 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 BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + // 1. Проверка минимальной роли для редактирования + if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { + throw new BaseException( + { + code: 'ADMIN_ROLE_REQUIRED', + message: 'У вас нет прав на редактирование участников', + }, + HttpStatus.FORBIDDEN, + ); + } + + // 2. Нельзя менять роль тому, кто выше или равен по весу + if ( + currentUserId !== targetUserId && + ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] + ) { + throw new BaseException( + { + code: 'INSUFFICIENT_RANK', + message: 'Вы не можете менять данные участника с равным или высшим рангом', + details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + // 3. Защита Owner + if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { + throw new BaseException( + { + code: 'OWNER_PROTECTION_VIOLATION', + message: 'Нельзя изменить роль владельца через это меню', + }, + HttpStatus.BAD_REQUEST, + ); + } + + // 4. Нельзя назначить роль выше своей + if ( + dto.role && + ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && + currentUser.role !== 'owner' + ) { + throw new BaseException( + { + code: 'CANNOT_ASSIGN_HIGHER_ROLE', + message: 'Вы не можете назначить роль выше своей или равную своей', + }, + HttpStatus.FORBIDDEN, + ); + } + + 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, + ); + } + } +} diff --git a/src/teams/application/use-cases/update-team.use-case.ts b/src/teams/application/use-cases/update-team.use-case.ts new file mode 100644 index 0000000..7525c5a --- /dev/null +++ b/src/teams/application/use-cases/update-team.use-case.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { ITeamsRepository } from '../../domain/repository'; +import type { UpdateTeamDto } from '../dtos'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string, dto: UpdateTeamDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const { tags, ...data } = dto; + + try { + const result = await this.teamsRepo.update(team.id, data, tags); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts new file mode 100644 index 0000000..40d100b --- /dev/null +++ b/src/teams/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './teams.domain'; diff --git a/src/modules/teams/entities/teams.domain.ts b/src/teams/domain/entities/teams.domain.ts similarity index 87% rename from src/modules/teams/entities/teams.domain.ts rename to src/teams/domain/entities/teams.domain.ts index 75c044b..9ffee16 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/teams/domain/entities/teams.domain.ts @@ -1,5 +1,5 @@ import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -import { teams, teamMembers, tags, teamsToTags } from './teams.entity'; +import { teams, teamMembers, tags, teamsToTags } from '../../infrastructure/persistence/models'; export type Team = InferSelectModel; export type NewTeam = InferInsertModel; diff --git a/src/teams/domain/enums/index.ts b/src/teams/domain/enums/index.ts new file mode 100644 index 0000000..4a72780 --- /dev/null +++ b/src/teams/domain/enums/index.ts @@ -0,0 +1 @@ +export { TeamMailJobs, TeamQueues } from './mail-jobs.enum'; diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/teams/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..a46d334 --- /dev/null +++ b/src/teams/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,7 @@ +export enum TeamQueues { + TEAM_MAIL = 'TEAM_MAIL_QUEUE', +} + +export enum TeamMailJobs { + SEND_TEAM_INVITATION = 'TEAM_SEND_TEAM_INVITATION', +} diff --git a/src/shared/workers/events/index.ts b/src/teams/domain/events/index.ts similarity index 100% rename from src/shared/workers/events/index.ts rename to src/teams/domain/events/index.ts diff --git a/src/shared/workers/events/team-invitation.event.ts b/src/teams/domain/events/team-invitation.event.ts similarity index 100% rename from src/shared/workers/events/team-invitation.event.ts rename to src/teams/domain/events/team-invitation.event.ts diff --git a/src/modules/teams/repository/index.ts b/src/teams/domain/repository/index.ts similarity index 68% rename from src/modules/teams/repository/index.ts rename to src/teams/domain/repository/index.ts index f78a0c8..0d97b36 100644 --- a/src/modules/teams/repository/index.ts +++ b/src/teams/domain/repository/index.ts @@ -1,4 +1,3 @@ -export { TeamsRepository } from './teams.repository'; export { ITeamsRepository, type RawMemberRow, diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts similarity index 100% rename from src/modules/teams/repository/teams.repository.interface.ts rename to src/teams/domain/repository/teams.repository.interface.ts diff --git a/src/teams/index.ts b/src/teams/index.ts new file mode 100644 index 0000000..f4d6e9c --- /dev/null +++ b/src/teams/index.ts @@ -0,0 +1,2 @@ +export { TeamsModule } from './teams.module'; +export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/modules/teams/entities/enums.ts b/src/teams/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/teams/entities/enums.ts rename to src/teams/infrastructure/persistence/models/enums.ts diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..f97c6e3 --- /dev/null +++ b/src/teams/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { tags, teamMembers, teams, teamsToTags } from './teams.model'; +export { type TeamRole, roleEnum, statusEnum } from './enums'; diff --git a/src/modules/teams/entities/teams.entity.ts b/src/teams/infrastructure/persistence/models/teams.model.ts similarity index 100% rename from src/modules/teams/entities/teams.entity.ts rename to src/teams/infrastructure/persistence/models/teams.model.ts diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..259ca0a --- /dev/null +++ b/src/teams/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamsRepository } from './teams.repository'; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts similarity index 93% rename from src/modules/teams/repository/teams.repository.ts rename to src/teams/infrastructure/persistence/repositories/teams.repository.ts index 59f078b..b4e4294 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -1,13 +1,12 @@ -import { Inject, Logger } from '@nestjs/common'; -import { ITeamsRepository } from './teams.repository.interface'; +import { Inject } from '@nestjs/common'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import * as schema from '../entities'; +import * as schema from '../models'; import * as scUsers from '@core/user/infrastructure/persistence/models'; import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; +import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; +import { ITeamsRepository } from '@core/teams/domain/repository'; export class TeamsRepository implements ITeamsRepository { - private logger = new Logger(TeamsRepository.name); - constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, @@ -22,7 +21,7 @@ export class TeamsRepository implements ITeamsRepository { return result.length === 0; }; - public addMember = async (dto: schema.NewTeamMember) => { + public addMember = async (dto: NewTeamMember) => { const { rowCount } = await this.db .insert(schema.teamMembers) .values(dto) @@ -33,7 +32,7 @@ export class TeamsRepository implements ITeamsRepository { return (rowCount ?? 0) > 0; }; - public create = async (ownerId: string, dto: schema.NewTeam, tags?: string[]) => { + public create = async (ownerId: string, dto: NewTeam, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .insert(schema.teams) @@ -80,7 +79,7 @@ export class TeamsRepository implements ITeamsRepository { }); }; - public update = async (id: string, dto: Partial, tags?: string[]) => { + public update = async (id: string, dto: Partial, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .update(schema.teams) @@ -231,11 +230,7 @@ export class TeamsRepository implements ITeamsRepository { return true; }; - public updateMember = async ( - teamId: string, - userId: string, - dto: Partial, - ) => { + public updateMember = async (teamId: string, userId: string, dto: Partial) => { const { role, status } = dto; const data = { diff --git a/src/teams/infrastructure/workers/index.ts b/src/teams/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/teams/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/shared/workers/mail/worker.ts b/src/teams/infrastructure/workers/mail.processor.ts similarity index 70% rename from src/shared/workers/mail/worker.ts rename to src/teams/infrastructure/workers/mail.processor.ts index 3fe4d34..34320b9 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/teams/infrastructure/workers/mail.processor.ts @@ -1,11 +1,11 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import { TeamInvitationEvent } from '../events'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamQueues } from '@core/teams/domain/enums'; -@Processor(Queues.MAIL) +@Processor(TeamQueues.TEAM_MAIL) export class MailProcessor extends WorkerHost { constructor( @Inject('IMailPort') @@ -14,19 +14,11 @@ export class MailProcessor extends WorkerHost { super(); } - async process(job: Job): Promise; - async process(job: Job): Promise { + async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { - switch (job.name) { - case MailJobs.SEND_TEAM_INVITATION: - await this.sendTeamInvitation(job); - break; - default: - await job.log(`[WRN] No handler for job: ${job.name}`); - await job.updateProgress(100); - } + await this.sendTeamInvitation(job); await job.log(`[DONE] Job ${job.id} processed`); } catch (error) { @@ -34,6 +26,7 @@ export class MailProcessor extends WorkerHost { const errorStack = error instanceof Error ? error.stack : ''; await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { await job.log(errorStack); } diff --git a/src/modules/teams/teams.module.ts b/src/teams/teams.module.ts similarity index 57% rename from src/modules/teams/teams.module.ts rename to src/teams/teams.module.ts index 708f2b6..1e81ba6 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -5,26 +5,51 @@ import { TeamsMembersController, TeamsController, MeController, -} from './controller'; -import { MediaModule } from '../media'; -import { - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, -} from './services'; -import { TeamsRepository } from './repository'; +} from './application/controller'; import { RedisModule } from '@nestjs-modules/ioredis'; import { ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; -import { FindTeamCommand, FindTeamMemberCommand } from './commands'; +import { TeamsRepository } from './infrastructure/persistence/repositories'; +import { TeamQueues } from './domain/enums'; +import { MediaModule } from '@core/modules/media'; +import { TeamsFacade } from './application/team.facade'; + +import * as UC from './application/use-cases'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; +const QUERIES = [ + UC.FindTeamQuery, + UC.FindTeamMemberQuery, + UC.GetInvitationQuery, + UC.GetInvitationsQuery, + UC.GetTeamMembersQuery, + UC.GetMyInvitesUseCase, + UC.GetMyTeamsUseCase, + UC.GetUserInvitesUseCase, + UC.GetAllTagsUseCase, + UC.CheckTeamSlugQuery, +]; + +const USE_CASES = [ + UC.CreateTeamUseCase, + UC.DeleteTeamUseCase, + UC.UpdateTeamUseCase, + UC.UpdateTeamAvatarUseCase, + UC.UpdateTeamBannerUseCase, + UC.SyncTeamTagsUseCase, + UC.UpdateTeamMemberUseCase, + UC.RemoveTeamMemberUseCase, + UC.SendInvitationUseCase, + UC.AcceptInvitationUseCase, + UC.UpdateInvitationUseCase, + UC.DeclineInvitationUseCase, +]; + +const EXTERNAL_USE_CASES = [UC.FindTeamMemberQuery, UC.FindTeamQuery]; + @Module({ imports: [ MediaModule, @@ -50,10 +75,10 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, adapter: BullMQAdapter, }), ], @@ -64,16 +89,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [ - REPOSITORY, - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, - FindTeamCommand, - FindTeamMemberCommand, - ], - exports: [FindTeamCommand, FindTeamMemberCommand], + providers: [REPOSITORY, ...USE_CASES, ...QUERIES, TeamsFacade], + exports: [...EXTERNAL_USE_CASES], }) export class TeamsModule {} From dda0d55bcf2c63000a0d7d549945528bb7cbc8a5 Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:09:01 +0300 Subject: [PATCH 5/9] feat(projects): implement access validation policy and refactor to DDD hexagon (#53) --- src/app.module.ts | 2 +- .../projects/commands/find-project.command.ts | 90 ----- src/modules/projects/commands/index.ts | 1 - src/modules/projects/controller/index.ts | 1 - src/modules/projects/entities/index.ts | 3 - src/modules/projects/projects.module.ts | 19 - src/modules/projects/services/index.ts | 1 - .../projects/services/projects.service.ts | 327 ------------------ src/projects/application/controller/index.ts | 1 + .../controller/projects/controller.ts} | 16 +- .../controller/projects/swagger.ts} | 2 +- .../application}/dtos/index.ts | 0 .../application}/dtos/projects.dto.ts | 2 +- .../application}/mappers/index.ts | 0 .../application}/mappers/projects.mapper.ts | 2 +- src/projects/application/projects.facade.ts | 58 ++++ .../use-cases/create-project.use-case.ts | 34 ++ .../use-cases/delete-project.use-case.ts | 35 ++ .../use-cases/find-project.query.ts | 116 +++++++ .../use-cases/find-projects-by-team.query.ts | 31 ++ .../generate-share-token.use-case.ts | 82 +++++ .../use-cases/get-project-detail.query.ts | 20 ++ src/projects/application/use-cases/index.ts | 27 ++ .../use-cases/set-project-status.use-case.ts | 41 +++ .../use-cases/update-project.use-case.ts | 43 +++ .../domain}/entities/entities.domain.ts | 2 +- src/projects/domain/entities/index.ts | 1 + src/projects/domain/policy/index.ts | 5 + .../domain/policy/project-access.policy.ts | 78 +++++ .../domain}/repository/index.ts | 1 - .../projects.repository.interface.ts | 0 src/{modules => }/projects/index.ts | 0 .../persistence/models}/enums.ts | 0 .../persistence/models/index.ts | 2 + .../persistence/models/projects.model.ts} | 0 .../persistence/repositories/index.ts | 2 + .../repositories}/projects.repository.ts | 11 +- src/projects/projects.module.ts | 20 ++ src/shared/entities/index.ts | 2 +- 39 files changed, 616 insertions(+), 462 deletions(-) delete mode 100644 src/modules/projects/commands/find-project.command.ts delete mode 100644 src/modules/projects/commands/index.ts delete mode 100644 src/modules/projects/controller/index.ts delete mode 100644 src/modules/projects/entities/index.ts delete mode 100644 src/modules/projects/projects.module.ts delete mode 100644 src/modules/projects/services/index.ts delete mode 100644 src/modules/projects/services/projects.service.ts create mode 100644 src/projects/application/controller/index.ts rename src/{modules/projects/controller/projects.controller.ts => projects/application/controller/projects/controller.ts} (84%) rename src/{modules/projects/controller/projects.swagger.ts => projects/application/controller/projects/swagger.ts} (99%) rename src/{modules/projects => projects/application}/dtos/index.ts (100%) rename src/{modules/projects => projects/application}/dtos/projects.dto.ts (97%) rename src/{modules/projects => projects/application}/mappers/index.ts (100%) rename src/{modules/projects => projects/application}/mappers/projects.mapper.ts (96%) create mode 100644 src/projects/application/projects.facade.ts create mode 100644 src/projects/application/use-cases/create-project.use-case.ts create mode 100644 src/projects/application/use-cases/delete-project.use-case.ts create mode 100644 src/projects/application/use-cases/find-project.query.ts create mode 100644 src/projects/application/use-cases/find-projects-by-team.query.ts create mode 100644 src/projects/application/use-cases/generate-share-token.use-case.ts create mode 100644 src/projects/application/use-cases/get-project-detail.query.ts create mode 100644 src/projects/application/use-cases/index.ts create mode 100644 src/projects/application/use-cases/set-project-status.use-case.ts create mode 100644 src/projects/application/use-cases/update-project.use-case.ts rename src/{modules/projects => projects/domain}/entities/entities.domain.ts (88%) create mode 100644 src/projects/domain/entities/index.ts create mode 100644 src/projects/domain/policy/index.ts create mode 100644 src/projects/domain/policy/project-access.policy.ts rename src/{modules/projects => projects/domain}/repository/index.ts (54%) rename src/{modules/projects => projects/domain}/repository/projects.repository.interface.ts (100%) rename src/{modules => }/projects/index.ts (100%) rename src/{modules/projects/entities => projects/infrastructure/persistence/models}/enums.ts (100%) create mode 100644 src/projects/infrastructure/persistence/models/index.ts rename src/{modules/projects/entities/projects.entity.ts => projects/infrastructure/persistence/models/projects.model.ts} (100%) create mode 100644 src/projects/infrastructure/persistence/repositories/index.ts rename src/{modules/projects/repository => projects/infrastructure/persistence/repositories}/projects.repository.ts (89%) create mode 100644 src/projects/projects.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 0d82338..a73784d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,7 +16,7 @@ import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; import { TeamsModule } from './teams'; -import { ProjectsModule } from './modules/projects'; +import { ProjectsModule } from './projects'; @Module({ imports: [ diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts deleted file mode 100644 index a1d358b..0000000 --- a/src/modules/projects/commands/find-project.command.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; -import { FindTeamMemberQuery } from '@core/teams'; -import { createHash } from 'crypto'; -import type { Project } from '../entities'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class FindProjectCommand { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly findTeamMemberQ: FindTeamMemberQuery, - ) {} - - public async execute(projectId: string, userId?: string, shareToken?: string) { - const project = await this.projectsRepo.findOne(projectId); - - if (!project) { - throw new BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден или доступ ограничен', - details: [{ target: 'projectId', value: projectId }], - }, - HttpStatus.NOT_FOUND, - ); - } - - if (shareToken) { - return this.findPublic(project, shareToken); - } - - return this.findPrivate(project, userId); - } - - private findPrivate = async (project: Project, userId?: string) => { - if (!userId) { - throw new BaseException( - { - code: 'AUTH_REQUIRED', - message: 'Для доступа к приватному проекту нужна авторизация', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const member = await this.findTeamMemberQ.execute(project.teamId, userId); - - if (!member) { - throw new BaseException( - { - code: 'ACCESS_DENIED', - message: 'У вас нет прав для просмотра этого проекта', - details: [{ target: 'teamId', value: project.teamId }], - }, - HttpStatus.FORBIDDEN, - ); - } - - return { project, member }; - }; - - private findPublic = async (project: Project, token: string) => { - if (project.visibility !== 'public') { - 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 BaseException( - { - code: 'SHARE_LINK_INVALID', - message: 'Ссылка недействительна или срок её действия истек', - }, - HttpStatus.GONE, - ); - } - - return { project, member: null }; - }; -} diff --git a/src/modules/projects/commands/index.ts b/src/modules/projects/commands/index.ts deleted file mode 100644 index d79925b..0000000 --- a/src/modules/projects/commands/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FindProjectCommand } from './find-project.command'; diff --git a/src/modules/projects/controller/index.ts b/src/modules/projects/controller/index.ts deleted file mode 100644 index 19a0d95..0000000 --- a/src/modules/projects/controller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsController } from './projects.controller'; diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts deleted file mode 100644 index 4dd5b24..0000000 --- a/src/modules/projects/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { projects, projectShares } from './projects.entity'; -export { projectStatusEnum, projectVisibilityEnum } from './enums'; -export * from './entities.domain'; diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts deleted file mode 100644 index fb78d1b..0000000 --- a/src/modules/projects/projects.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { ProjectsService } from './services'; -import { ProjectsController } from './controller'; -import { ProjectsRepository } from './repository'; -import { TeamsModule } from '../../teams'; -import { FindProjectCommand } from './commands'; - -const REPOSITORY = { - provide: 'IProjectsRepository', - useClass: ProjectsRepository, -}; - -@Module({ - imports: [forwardRef(() => TeamsModule)], - controllers: [ProjectsController], - providers: [REPOSITORY, FindProjectCommand, ProjectsService], - exports: [FindProjectCommand], -}) -export class ProjectsModule {} diff --git a/src/modules/projects/services/index.ts b/src/modules/projects/services/index.ts deleted file mode 100644 index e46b58b..0000000 --- a/src/modules/projects/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsService } from './projects.service'; diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts deleted file mode 100644 index f15f6ce..0000000 --- a/src/modules/projects/services/projects.service.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { IProjectsRepository } from '../repository'; -import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { ROLE_PRIORITY } from '@shared/constants'; -import { ProjectStatus } from '../entities'; -import { ProjectsMapper } from '../mappers'; -import { createHash, randomBytes } from 'crypto'; -import { BaseException } from '@shared/error'; -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; - -@Injectable() -export class ProjectsService { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly findTeamQ: FindTeamQuery, - private readonly findTeamMemberQ: FindTeamMemberQuery, - ) {} - - public create = async (userId: string, slug: string, dto: CreateProjectDto) => { - const { team } = await this.ensureTeamAccess(slug, userId, 'admin'); - - const data = { - ...dto, - teamId: team.id, - ownerId: userId, - key: dto.key.toUpperCase(), - status: ProjectStatus.Active, - }; - - const { result, id } = await this.projectsRepo.create(data); - - return { - success: result, - message: `Проект ${dto.name} успешно создан`, - projectId: id, - }; - }; - - public generateToken = async ( - id: string, - slug: string, - userId: string, - dto: CreateShareTokenDto, - ) => { - const project = await this.validateAccess(id, slug, userId); - - let expiresAt: Date; - - if (dto.ttl) { - expiresAt = new Date(dto.ttl); - - if (expiresAt <= new Date()) { - throw new BaseException( - { - code: 'INVALID_EXPIRATION', - message: 'Дата истечения не может быть в прошлом', - details: [ - { target: 'ttl', message: 'Expiration date is behind current time' }, - ], - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - expiresAt = new Date(); - expiresAt.setMonth(expiresAt.getMonth() + 3); - } - - const rawToken = this.generateSecureToken(); - - const isSaved = await this.projectsRepo.createShare({ - projectId: project.id, - token: this.hash(rawToken), - expiresAt, - createdBy: userId, - }); - - if (!isSaved) { - throw new BaseException( - { - code: 'SHARE_CREATE_FAILED', - message: 'Не удалось сгенерировать ссылку доступа', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const durationMsg = dto.ttl - ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` - : 'бессрочна (на 3 месяца по умолчанию)'; - - return { - success: true, - message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, - payload: { - token: rawToken, - isYourself: !!dto, - expiresAt: expiresAt.toISOString(), - }, - }; - }; - - public delete = async (id: string, slug: string, userId: string) => { - 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 - ? `Проект ${project.name} успешно перемещен в корзину` - : 'Не удалось удалить проект, попробуйте позже', - }; - }; - - public update = async (id: string, slug: string, userId: string, dto: UpdateProjectDto) => { - const project = await this.validateAccess(id, slug, userId); - const { isPublic, key, ...data } = dto; - - const result = await this.projectsRepo.update(project.id, { - ...data, - ...(key && { key: key.toUpperCase() }), - ...(typeof isPublic === 'boolean' && { - visibility: isPublic ? 'public' : 'private', - }), - }); - - if (!result) { - throw new BaseException( - { - code: 'UPDATE_FAILED', - message: - 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', - }, - HttpStatus.BAD_REQUEST, - ); - } - - return { - success: result, - message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', - }; - }; - - public findOne = async (id: string, slug: string, userId: string, token: string) => { - const project = await this.projectsRepo.findOne(id); - - if (!project) { - throw new BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (token) { - const hashedToken = this.hash(token); - const isValidAccess = await this.projectsRepo.hasValidShareToken( - project.id, - hashedToken, - ); - - if (!isValidAccess) { - throw new BaseException( - { - code: 'INVALID_TOKEN', - message: 'Ссылка недействительна или срок её действия истек', - }, - HttpStatus.GONE, - ); - } - - return ProjectsMapper.toDetailResponse(project, null, token); - } - - if (!userId) { - throw new BaseException( - { code: 'AUTH_REQUIRED', message: 'Требуется авторизация' }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { member, team } = await this.ensureTeamAccess(slug, userId, 'viewer'); - - 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, member } = await this.ensureTeamAccess(slug, userId, 'viewer'); - const projects = await this.projectsRepo.findByTeam(team.id); - - return { - team: { - id: team.id, - name: team.name, - slug: team.slug, - role: member.role, - }, - items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), - meta: { - total: projects.length, - }, - }; - }; - - public setStatus = async (id: string, slug: string, userId: string, status: ProjectStatus) => { - 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}» теперь активен`, - template: `Проект «${project.name}» успешно сохранен как шаблон`, - }; - - return { - success: result, - message: messages[status] || `Статус проекта «${project.name}» изменен`, - }; - }; - - private async ensureTeamAccess( - slug: string, - userId: string, - minRole: keyof typeof ROLE_PRIORITY = 'viewer', - ) { - const team = await this.findTeamQ.execute(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.findTeamMemberQ.execute(team.id, userId); - 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 BaseException( - { - code: 'PROJECT_NOT_FOUND', - message: 'Проект не найден в этой команде', - }, - HttpStatus.NOT_FOUND, - ); - } - - return project; - } - - private generateSecureToken(): string { - return `st_${randomBytes(32).toString('hex')}`; - } - - private hash(token: string): string { - return createHash('sha256').update(token).digest('hex'); - } -} diff --git a/src/projects/application/controller/index.ts b/src/projects/application/controller/index.ts new file mode 100644 index 0000000..4c96d63 --- /dev/null +++ b/src/projects/application/controller/index.ts @@ -0,0 +1 @@ +export { ProjectsController } from './projects/controller'; diff --git a/src/modules/projects/controller/projects.controller.ts b/src/projects/application/controller/projects/controller.ts similarity index 84% rename from src/modules/projects/controller/projects.controller.ts rename to src/projects/application/controller/projects/controller.ts index c3e41ba..390a31f 100644 --- a/src/modules/projects/controller/projects.controller.ts +++ b/src/projects/application/controller/projects/controller.ts @@ -1,5 +1,4 @@ import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; -import { ProjectsService } from '../services'; import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ArchiveProjectSwagger, @@ -9,18 +8,19 @@ import { FindOneProjectSwagger, RemoveProjectSwagger, UpdateProjectSwagger, -} from './projects.swagger'; -import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { ProjectStatus } from '../entities'; +} from './swagger'; +import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectsFacade } from '../../projects.facade'; @ApiBaseController('teams/:slug/projects', 'Team Projects', true) export class ProjectsController { - constructor(private readonly facade: ProjectsService) {} + constructor(private readonly facade: ProjectsFacade) {} @Get() @FindAllProjectsSwagger() async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.findByTeam(slug, userId); + return this.facade.getTeamProjects(slug, userId); } @Get(':id') @@ -32,7 +32,7 @@ export class ProjectsController { @GetUserId() userId?: string, @Query('token') token?: string, ) { - return this.facade.findOne(id, slug, userId, token); + return this.facade.getDetail(id, slug, userId, token); } @Post(':id/share') @@ -43,7 +43,7 @@ export class ProjectsController { @GetUserId() userId: string, @Body() dto: CreateShareTokenDto, ) { - return this.facade.generateToken(id, slug, userId, dto); + return this.facade.generateShareToken(id, slug, userId, dto); } @Post(':id/archive') diff --git a/src/modules/projects/controller/projects.swagger.ts b/src/projects/application/controller/projects/swagger.ts similarity index 99% rename from src/modules/projects/controller/projects.swagger.ts rename to src/projects/application/controller/projects/swagger.ts index 09f184c..55e1e1a 100644 --- a/src/modules/projects/controller/projects.swagger.ts +++ b/src/projects/application/controller/projects/swagger.ts @@ -7,7 +7,7 @@ import { CreateProjectResponse, CreateShareTokenDto, UpdateProjectDto, -} from '../dtos'; +} from '../../dtos'; export const CreateProjectSwagger = () => applyDecorators( diff --git a/src/modules/projects/dtos/index.ts b/src/projects/application/dtos/index.ts similarity index 100% rename from src/modules/projects/dtos/index.ts rename to src/projects/application/dtos/index.ts diff --git a/src/modules/projects/dtos/projects.dto.ts b/src/projects/application/dtos/projects.dto.ts similarity index 97% rename from src/modules/projects/dtos/projects.dto.ts rename to src/projects/application/dtos/projects.dto.ts index 042444f..82375e8 100644 --- a/src/modules/projects/dtos/projects.dto.ts +++ b/src/projects/application/dtos/projects.dto.ts @@ -1,7 +1,7 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { ProjectStatus } from '../entities'; import { ActionResponseSchema } from '@shared/dtos'; +import { ProjectStatus } from '@core/projects/domain/entities'; export const CreateProjectSchema = z.object({ name: z diff --git a/src/modules/projects/mappers/index.ts b/src/projects/application/mappers/index.ts similarity index 100% rename from src/modules/projects/mappers/index.ts rename to src/projects/application/mappers/index.ts diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/projects/application/mappers/projects.mapper.ts similarity index 96% rename from src/modules/projects/mappers/projects.mapper.ts rename to src/projects/application/mappers/projects.mapper.ts index 708c1a2..4aa1a12 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/projects/application/mappers/projects.mapper.ts @@ -1,6 +1,6 @@ -import type { Project } from '@shared/entities'; import { ROLE_PRIORITY } from '@shared/constants'; import { RawMemberRow } from '@core/teams/domain/repository'; +import { Project } from '@core/projects/domain/entities'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/projects/application/projects.facade.ts b/src/projects/application/projects.facade.ts new file mode 100644 index 0000000..3fb6dc9 --- /dev/null +++ b/src/projects/application/projects.facade.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectStatus } from '../domain/entities'; +import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from './dtos'; +import { + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, + FindProjectsByTeamQuery, + GetProjectDetailQuery, +} from './use-cases'; + +@Injectable() +export class ProjectsFacade { + constructor( + private readonly createProjectUC: CreateProjectUseCase, + private readonly updateProjectUC: UpdateProjectUseCase, + private readonly deleteProjectUC: DeleteProjectUseCase, + private readonly setStatusUC: SetProjectStatusUseCase, + private readonly generateTokenUC: GenerateShareTokenUseCase, + private readonly getDetailQ: GetProjectDetailQuery, + private readonly findByTeamQ: FindProjectsByTeamQuery, + ) {} + + public async create(userId: string, slug: string, dto: CreateProjectDto) { + return this.createProjectUC.execute(userId, slug, dto); + } + + public async update(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + return this.updateProjectUC.execute(id, slug, userId, dto); + } + + public async delete(id: string, slug: string, userId: string) { + return this.deleteProjectUC.execute(id, slug, userId); + } + + public async setStatus(id: string, slug: string, userId: string, status: ProjectStatus) { + return this.setStatusUC.execute(id, slug, userId, status); + } + + public async generateShareToken( + id: string, + slug: string, + userId: string, + dto: CreateShareTokenDto, + ) { + return this.generateTokenUC.execute(id, slug, userId, dto); + } + + public async getDetail(id: string, slug: string, userId?: string, token?: string) { + return this.getDetailQ.execute(id, slug, userId, token); + } + + public async getTeamProjects(slug: string, userId: string) { + return this.findByTeamQ.execute(slug, userId); + } +} diff --git a/src/projects/application/use-cases/create-project.use-case.ts b/src/projects/application/use-cases/create-project.use-case.ts new file mode 100644 index 0000000..a2cc6de --- /dev/null +++ b/src/projects/application/use-cases/create-project.use-case.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { CreateProjectDto } from '../dtos'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class CreateProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(userId: string, slug: string, dto: CreateProjectDto) { + const { team } = await this.policy.ensureTeamAccess(slug, userId, 'admin'); + + const data = { + ...dto, + teamId: team.id, + ownerId: userId, + key: dto.key.toUpperCase(), + status: ProjectStatus.Active, + }; + + const { result, id } = await this.projectsRepo.create(data); + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + projectId: id, + }; + } +} diff --git a/src/projects/application/use-cases/delete-project.use-case.ts b/src/projects/application/use-cases/delete-project.use-case.ts new file mode 100644 index 0000000..b5d3e71 --- /dev/null +++ b/src/projects/application/use-cases/delete-project.use-case.ts @@ -0,0 +1,35 @@ +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string) { + const { project } = await this.policy.validateProjectAccess(id, slug, userId, 'admin'); + const result = await this.projectsRepo.delete(project.id); + + if (!result) { + throw new BaseException( + { + code: 'DELETE_FAILED', + message: 'Не удалось удалить проект', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return { + success: true, + message: result + ? `Проект ${project.name} успешно перемещен в корзину` + : 'Не удалось удалить проект, попробуйте позже', + }; + } +} diff --git a/src/projects/application/use-cases/find-project.query.ts b/src/projects/application/use-cases/find-project.query.ts new file mode 100644 index 0000000..c5b41d9 --- /dev/null +++ b/src/projects/application/use-cases/find-project.query.ts @@ -0,0 +1,116 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { createHash } from 'crypto'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import type { Project } from '@core/projects/domain/entities'; + +@Injectable() +export class FindProjectQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Точка входа для получения проекта с проверкой прав. + */ + public async execute( + projectId: string, + slug: string, + userId?: string, + shareToken?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const project = await this.projectsRepo.findOne(projectId); + + if (!project) { + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден', + details: [{ target: 'projectId', value: projectId }], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (shareToken) { + return this.findPublic(project, shareToken); + } + + return this.findPrivate(project, slug, userId, minRole); + } + + private findPrivate = async ( + project: Project, + slug: string, + userId?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) => { + if (!userId) { + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Требуется авторизация для доступа к приватному проекту', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const team = await this.findTeamQ.execute(slug); + if (!team || team.id !== project.teamId) { + throw new BaseException( + { + code: 'PROJECT_TEAM_MISMATCH', + message: 'Проект не принадлежит указанной команде', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этой команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Для этого действия необходимы права: ${minRole}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member, team }; + }; + + private findPublic = async (project: Project, token: string) => { + if (project.visibility !== 'public') { + 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 BaseException( + { code: 'SHARE_LINK_INVALID', message: 'Ссылка недействительна или истекла' }, + HttpStatus.GONE, + ); + } + + return { project, member: null, team: null }; + }; +} diff --git a/src/projects/application/use-cases/find-projects-by-team.query.ts b/src/projects/application/use-cases/find-projects-by-team.query.ts new file mode 100644 index 0000000..7229508 --- /dev/null +++ b/src/projects/application/use-cases/find-projects-by-team.query.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class FindProjectsByTeamQuery { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, userId: string) { + const { team, member } = await this.policy.ensureTeamAccess(slug, userId, 'viewer'); + const projects = await this.projectsRepo.findByTeam(team.id); + + return { + team: { + id: team.id, + name: team.name, + slug: team.slug, + role: member.role, + }, + items: projects.map((p) => ProjectsMapper.toListResponse(p, member)), + meta: { + total: projects.length, + }, + }; + } +} diff --git a/src/projects/application/use-cases/generate-share-token.use-case.ts b/src/projects/application/use-cases/generate-share-token.use-case.ts new file mode 100644 index 0000000..deb68da --- /dev/null +++ b/src/projects/application/use-cases/generate-share-token.use-case.ts @@ -0,0 +1,82 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { CreateShareTokenDto } from '../dtos'; +import { createHash, randomBytes } from 'crypto'; +import { BaseException } from '@shared/error'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; + +@Injectable() +export class GenerateShareTokenUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, dto: CreateShareTokenDto) { + const { project } = await this.policy.validateProjectAccess(id, slug, userId); + + let expiresAt: Date; + + if (dto.ttl) { + expiresAt = new Date(dto.ttl); + + if (expiresAt <= new Date()) { + throw new BaseException( + { + code: 'INVALID_EXPIRATION', + message: 'Дата истечения не может быть в прошлом', + details: [ + { target: 'ttl', message: 'Expiration date is behind current time' }, + ], + }, + HttpStatus.BAD_REQUEST, + ); + } + } else { + expiresAt = new Date(); + expiresAt.setMonth(expiresAt.getMonth() + 3); + } + + const rawToken = this.generateSecureToken(); + + const isSaved = await this.projectsRepo.createShare({ + projectId: project.id, + token: this.hash(rawToken), + expiresAt, + createdBy: userId, + }); + + if (!isSaved) { + throw new BaseException( + { + code: 'SHARE_CREATE_FAILED', + message: 'Не удалось сгенерировать ссылку доступа', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const durationMsg = dto.ttl + ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` + : 'бессрочна (на 3 месяца по умолчанию)'; + + return { + success: true, + message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, + payload: { + token: rawToken, + isYourself: !!dto, + expiresAt: expiresAt.toISOString(), + }, + }; + } + + private generateSecureToken(): string { + return `st_${randomBytes(32).toString('hex')}`; + } + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/projects/application/use-cases/get-project-detail.query.ts b/src/projects/application/use-cases/get-project-detail.query.ts new file mode 100644 index 0000000..d69ec4e --- /dev/null +++ b/src/projects/application/use-cases/get-project-detail.query.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectsMapper } from '../mappers'; +import { FindProjectQuery } from './find-project.query'; + +@Injectable() +export class GetProjectDetailQuery { + constructor(private readonly findProjectQuery: FindProjectQuery) {} + + public async execute(id: string, slug: string, userId?: string, token?: string) { + const { project, member } = await this.findProjectQuery.execute( + id, + slug, + userId, + token, + 'viewer', + ); + + return ProjectsMapper.toDetailResponse(project, member); + } +} diff --git a/src/projects/application/use-cases/index.ts b/src/projects/application/use-cases/index.ts new file mode 100644 index 0000000..e7fb41c --- /dev/null +++ b/src/projects/application/use-cases/index.ts @@ -0,0 +1,27 @@ +import { CreateProjectUseCase } from './create-project.use-case'; +import { DeleteProjectUseCase } from './delete-project.use-case'; +import { GenerateShareTokenUseCase } from './generate-share-token.use-case'; +import { SetProjectStatusUseCase } from './set-project-status.use-case'; +import { UpdateProjectUseCase } from './update-project.use-case'; +import { FindProjectsByTeamQuery } from './find-projects-by-team.query'; +import { GetProjectDetailQuery } from './get-project-detail.query'; +import { FindProjectQuery } from './find-project.query'; + +export * from './create-project.use-case'; +export * from './delete-project.use-case'; +export * from './generate-share-token.use-case'; +export * from './set-project-status.use-case'; +export * from './update-project.use-case'; +export * from './find-projects-by-team.query'; +export * from './get-project-detail.query'; +export * from './find-project.query'; + +export const ProjectUseCases = [ + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, +]; + +export const ProjectQueries = [FindProjectsByTeamQuery, GetProjectDetailQuery, FindProjectQuery]; diff --git a/src/projects/application/use-cases/set-project-status.use-case.ts b/src/projects/application/use-cases/set-project-status.use-case.ts new file mode 100644 index 0000000..9e5240e --- /dev/null +++ b/src/projects/application/use-cases/set-project-status.use-case.ts @@ -0,0 +1,41 @@ +import { ProjectStatus } from '@core/projects/domain/entities'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SetProjectStatusUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, status: ProjectStatus) { + const { project } = await this.policy.validateProjectAccess(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}» теперь активен`, + template: `Проект «${project.name}» успешно сохранен как шаблон`, + }; + + return { + success: result, + message: messages[status] || `Статус проекта «${project.name}» изменен`, + }; + } +} diff --git a/src/projects/application/use-cases/update-project.use-case.ts b/src/projects/application/use-cases/update-project.use-case.ts new file mode 100644 index 0000000..eaf8a7b --- /dev/null +++ b/src/projects/application/use-cases/update-project.use-case.ts @@ -0,0 +1,43 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { UpdateProjectDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { IProjectsRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/projects/domain/policy'; + +@Injectable() +export class UpdateProjectUseCase { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(id: string, slug: string, userId: string, dto: UpdateProjectDto) { + const { project } = await this.policy.validateProjectAccess(id, slug, userId); + const { isPublic, key, ...data } = dto; + + const result = await this.projectsRepo.update(project.id, { + ...data, + ...(key && { key: key.toUpperCase() }), + ...(typeof isPublic === 'boolean' && { + visibility: isPublic ? 'public' : 'private', + }), + }); + + if (!result) { + throw new BaseException( + { + code: 'UPDATE_FAILED', + message: + 'Изменения не были применены. Возможно, данные идентичны текущим или проект недоступен', + }, + HttpStatus.BAD_REQUEST, + ); + } + + return { + success: result, + message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', + }; + } +} diff --git a/src/modules/projects/entities/entities.domain.ts b/src/projects/domain/entities/entities.domain.ts similarity index 88% rename from src/modules/projects/entities/entities.domain.ts rename to src/projects/domain/entities/entities.domain.ts index 6170b73..4df40c2 100644 --- a/src/modules/projects/entities/entities.domain.ts +++ b/src/projects/domain/entities/entities.domain.ts @@ -1,5 +1,5 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; -import { projects, projectShares } from './projects.entity'; +import { projects, projectShares } from '../../infrastructure/persistence/models/projects.model'; export enum ProjectStatus { Active = 'active', diff --git a/src/projects/domain/entities/index.ts b/src/projects/domain/entities/index.ts new file mode 100644 index 0000000..1481834 --- /dev/null +++ b/src/projects/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './entities.domain'; diff --git a/src/projects/domain/policy/index.ts b/src/projects/domain/policy/index.ts new file mode 100644 index 0000000..cc90b6c --- /dev/null +++ b/src/projects/domain/policy/index.ts @@ -0,0 +1,5 @@ +import { ProjectAccessPolicy } from './project-access.policy'; + +export * from './project-access.policy'; + +export const POLICIES = [ProjectAccessPolicy]; diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/projects/domain/policy/project-access.policy.ts new file mode 100644 index 0000000..7001ebf --- /dev/null +++ b/src/projects/domain/policy/project-access.policy.ts @@ -0,0 +1,78 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { IProjectsRepository } from '../repository'; +import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { ROLE_PRIORITY } from '@shared/constants'; + +@Injectable() +export class ProjectAccessPolicy { + constructor( + @Inject('IProjectsRepository') + private readonly projectsRepo: IProjectsRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Проверка доступа к команде (используется, например, при создании проекта) + */ + public async ensureTeamAccess( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const team = await this.findTeamQ.execute(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + 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', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { team, member }; + } + + /** + * Полная проверка доступа к конкретному проекту внутри команды + */ + public async validateProjectAccess( + projectId: string, + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'admin', + ) { + const { team, member } = await this.ensureTeamAccess(slug, userId, minRole); + + const project = await this.projectsRepo.findOne(projectId); + if (!project || project.teamId !== team.id) { + throw new BaseException( + { + code: 'PROJECT_NOT_FOUND', + message: 'Проект не найден в этой команде', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { project, member, team }; + } +} diff --git a/src/modules/projects/repository/index.ts b/src/projects/domain/repository/index.ts similarity index 54% rename from src/modules/projects/repository/index.ts rename to src/projects/domain/repository/index.ts index 8aec19a..aea7492 100644 --- a/src/modules/projects/repository/index.ts +++ b/src/projects/domain/repository/index.ts @@ -1,2 +1 @@ -export { ProjectsRepository } from './projects.repository'; export { IProjectsRepository } from './projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.interface.ts b/src/projects/domain/repository/projects.repository.interface.ts similarity index 100% rename from src/modules/projects/repository/projects.repository.interface.ts rename to src/projects/domain/repository/projects.repository.interface.ts diff --git a/src/modules/projects/index.ts b/src/projects/index.ts similarity index 100% rename from src/modules/projects/index.ts rename to src/projects/index.ts diff --git a/src/modules/projects/entities/enums.ts b/src/projects/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/projects/entities/enums.ts rename to src/projects/infrastructure/persistence/models/enums.ts diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/projects/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..ed46b14 --- /dev/null +++ b/src/projects/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { projectStatusEnum, projectVisibilityEnum } from './enums'; +export { projectShares, projects } from './projects.model'; diff --git a/src/modules/projects/entities/projects.entity.ts b/src/projects/infrastructure/persistence/models/projects.model.ts similarity index 100% rename from src/modules/projects/entities/projects.entity.ts rename to src/projects/infrastructure/persistence/models/projects.model.ts diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/projects/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..5c64093 --- /dev/null +++ b/src/projects/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,2 @@ +export { ProjectsRepository } from './projects.repository'; +export { IProjectsRepository } from '../../../domain/repository/projects.repository.interface'; diff --git a/src/modules/projects/repository/projects.repository.ts b/src/projects/infrastructure/persistence/repositories/projects.repository.ts similarity index 89% rename from src/modules/projects/repository/projects.repository.ts rename to src/projects/infrastructure/persistence/repositories/projects.repository.ts index a4f6750..69d8c33 100644 --- a/src/modules/projects/repository/projects.repository.ts +++ b/src/projects/infrastructure/persistence/repositories/projects.repository.ts @@ -1,8 +1,9 @@ import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Injectable, Inject } from '@nestjs/common'; -import * as schema from '../entities'; -import { IProjectsRepository } from './projects.repository.interface'; +import * as schema from '../models'; +import { IProjectsRepository } from '../../../domain/repository'; import { and, eq, gt, isNull, or } from 'drizzle-orm'; +import type { NewProject, NewProjectShare } from '@core/projects/domain/entities'; @Injectable() export class ProjectsRepository implements IProjectsRepository { @@ -11,7 +12,7 @@ export class ProjectsRepository implements IProjectsRepository { private readonly db: DatabaseService, ) {} - public create = async (data: schema.NewProject) => { + public create = async (data: NewProject) => { const result = await this.db .insert(schema.projects) .values(data) @@ -20,7 +21,7 @@ export class ProjectsRepository implements IProjectsRepository { return { result: result.length > 0, id: result[0].id }; }; - public update = async (id: string, data: Partial) => { + public update = async (id: string, data: Partial) => { const result = await this.db .update(schema.projects) .set({ ...data, updatedAt: new Date() }) @@ -58,7 +59,7 @@ export class ProjectsRepository implements IProjectsRepository { .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); }; - public createShare = async (data: schema.NewProjectShare) => { + public createShare = async (data: NewProjectShare) => { const [result] = await this.db .insert(schema.projectShares) .values(data) diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts new file mode 100644 index 0000000..4a74316 --- /dev/null +++ b/src/projects/projects.module.ts @@ -0,0 +1,20 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { ProjectsRepository } from './infrastructure/persistence/repositories'; +import { TeamsModule } from '@core/teams'; +import { ProjectsController } from './application/controller'; +import { FindProjectQuery, ProjectQueries, ProjectUseCases } from './application/use-cases'; +import { POLICIES } from './domain/policy'; +import { ProjectsFacade } from './application/projects.facade'; + +const REPOSITORY = { + provide: 'IProjectsRepository', + useClass: ProjectsRepository, +}; + +@Module({ + imports: [forwardRef(() => TeamsModule)], + controllers: [ProjectsController], + providers: [REPOSITORY, ...POLICIES, ...ProjectUseCases, ...ProjectQueries, ProjectsFacade], + exports: [FindProjectQuery], +}) +export class ProjectsModule {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 119ec4c..b50a6a2 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -2,4 +2,4 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; -export * from '../../modules/projects/entities'; +export * from '../../projects/infrastructure/persistence/models'; From ca00c884b96f36f069c078253a87884242f6befa Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 30 Apr 2026 17:23:38 +0300 Subject: [PATCH 6/9] refactor: cleanup use-cases, optimize imports and move media to shared --- src/auth/application/use-cases/index.ts | 38 +++++-- src/auth/auth.module.ts | 24 +---- src/{modules => shared}/media/dtos/index.ts | 0 .../media/dtos/upload-file-response.dto.ts | 0 .../media/dtos/upload-file.dto.ts | 0 src/{modules => shared}/media/index.ts | 0 .../media/interfaces/team-media.interface.ts | 0 .../media/interfaces/user-media.interface.ts | 0 src/{modules => shared}/media/media.module.ts | 0 .../media/media.service.ts | 0 .../controller/settings/controller.ts | 2 +- .../controller/settings/swagger.ts | 2 +- src/teams/application/team.facade.ts | 2 +- src/teams/application/use-cases/index.ts | 98 ++++++++++++++----- .../use-cases/update-team-avatar.use-case.ts | 2 +- .../use-cases/update-team-banner.use-case.ts | 2 +- src/teams/teams.module.ts | 39 +------- .../application/controller/user/controller.ts | 2 +- src/user/application/use-cases/index.ts | 39 ++++++-- .../use-cases/upload-avatar.use-case.ts | 2 +- src/user/application/user.facade.ts | 2 +- src/user/user.module.ts | 27 +---- 22 files changed, 155 insertions(+), 126 deletions(-) rename src/{modules => shared}/media/dtos/index.ts (100%) rename src/{modules => shared}/media/dtos/upload-file-response.dto.ts (100%) rename src/{modules => shared}/media/dtos/upload-file.dto.ts (100%) rename src/{modules => shared}/media/index.ts (100%) rename src/{modules => shared}/media/interfaces/team-media.interface.ts (100%) rename src/{modules => shared}/media/interfaces/user-media.interface.ts (100%) rename src/{modules => shared}/media/media.module.ts (100%) rename src/{modules => shared}/media/media.service.ts (100%) diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index 97a9482..450edfb 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -1,8 +1,30 @@ -export { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; -export { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; -export { RefreshTokensUseCase } from './refresh-tokens.use-case'; -export { ResetPasswordUseCase } from './reset-password.use-case'; -export { SignUpVerifyUseCase } from './sign-up-verify.use-case'; -export { SignInUseCase } from './sign-in.use-case'; -export { SignOutUseCase } from './sign-out.use-case'; -export { SignUpUseCase } from './sign-up.use-case'; +import { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; +import { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +import { RefreshTokensUseCase } from './refresh-tokens.use-case'; +import { ResetPasswordUseCase } from './reset-password.use-case'; +import { SignUpVerifyUseCase } from './sign-up-verify.use-case'; +import { SignInUseCase } from './sign-in.use-case'; +import { SignOutUseCase } from './sign-out.use-case'; +import { SignUpUseCase } from './sign-up.use-case'; + +export { + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +}; + +export const AuthUseCases = [ + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, +]; diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e79f42a..3aec5b8 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -8,16 +8,7 @@ import { JwtModule } from '@nestjs/jwt'; import { UserModule } from '@core/user'; import { AuthController, AuthRecoveryController } from './application/controller'; import { AuthFacade } from './application/auth.facade'; -import { - ConfirmResetPasswordUseCase, - VerifyResetPasswordUseCase, - RefreshTokensUseCase, - ResetPasswordUseCase, - SignUpVerifyUseCase, - SignInUseCase, - SignOutUseCase, - SignUpUseCase, -} from './application/use-cases'; +import { AuthUseCases } from './application/use-cases'; import { AuthQueues } from './domain/enums'; import { SessionRepository } from './infrastructure/persistence/repositories'; import { TokenService } from './infrastructure/security'; @@ -25,17 +16,6 @@ import { BearerStrategy, CookieStrategy } from './infrastructure/strategies'; import { MailProcessor } from './infrastructure/workers'; import { MailAdapter } from '@shared/adapters/mail'; -const USE_CASES = [ - ConfirmResetPasswordUseCase, - VerifyResetPasswordUseCase, - RefreshTokensUseCase, - ResetPasswordUseCase, - SignUpVerifyUseCase, - SignInUseCase, - SignOutUseCase, - SignUpUseCase, -]; - const WORKERS = [MailProcessor]; const REPOSITORY = { @@ -105,9 +85,9 @@ const REPOSITORY = { ...WORKERS, TokenService, CookieStrategy, + ...AuthUseCases, BearerStrategy, REPOSITORY, - ...USE_CASES, AuthFacade, ], exports: [], diff --git a/src/modules/media/dtos/index.ts b/src/shared/media/dtos/index.ts similarity index 100% rename from src/modules/media/dtos/index.ts rename to src/shared/media/dtos/index.ts diff --git a/src/modules/media/dtos/upload-file-response.dto.ts b/src/shared/media/dtos/upload-file-response.dto.ts similarity index 100% rename from src/modules/media/dtos/upload-file-response.dto.ts rename to src/shared/media/dtos/upload-file-response.dto.ts diff --git a/src/modules/media/dtos/upload-file.dto.ts b/src/shared/media/dtos/upload-file.dto.ts similarity index 100% rename from src/modules/media/dtos/upload-file.dto.ts rename to src/shared/media/dtos/upload-file.dto.ts diff --git a/src/modules/media/index.ts b/src/shared/media/index.ts similarity index 100% rename from src/modules/media/index.ts rename to src/shared/media/index.ts diff --git a/src/modules/media/interfaces/team-media.interface.ts b/src/shared/media/interfaces/team-media.interface.ts similarity index 100% rename from src/modules/media/interfaces/team-media.interface.ts rename to src/shared/media/interfaces/team-media.interface.ts diff --git a/src/modules/media/interfaces/user-media.interface.ts b/src/shared/media/interfaces/user-media.interface.ts similarity index 100% rename from src/modules/media/interfaces/user-media.interface.ts rename to src/shared/media/interfaces/user-media.interface.ts diff --git a/src/modules/media/media.module.ts b/src/shared/media/media.module.ts similarity index 100% rename from src/modules/media/media.module.ts rename to src/shared/media/media.module.ts diff --git a/src/modules/media/media.service.ts b/src/shared/media/media.service.ts similarity index 100% rename from src/modules/media/media.service.ts rename to src/shared/media/media.service.ts diff --git a/src/teams/application/controller/settings/controller.ts b/src/teams/application/controller/settings/controller.ts index 16f8ac5..43c18ba 100644 --- a/src/teams/application/controller/settings/controller.ts +++ b/src/teams/application/controller/settings/controller.ts @@ -3,7 +3,7 @@ import { ApiBaseController, ExtractFastifyFile } from '@shared/decorators'; import { SyncTeamTagsSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger } from './swagger'; import { SyncTagsDto } from '../../dtos'; import { TeamsFacade } from '../../team.facade'; -import { FileUploadDto } from '@core/modules/media'; +import { FileUploadDto } from '@shared/media'; @ApiBaseController('teams/:slug', 'Teams Settings', true) export class TeamsSettingsController { diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts index 19d90e3..88e409f 100644 --- a/src/teams/application/controller/settings/swagger.ts +++ b/src/teams/application/controller/settings/swagger.ts @@ -3,7 +3,7 @@ import { ApiBody, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger import { ActionResponse } from '@shared/dtos'; import { ApiBadRequest, ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { SyncTagsDto } from '../../dtos'; -import { FileUploadResponse } from '@core/modules/media'; +import { FileUploadResponse } from '@shared/media'; export const SyncTeamTagsSwagger = () => applyDecorators( diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts index e2832ff..e768d31 100644 --- a/src/teams/application/team.facade.ts +++ b/src/teams/application/team.facade.ts @@ -7,7 +7,7 @@ import type { UpdateMemberDto, UpdateTeamDto, } from './dtos'; -import { FileUploadDto } from '@core/modules/media'; +import { FileUploadDto } from '@shared/media'; @Injectable() export class TeamsFacade { diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts index d6624d6..3bb1124 100644 --- a/src/teams/application/use-cases/index.ts +++ b/src/teams/application/use-cases/index.ts @@ -1,24 +1,78 @@ -export { CheckTeamSlugQuery } from './check-team-slug.query'; -export { FindTeamQuery } from './find-team.query'; -export { FindTeamMemberQuery } from './find-team-member.query'; -export { GetInvitationQuery } from './get-invitation.query'; -export { GetInvitationsQuery } from './get-invitations.query'; -export { GetTeamMembersQuery } from './get-team-members.query'; +import { CheckTeamSlugQuery } from './check-team-slug.query'; +import { FindTeamQuery } from './find-team.query'; +import { FindTeamMemberQuery } from './find-team-member.query'; +import { GetInvitationQuery } from './get-invitation.query'; +import { GetInvitationsQuery } from './get-invitations.query'; +import { GetTeamMembersQuery } from './get-team-members.query'; +import { GetAllTagsUseCase } from './get-all-tags.use-case'; +import { GetMyInvitesUseCase } from './get-my-invites.use-case'; +import { GetMyTeamsUseCase } from './get-my-teams.use-case'; +import { GetUserInvitesUseCase } from './get-user-invites.use-case'; -export { AcceptInvitationUseCase } from './accept-invitation.use-case'; -export { CreateTeamUseCase } from './create-team.use-case'; -export { DeleteTeamUseCase } from './delete-team.use-case'; -export { GetAllTagsUseCase } from './get-all-tags.use-case'; -export { GetMyInvitesUseCase } from './get-my-invites.use-case'; -export { GetMyTeamsUseCase } from './get-my-teams.use-case'; -export { GetUserInvitesUseCase } from './get-user-invites.use-case'; -export { RemoveTeamMemberUseCase } from './remove-team-member.use-case'; -export { SendInvitationUseCase } from './send-invitation.use-case'; -export { SyncTeamTagsUseCase } from './sync-team-tags.use-case'; -export { UpdateTeamUseCase } from './update-team.use-case'; -export { UpdateTeamAvatarUseCase } from './update-team-avatar.use-case'; -export { UpdateTeamBannerUseCase } from './update-team-banner.use-case'; -export { UpdateTeamMemberUseCase } from './update-team-member.use-case'; +import { AcceptInvitationUseCase } from './accept-invitation.use-case'; +import { CreateTeamUseCase } from './create-team.use-case'; +import { DeleteTeamUseCase } from './delete-team.use-case'; +import { RemoveTeamMemberUseCase } from './remove-team-member.use-case'; +import { SendInvitationUseCase } from './send-invitation.use-case'; +import { SyncTeamTagsUseCase } from './sync-team-tags.use-case'; +import { UpdateTeamUseCase } from './update-team.use-case'; +import { UpdateTeamAvatarUseCase } from './update-team-avatar.use-case'; +import { UpdateTeamBannerUseCase } from './update-team-banner.use-case'; +import { UpdateTeamMemberUseCase } from './update-team-member.use-case'; +import { UpdateInvitationUseCase } from './update-invitation.use-case'; +import { DeclineInvitationUseCase } from './decline-invitation.use-case'; -export { UpdateInvitationUseCase } from './update-invitation.use-case'; -export { DeclineInvitationUseCase } from './decline-invitation.use-case'; +export { + CheckTeamSlugQuery, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, + GetUserInvitesUseCase, + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + SyncTeamTagsUseCase, + UpdateTeamUseCase, + UpdateTeamAvatarUseCase, + UpdateTeamBannerUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +}; + +export const TeamQueries = [ + CheckTeamSlugQuery, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, + GetUserInvitesUseCase, +]; + +export const TeamUseCases = [ + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + SyncTeamTagsUseCase, + UpdateTeamUseCase, + UpdateTeamAvatarUseCase, + UpdateTeamBannerUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +]; + +export const TEAM_EXTERNAL_QUERIES = [FindTeamQuery, FindTeamMemberQuery]; diff --git a/src/teams/application/use-cases/update-team-avatar.use-case.ts b/src/teams/application/use-cases/update-team-avatar.use-case.ts index d1cf2f3..955b340 100644 --- a/src/teams/application/use-cases/update-team-avatar.use-case.ts +++ b/src/teams/application/use-cases/update-team-avatar.use-case.ts @@ -1,4 +1,4 @@ -import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/teams/application/use-cases/update-team-banner.use-case.ts b/src/teams/application/use-cases/update-team-banner.use-case.ts index 6bf4a52..97c0218 100644 --- a/src/teams/application/use-cases/update-team-banner.use-case.ts +++ b/src/teams/application/use-cases/update-team-banner.use-case.ts @@ -1,4 +1,4 @@ -import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@shared/media'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 1e81ba6..871afe7 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -13,43 +13,12 @@ import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { TeamsRepository } from './infrastructure/persistence/repositories'; import { TeamQueues } from './domain/enums'; -import { MediaModule } from '@core/modules/media'; import { TeamsFacade } from './application/team.facade'; - -import * as UC from './application/use-cases'; +import { TeamQueries, TeamUseCases, TEAM_EXTERNAL_QUERIES } from './application/use-cases'; +import { MediaModule } from '@shared/media'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; -const QUERIES = [ - UC.FindTeamQuery, - UC.FindTeamMemberQuery, - UC.GetInvitationQuery, - UC.GetInvitationsQuery, - UC.GetTeamMembersQuery, - UC.GetMyInvitesUseCase, - UC.GetMyTeamsUseCase, - UC.GetUserInvitesUseCase, - UC.GetAllTagsUseCase, - UC.CheckTeamSlugQuery, -]; - -const USE_CASES = [ - UC.CreateTeamUseCase, - UC.DeleteTeamUseCase, - UC.UpdateTeamUseCase, - UC.UpdateTeamAvatarUseCase, - UC.UpdateTeamBannerUseCase, - UC.SyncTeamTagsUseCase, - UC.UpdateTeamMemberUseCase, - UC.RemoveTeamMemberUseCase, - UC.SendInvitationUseCase, - UC.AcceptInvitationUseCase, - UC.UpdateInvitationUseCase, - UC.DeclineInvitationUseCase, -]; - -const EXTERNAL_USE_CASES = [UC.FindTeamMemberQuery, UC.FindTeamQuery]; - @Module({ imports: [ MediaModule, @@ -89,7 +58,7 @@ const EXTERNAL_USE_CASES = [UC.FindTeamMemberQuery, UC.FindTeamQuery]; TeamsController, MeController, ], - providers: [REPOSITORY, ...USE_CASES, ...QUERIES, TeamsFacade], - exports: [...EXTERNAL_USE_CASES], + providers: [REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], + exports: [...TEAM_EXTERNAL_QUERIES], }) export class TeamsModule {} diff --git a/src/user/application/controller/user/controller.ts b/src/user/application/controller/user/controller.ts index ec6fd3d..e32273b 100644 --- a/src/user/application/controller/user/controller.ts +++ b/src/user/application/controller/user/controller.ts @@ -4,7 +4,7 @@ import { UpdateProfileDto } from '../../dtos'; import { ApiBaseController, ExtractFastifyFile, GetUserId } from '@shared/decorators'; import { UserFacade } from '../../user.facade'; import { PaginationDto } from '@shared/dtos'; -import { FileUploadDto } from '@core/modules/media'; +import { FileUploadDto } from '@shared/media'; @ApiBaseController('users/me', 'Account Profile', true) export class UserController { diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts index fa08830..2939769 100644 --- a/src/user/application/use-cases/index.ts +++ b/src/user/application/use-cases/index.ts @@ -1,8 +1,31 @@ -export { FindProfileQuery } from './find-profile.query'; -export { FindUserQuery } from './find-user.query'; -export { GetActivityQuery } from './get-activity.query'; -export { RegisterUserUseCase } from './register-user.use-case'; -export { UpdateNotificationsUseCase } from './update-notifications.use-case'; -export { UpdatePasswordUseCase } from './update-password.use-case'; -export { UpdateProfileUseCase } from './update-profile.use-case'; -export { UploadAvatarUseCase } from './upload-avatar.use-case'; +import { RegisterUserUseCase } from './register-user.use-case'; +import { UpdateNotificationsUseCase } from './update-notifications.use-case'; +import { UpdatePasswordUseCase } from './update-password.use-case'; +import { UpdateProfileUseCase } from './update-profile.use-case'; +import { UploadAvatarUseCase } from './upload-avatar.use-case'; + +import { FindProfileQuery } from './find-profile.query'; +import { FindUserQuery } from './find-user.query'; +import { GetActivityQuery } from './get-activity.query'; + +export * from './register-user.use-case'; +export * from './update-notifications.use-case'; +export * from './update-password.use-case'; +export * from './update-profile.use-case'; +export * from './upload-avatar.use-case'; + +export * from './find-profile.query'; +export * from './find-user.query'; +export * from './get-activity.query'; + +export const UserUseCases = [ + RegisterUserUseCase, + UpdateNotificationsUseCase, + UpdatePasswordUseCase, + UpdateProfileUseCase, + UploadAvatarUseCase, +]; + +export const UserQueries = [FindProfileQuery, FindUserQuery, GetActivityQuery]; + +export const USER_EXTERNAL_USE_CASES = [RegisterUserUseCase, UpdatePasswordUseCase, FindUserQuery]; diff --git a/src/user/application/use-cases/upload-avatar.use-case.ts b/src/user/application/use-cases/upload-avatar.use-case.ts index 12da3d0..dd79784 100644 --- a/src/user/application/use-cases/upload-avatar.use-case.ts +++ b/src/user/application/use-cases/upload-avatar.use-case.ts @@ -1,4 +1,4 @@ -import { FileUploadDto, IUserMedia, USER_MEDIA_TOKEN } from '@core/modules/media'; +import { FileUploadDto, IUserMedia, USER_MEDIA_TOKEN } from '@shared/media'; import { IUserRepository } from '@core/user/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts index e49cd8f..4be6829 100644 --- a/src/user/application/user.facade.ts +++ b/src/user/application/user.facade.ts @@ -7,7 +7,7 @@ import { UploadAvatarUseCase, } from './use-cases'; import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; -import { FileUploadDto } from '@core/modules/media'; +import { FileUploadDto } from '@shared/media'; @Injectable() export class UserFacade { diff --git a/src/user/user.module.ts b/src/user/user.module.ts index df7d473..62df1a2 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,38 +1,19 @@ import { Module } from '@nestjs/common'; -import { MediaModule } from '@core/modules/media'; +import { MediaModule } from '@shared/media'; import { UserRepository } from './infrastructure/persistence/repositories'; import { UserController, UserSettingsController } from './application/controller'; import { UserFacade } from './application/user.facade'; -import { - FindProfileQuery, - FindUserQuery, - GetActivityQuery, - RegisterUserUseCase, - UpdateNotificationsUseCase, - UpdatePasswordUseCase, - UpdateProfileUseCase, - UploadAvatarUseCase, -} from './application/use-cases'; +import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; const REPOSITORY = { provide: 'IUserRepository', useClass: UserRepository, }; -const USE_CASES = [ - UploadAvatarUseCase, - UpdateProfileUseCase, - UpdateNotificationsUseCase, - FindProfileQuery, - GetActivityQuery, -]; - -const EXTERNAL_USE_CASES = [RegisterUserUseCase, UpdatePasswordUseCase, FindUserQuery]; - @Module({ imports: [MediaModule], controllers: [UserController, UserSettingsController], - providers: [...USE_CASES, ...EXTERNAL_USE_CASES, REPOSITORY, UserFacade], - exports: [...EXTERNAL_USE_CASES], + providers: [...UserUseCases, ...UserQueries, REPOSITORY, UserFacade], + exports: [...USER_EXTERNAL_USE_CASES], }) export class UserModule {} From 9c5e5f91c8fb61a51dd852d2a9ceb986107b0f75 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 30 Apr 2026 17:33:01 +0300 Subject: [PATCH 7/9] refactor(database): move migration logic from shared to database lib --- libs/database/src/database.module.ts | 25 ++++++++++++++----- .../interfaces/database-module.interface.ts | 5 ++++ .../database/src}/migration.service.ts | 10 +++++++- src/app.module.ts | 2 -- .../extract-fastify-file.decorator.ts | 2 +- src/shared/migration/index.ts | 1 - 6 files changed, 34 insertions(+), 11 deletions(-) rename {src/shared/migration => libs/database/src}/migration.service.ts (72%) delete mode 100644 src/shared/migration/index.ts diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index 07d5c78..f3bfa4d 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -15,6 +15,7 @@ import type { DatabaseModuleOptions, DatabaseModuleOptionsFactory, } from './interfaces'; +import { MigrationService } from './migration.service'; @Module({ providers: [], @@ -28,7 +29,11 @@ export class DatabaseModule implements OnApplicationShutdown { return { module: DatabaseModule, global: config.global ?? false, - providers: [this.createOptionsProvider(config), this.createDatabaseProvider()], + providers: [ + this.createOptionsProvider(config), + this.createDatabaseProvider(), + MigrationService, + ], exports: [DATABASE_SERVICE], }; } @@ -38,7 +43,11 @@ export class DatabaseModule implements OnApplicationShutdown { module: DatabaseModule, global: config.global ?? false, imports: config.imports ?? [], - providers: [...this.createAsyncProviders(config), this.createDatabaseProvider()], + providers: [ + ...this.createAsyncProviders(config), + this.createDatabaseProvider(), + MigrationService, + ], exports: [DATABASE_SERVICE], }; } @@ -61,13 +70,17 @@ export class DatabaseModule implements OnApplicationShutdown { const pool = new Pool({ connectionString: url.toString(), max: 20, - min: 5, - connectionTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - maxUses: 7500, + min: 2, + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 10000, + maxUses: 5000, keepAlive: true, }); + pool.on('error', (err) => { + DatabaseModule.logger.error('Database pool connection lost or reset', err); + }); + this.pool = pool; return drizzle(pool, { diff --git a/libs/database/src/interfaces/database-module.interface.ts b/libs/database/src/interfaces/database-module.interface.ts index 7926881..5320f61 100644 --- a/libs/database/src/interfaces/database-module.interface.ts +++ b/libs/database/src/interfaces/database-module.interface.ts @@ -6,6 +6,11 @@ export interface DatabaseModuleOptions { schema: Record; logging?: boolean; global?: boolean; + /** + * Запускать миграции автоматически при инициализации модуля + * @default true + */ + runMigrations?: boolean; } export interface DatabaseModuleOptionsFactory { diff --git a/src/shared/migration/migration.service.ts b/libs/database/src/migration.service.ts similarity index 72% rename from src/shared/migration/migration.service.ts rename to libs/database/src/migration.service.ts index e1e49f9..4236b72 100644 --- a/src/shared/migration/migration.service.ts +++ b/libs/database/src/migration.service.ts @@ -1,6 +1,8 @@ import { Inject, Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; -import { DATABASE_SERVICE, type DatabaseService } from '@libs/database'; + +import { DATABASE_OPTIONS, DATABASE_SERVICE } from './database.constants'; +import type { DatabaseService, DatabaseModuleOptions } from './interfaces'; import * as path from 'path'; @Injectable() @@ -10,9 +12,15 @@ export class MigrationService implements OnModuleInit { constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService>, + @Inject(DATABASE_OPTIONS) + private readonly options: DatabaseModuleOptions, ) {} async onModuleInit() { + if (this.options.runMigrations === false) { + return; + } + this.logger.debug('Checking for database migrations...'); try { await migrate(this.db, { diff --git a/src/app.module.ts b/src/app.module.ts index a73784d..0270abf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,7 +14,6 @@ import { BullBoardModule } from '@bull-board/nestjs'; import { FastifyAdapter } from '@bull-board/fastify'; import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; -import { MigrationService } from '@shared/migration'; import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; @@ -61,7 +60,6 @@ import { ProjectsModule } from './projects'; HealthModule.register('gateway'), ], providers: [ - MigrationService, MailModule, { provide: APP_PIPE, diff --git a/src/shared/decorators/extract-fastify-file.decorator.ts b/src/shared/decorators/extract-fastify-file.decorator.ts index 05efe78..82b2658 100644 --- a/src/shared/decorators/extract-fastify-file.decorator.ts +++ b/src/shared/decorators/extract-fastify-file.decorator.ts @@ -1,7 +1,7 @@ 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 type { FileUploadDto } from '@shared/media'; import { BaseException } from '@shared/error'; export const ExtractFastifyFile = createParamDecorator( diff --git a/src/shared/migration/index.ts b/src/shared/migration/index.ts deleted file mode 100644 index 1be4fe2..0000000 --- a/src/shared/migration/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MigrationService } from './migration.service'; From eed20852ea2f61cafec12644099eb021fd861f7d Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 1 May 2026 17:47:11 +0300 Subject: [PATCH 8/9] feat: migrate all API modules to DDD and fix auth issues --- .../confirm-reset-password.use-case.ts | 1 + .../use-cases/refresh-tokens.use-case.ts | 13 ++- .../use-cases/sign-up-verify.use-case.ts | 11 +- .../application/use-cases/sign-up.use-case.ts | 1 - src/teams/application/dtos/member.dto.ts | 2 +- src/teams/application/dtos/team.dto.ts | 12 ++ .../{ => base}/check-team-slug.query.ts | 5 +- .../{ => base}/create-team.use-case.ts | 4 +- .../{ => base}/delete-team.use-case.ts | 7 +- .../use-cases/{ => base}/find-team.query.ts | 2 +- .../{ => base}/get-all-tags.use-case.ts | 2 +- .../{ => base}/get-my-teams.use-case.ts | 0 .../{ => base}/sync-team-tags.use-case.ts | 0 .../{ => base}/update-team-avatar.use-case.ts | 0 .../{ => base}/update-team-banner.use-case.ts | 0 .../{ => base}/update-team.use-case.ts | 8 +- .../use-cases/get-user-invites.use-case.ts | 24 ---- src/teams/application/use-cases/index.ts | 45 ++++---- .../accept-invitation.use-case.ts | 2 +- .../decline-invitation.use-case.ts | 2 +- .../{ => invitions}/get-invitation.query.ts | 2 +- .../{ => invitions}/get-invitations.query.ts | 2 +- .../get-my-invites.use-case.ts | 0 .../send-invitation.use-case.ts | 4 +- .../update-invitation.use-case.ts | 4 +- .../{ => members}/find-team-member.query.ts | 2 +- .../{ => members}/get-team-members.query.ts | 0 .../remove-team-member.use-case.ts | 44 ++++---- .../members/update-team-member.use-case.ts | 103 ++++++++++++++++++ .../use-cases/update-team-member.use-case.ts | 100 ----------------- src/teams/domain/policy/index.ts | 1 + src/teams/domain/policy/team-member.policy.ts | 70 ++++++++++++ .../repository/teams.repository.interface.ts | 2 +- .../repositories/teams.repository.ts | 20 ++-- src/teams/teams.module.ts | 3 +- .../use-cases/register-user.use-case.ts | 4 + .../use-cases/update-profile.use-case.ts | 13 ++- 37 files changed, 296 insertions(+), 219 deletions(-) rename src/teams/application/use-cases/{ => base}/check-team-slug.query.ts (71%) rename src/teams/application/use-cases/{ => base}/create-team.use-case.ts (94%) rename src/teams/application/use-cases/{ => base}/delete-team.use-case.ts (82%) rename src/teams/application/use-cases/{ => base}/find-team.query.ts (82%) rename src/teams/application/use-cases/{ => base}/get-all-tags.use-case.ts (95%) rename src/teams/application/use-cases/{ => base}/get-my-teams.use-case.ts (100%) rename src/teams/application/use-cases/{ => base}/sync-team-tags.use-case.ts (100%) rename src/teams/application/use-cases/{ => base}/update-team-avatar.use-case.ts (100%) rename src/teams/application/use-cases/{ => base}/update-team-banner.use-case.ts (100%) rename src/teams/application/use-cases/{ => base}/update-team.use-case.ts (90%) delete mode 100644 src/teams/application/use-cases/get-user-invites.use-case.ts rename src/teams/application/use-cases/{ => invitions}/accept-invitation.use-case.ts (98%) rename src/teams/application/use-cases/{ => invitions}/decline-invitation.use-case.ts (98%) rename src/teams/application/use-cases/{ => invitions}/get-invitation.query.ts (97%) rename src/teams/application/use-cases/{ => invitions}/get-invitations.query.ts (97%) rename src/teams/application/use-cases/{ => invitions}/get-my-invites.use-case.ts (100%) rename src/teams/application/use-cases/{ => invitions}/send-invitation.use-case.ts (97%) rename src/teams/application/use-cases/{ => invitions}/update-invitation.use-case.ts (96%) rename src/teams/application/use-cases/{ => members}/find-team-member.query.ts (84%) rename src/teams/application/use-cases/{ => members}/get-team-members.query.ts (100%) rename src/teams/application/use-cases/{ => members}/remove-team-member.use-case.ts (65%) create mode 100644 src/teams/application/use-cases/members/update-team-member.use-case.ts delete mode 100644 src/teams/application/use-cases/update-team-member.use-case.ts create mode 100644 src/teams/domain/policy/index.ts create mode 100644 src/teams/domain/policy/team-member.policy.ts diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts index cdbf3b6..fe044cb 100644 --- a/src/auth/application/use-cases/confirm-reset-password.use-case.ts +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -54,6 +54,7 @@ export class ConfirmResetPasswordUseCase { HttpStatus.INTERNAL_SERVER_ERROR, ); } + await this.redis.del(redisKey); return { diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts index 390c752..f9a853e 100644 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -29,7 +29,7 @@ export class RefreshTokensUseCase { const session = await this.sessionRepo.findById(payload.jti); - if (!session || session.isRevoked) { + if (!session || session?.isRevoked) { throw new BaseException( { code: 'SESSION_REVOKED', @@ -39,9 +39,9 @@ export class RefreshTokensUseCase { ); } - const { user } = await this.findUserQuery.execute({ id: session.userId }); + const entity = await this.findUserQuery.execute({ id: session.userId }); - if (!user) { + if (!entity?.user) { await this.sessionRepo.revoke(session.id); throw new BaseException( { @@ -55,12 +55,15 @@ export class RefreshTokensUseCase { await this.sessionRepo.revoke(session.id); const newSession = await this.sessionRepo.create({ - userId: user.id, + userId: entity.user.id, ...metadata, expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), }); - const { access, refresh } = await this.tokenService.generateTokens(user, newSession.id); + const { access, refresh } = await this.tokenService.generateTokens( + entity.user, + newSession.id, + ); return { tokens: { access, refresh }, diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts index 6046c73..e0b1d61 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -22,7 +22,6 @@ export class SignUpVerifyUseCase { async execute(dto: VerifyDto, meta: DeviceMetadata) { const redisKey = `reg:${dto.email}`; - const cachedData = await this.redis.get(redisKey); if (!cachedData) { @@ -37,6 +36,16 @@ export class SignUpVerifyUseCase { const userData = JSON.parse(cachedData); + if (!userData) { + throw new BaseException( + { + code: 'INTERNAL_DATA_CORRUPTION', + message: 'Ошибка целостности данных. Попробуйте начать регистрацию заново.', + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + const verifyResult = await verifyOTP({ token: dto.code, secret: userData.otp.secret, diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts index 5550c06..a893b5e 100644 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -23,7 +23,6 @@ export class SignUpUseCase { async execute(dto: SignUpDto) { const redisKey = `reg:${dto.email}`; - const cachedData = await this.redis.get(redisKey); if (cachedData) { diff --git a/src/teams/application/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts index 2fe245d..1e9fe35 100644 --- a/src/teams/application/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -14,7 +14,7 @@ export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} const UpdateMemberDtoSchema = z .object({ - role: z.string().optional().describe('Новая роль участника'), + role: z.enum(roleEnum.enumValues).optional().describe('Новая роль участника'), status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), }) .refine((data) => Object.keys(data).length > 0, { diff --git a/src/teams/application/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts index 4fe2fb8..b96c9c4 100644 --- a/src/teams/application/dtos/team.dto.ts +++ b/src/teams/application/dtos/team.dto.ts @@ -46,6 +46,18 @@ export const SyncTagsSchema = z.object({ .array(z.string()) .min(1, 'Список тегов не может быть пустым') .max(15, 'Нельзя добавить более 15 тегов за раз') + .superRefine((items, ctx) => { + if (!items) return; + const lowerItems = items.map((i) => i.toLowerCase()); + const hasDuplicates = new Set(lowerItems).size !== items.length; + + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }) .describe( 'Массив названий тегов для привязки к команде. Если тега нет в базе, он будет создан.', ), diff --git a/src/teams/application/use-cases/check-team-slug.query.ts b/src/teams/application/use-cases/base/check-team-slug.query.ts similarity index 71% rename from src/teams/application/use-cases/check-team-slug.query.ts rename to src/teams/application/use-cases/base/check-team-slug.query.ts index 2ad8ae8..46a3083 100644 --- a/src/teams/application/use-cases/check-team-slug.query.ts +++ b/src/teams/application/use-cases/base/check-team-slug.query.ts @@ -13,9 +13,8 @@ export class CheckTeamSlugQuery { return { available, message: available - ? `Slug "${normalizedSlug}" доступен для использования` - : `Slug "${normalizedSlug}" уже занят`, - details: { slug: normalizedSlug }, + ? `Slug ${normalizedSlug} доступен для использования` + : `Slug ${normalizedSlug} уже занят`, }; } } diff --git a/src/teams/application/use-cases/create-team.use-case.ts b/src/teams/application/use-cases/base/create-team.use-case.ts similarity index 94% rename from src/teams/application/use-cases/create-team.use-case.ts rename to src/teams/application/use-cases/base/create-team.use-case.ts index d12ced0..c6b8cc7 100644 --- a/src/teams/application/use-cases/create-team.use-case.ts +++ b/src/teams/application/use-cases/base/create-team.use-case.ts @@ -1,6 +1,6 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CreateTeamDto } from '../dtos'; +import { CreateTeamDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { slugify } from 'transliteration'; @@ -45,6 +45,8 @@ export class CreateTeamUseCase { message: 'Команда успешно создана', }; } catch (error) { + if (error instanceof BaseException) throw error; + throw new BaseException( { code: 'TEAM_CREATE_FAILED', diff --git a/src/teams/application/use-cases/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts similarity index 82% rename from src/teams/application/use-cases/delete-team.use-case.ts rename to src/teams/application/use-cases/base/delete-team.use-case.ts index b394542..134bdf6 100644 --- a/src/teams/application/use-cases/delete-team.use-case.ts +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -10,7 +10,6 @@ export class DeleteTeamUseCase { ) {} async execute(slug: string, userId: string) { - // 1. Ищем команду по слагу const team = await this.teamsRepo.findBySlug(slug); if (!team) { @@ -23,9 +22,6 @@ export class DeleteTeamUseCase { ); } - // 2. Проверяем права (бизнес-логика удаления) - // Владелец определяется либо через ownerId в таблице команд, - // либо через роль 'owner' в таблице участников. const member = await this.teamsRepo.findMember(team.id, userId); const isOwner = team.ownerId === userId || member?.role === 'owner'; @@ -39,7 +35,6 @@ export class DeleteTeamUseCase { ); } - // 3. Выполняем удаление try { const result = await this.teamsRepo.remove(team.id, userId); @@ -48,6 +43,8 @@ export class DeleteTeamUseCase { message: 'Команда успешно удалена', }; } catch (error) { + if (error instanceof BaseException) throw error; + throw new BaseException( { code: 'TEAM_DELETE_FAILED', diff --git a/src/teams/application/use-cases/find-team.query.ts b/src/teams/application/use-cases/base/find-team.query.ts similarity index 82% rename from src/teams/application/use-cases/find-team.query.ts rename to src/teams/application/use-cases/base/find-team.query.ts index b7b7fa0..58bae3c 100644 --- a/src/teams/application/use-cases/find-team.query.ts +++ b/src/teams/application/use-cases/base/find-team.query.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../domain/repository'; +import { ITeamsRepository } from '../../../domain/repository'; @Injectable() export class FindTeamQuery { diff --git a/src/teams/application/use-cases/get-all-tags.use-case.ts b/src/teams/application/use-cases/base/get-all-tags.use-case.ts similarity index 95% rename from src/teams/application/use-cases/get-all-tags.use-case.ts rename to src/teams/application/use-cases/base/get-all-tags.use-case.ts index 4e7890f..de51e75 100644 --- a/src/teams/application/use-cases/get-all-tags.use-case.ts +++ b/src/teams/application/use-cases/base/get-all-tags.use-case.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { FindTagsQuery } from '../dtos'; +import { FindTagsQuery } from '../../dtos'; import { ITeamsRepository } from '@core/teams/domain/repository'; @Injectable() diff --git a/src/teams/application/use-cases/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts similarity index 100% rename from src/teams/application/use-cases/get-my-teams.use-case.ts rename to src/teams/application/use-cases/base/get-my-teams.use-case.ts diff --git a/src/teams/application/use-cases/sync-team-tags.use-case.ts b/src/teams/application/use-cases/base/sync-team-tags.use-case.ts similarity index 100% rename from src/teams/application/use-cases/sync-team-tags.use-case.ts rename to src/teams/application/use-cases/base/sync-team-tags.use-case.ts diff --git a/src/teams/application/use-cases/update-team-avatar.use-case.ts b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts similarity index 100% rename from src/teams/application/use-cases/update-team-avatar.use-case.ts rename to src/teams/application/use-cases/base/update-team-avatar.use-case.ts diff --git a/src/teams/application/use-cases/update-team-banner.use-case.ts b/src/teams/application/use-cases/base/update-team-banner.use-case.ts similarity index 100% rename from src/teams/application/use-cases/update-team-banner.use-case.ts rename to src/teams/application/use-cases/base/update-team-banner.use-case.ts diff --git a/src/teams/application/use-cases/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts similarity index 90% rename from src/teams/application/use-cases/update-team.use-case.ts rename to src/teams/application/use-cases/base/update-team.use-case.ts index 7525c5a..745d438 100644 --- a/src/teams/application/use-cases/update-team.use-case.ts +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, HttpStatus } from '@nestjs/common'; -import { ITeamsRepository } from '../../domain/repository'; -import type { UpdateTeamDto } from '../dtos'; +import { ITeamsRepository } from '../../../domain/repository'; +import type { UpdateTeamDto } from '../../dtos'; import { BaseException } from '@shared/error'; @Injectable() @@ -13,7 +13,7 @@ export class UpdateTeamUseCase { async execute(slug: string, userId: string, dto: UpdateTeamDto) { const team = await this.teamsRepo.findBySlug(slug); - if (!team) { + if (!team?.id) { throw new BaseException( { code: 'TEAM_NOT_FOUND', @@ -47,6 +47,8 @@ export class UpdateTeamUseCase { message: 'Данные команды успешно обновлены', }; } catch (error) { + if (error instanceof BaseException) throw error; + throw new BaseException( { code: 'TEAM_UPDATE_FAILED', diff --git a/src/teams/application/use-cases/get-user-invites.use-case.ts b/src/teams/application/use-cases/get-user-invites.use-case.ts deleted file mode 100644 index 9937531..0000000 --- a/src/teams/application/use-cases/get-user-invites.use-case.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { TeamMemberMapper } from '@core/teams/application/mappers'; - -@Injectable() -export class GetUserInvitesUseCase { - constructor( - @InjectRedis() - private readonly redis: Redis, - ) {} - - async execute(email: string) { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - } -} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts index 3bb1124..9a63685 100644 --- a/src/teams/application/use-cases/index.ts +++ b/src/teams/application/use-cases/index.ts @@ -1,26 +1,25 @@ -import { CheckTeamSlugQuery } from './check-team-slug.query'; -import { FindTeamQuery } from './find-team.query'; -import { FindTeamMemberQuery } from './find-team-member.query'; -import { GetInvitationQuery } from './get-invitation.query'; -import { GetInvitationsQuery } from './get-invitations.query'; -import { GetTeamMembersQuery } from './get-team-members.query'; -import { GetAllTagsUseCase } from './get-all-tags.use-case'; -import { GetMyInvitesUseCase } from './get-my-invites.use-case'; -import { GetMyTeamsUseCase } from './get-my-teams.use-case'; -import { GetUserInvitesUseCase } from './get-user-invites.use-case'; +import { CheckTeamSlugQuery } from './base/check-team-slug.query'; +import { FindTeamQuery } from './base/find-team.query'; +import { FindTeamMemberQuery } from './members/find-team-member.query'; +import { GetInvitationQuery } from './invitions/get-invitation.query'; +import { GetInvitationsQuery } from './invitions/get-invitations.query'; +import { GetTeamMembersQuery } from './members/get-team-members.query'; +import { GetAllTagsUseCase } from './base/get-all-tags.use-case'; +import { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; +import { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; -import { AcceptInvitationUseCase } from './accept-invitation.use-case'; -import { CreateTeamUseCase } from './create-team.use-case'; -import { DeleteTeamUseCase } from './delete-team.use-case'; -import { RemoveTeamMemberUseCase } from './remove-team-member.use-case'; -import { SendInvitationUseCase } from './send-invitation.use-case'; -import { SyncTeamTagsUseCase } from './sync-team-tags.use-case'; -import { UpdateTeamUseCase } from './update-team.use-case'; -import { UpdateTeamAvatarUseCase } from './update-team-avatar.use-case'; -import { UpdateTeamBannerUseCase } from './update-team-banner.use-case'; -import { UpdateTeamMemberUseCase } from './update-team-member.use-case'; -import { UpdateInvitationUseCase } from './update-invitation.use-case'; -import { DeclineInvitationUseCase } from './decline-invitation.use-case'; +import { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; +import { CreateTeamUseCase } from './base/create-team.use-case'; +import { DeleteTeamUseCase } from './base/delete-team.use-case'; +import { RemoveTeamMemberUseCase } from './members/remove-team-member.use-case'; +import { SendInvitationUseCase } from './invitions/send-invitation.use-case'; +import { SyncTeamTagsUseCase } from './base/sync-team-tags.use-case'; +import { UpdateTeamUseCase } from './base/update-team.use-case'; +import { UpdateTeamAvatarUseCase } from './base/update-team-avatar.use-case'; +import { UpdateTeamBannerUseCase } from './base/update-team-banner.use-case'; +import { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; +import { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; +import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; export { CheckTeamSlugQuery, @@ -32,7 +31,6 @@ export { GetAllTagsUseCase, GetMyInvitesUseCase, GetMyTeamsUseCase, - GetUserInvitesUseCase, AcceptInvitationUseCase, CreateTeamUseCase, DeleteTeamUseCase, @@ -57,7 +55,6 @@ export const TeamQueries = [ GetAllTagsUseCase, GetMyInvitesUseCase, GetMyTeamsUseCase, - GetUserInvitesUseCase, ]; export const TeamUseCases = [ diff --git a/src/teams/application/use-cases/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts similarity index 98% rename from src/teams/application/use-cases/accept-invitation.use-case.ts rename to src/teams/application/use-cases/invitions/accept-invitation.use-case.ts index 46fabe2..af300ea 100644 --- a/src/teams/application/use-cases/accept-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import type { TeamInvite } from '../dtos/invitation.dto'; +import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class AcceptInvitationUseCase { diff --git a/src/teams/application/use-cases/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts similarity index 98% rename from src/teams/application/use-cases/decline-invitation.use-case.ts rename to src/teams/application/use-cases/invitions/decline-invitation.use-case.ts index 957ca1e..b2231e1 100644 --- a/src/teams/application/use-cases/decline-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class DeclineInvitationUseCase { diff --git a/src/teams/application/use-cases/get-invitation.query.ts b/src/teams/application/use-cases/invitions/get-invitation.query.ts similarity index 97% rename from src/teams/application/use-cases/get-invitation.query.ts rename to src/teams/application/use-cases/invitions/get-invitation.query.ts index 0abde02..23f4332 100644 --- a/src/teams/application/use-cases/get-invitation.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class GetInvitationQuery { diff --git a/src/teams/application/use-cases/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts similarity index 97% rename from src/teams/application/use-cases/get-invitations.query.ts rename to src/teams/application/use-cases/invitions/get-invitations.query.ts index 5e7f50a..4a45b97 100644 --- a/src/teams/application/use-cases/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class GetInvitationsQuery { diff --git a/src/teams/application/use-cases/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts similarity index 100% rename from src/teams/application/use-cases/get-my-invites.use-case.ts rename to src/teams/application/use-cases/invitions/get-my-invites.use-case.ts diff --git a/src/teams/application/use-cases/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts similarity index 97% rename from src/teams/application/use-cases/send-invitation.use-case.ts rename to src/teams/application/use-cases/invitions/send-invitation.use-case.ts index b8e9c19..415ee39 100644 --- a/src/teams/application/use-cases/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -6,10 +6,10 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; import Redis from 'ioredis'; -import { InviteMemberDto } from '../dtos'; +import { InviteMemberDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { generateSecret } from 'otplib'; -import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvite } from '../../dtos/invitation.dto'; import { TeamInvitationEvent } from '@core/teams/domain/events'; @Injectable() diff --git a/src/teams/application/use-cases/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts similarity index 96% rename from src/teams/application/use-cases/update-invitation.use-case.ts rename to src/teams/application/use-cases/invitions/update-invitation.use-case.ts index c15f451..d117435 100644 --- a/src/teams/application/use-cases/update-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -2,9 +2,9 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import { UpdateInvitationDto } from '../dtos'; +import { UpdateInvitationDto } from '../../dtos'; import { BaseException } from '@shared/error'; -import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class UpdateInvitationUseCase { diff --git a/src/teams/application/use-cases/find-team-member.query.ts b/src/teams/application/use-cases/members/find-team-member.query.ts similarity index 84% rename from src/teams/application/use-cases/find-team-member.query.ts rename to src/teams/application/use-cases/members/find-team-member.query.ts index ee38870..291ce9f 100644 --- a/src/teams/application/use-cases/find-team-member.query.ts +++ b/src/teams/application/use-cases/members/find-team-member.query.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../domain/repository'; +import { ITeamsRepository } from '../../../domain/repository'; @Injectable() export class FindTeamMemberQuery { diff --git a/src/teams/application/use-cases/get-team-members.query.ts b/src/teams/application/use-cases/members/get-team-members.query.ts similarity index 100% rename from src/teams/application/use-cases/get-team-members.query.ts rename to src/teams/application/use-cases/members/get-team-members.query.ts diff --git a/src/teams/application/use-cases/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts similarity index 65% rename from src/teams/application/use-cases/remove-team-member.use-case.ts rename to src/teams/application/use-cases/members/remove-team-member.use-case.ts index 835e3af..a8c219f 100644 --- a/src/teams/application/use-cases/remove-team-member.use-case.ts +++ b/src/teams/application/use-cases/members/remove-team-member.use-case.ts @@ -1,6 +1,7 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ROLE_PRIORITY } from '@shared/constants'; +import type { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; @Injectable() @@ -8,6 +9,7 @@ export class RemoveTeamMemberUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + private readonly policy: TeamMemberPolicy, ) {} async execute(slug: string, currentUserId: string, targetUserId: string) { @@ -30,6 +32,7 @@ export class RemoveTeamMemberUseCase { HttpStatus.NOT_FOUND, ); } + if (!currentUser) { throw new BaseException( { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, @@ -39,29 +42,22 @@ export class RemoveTeamMemberUseCase { const isSelfRemoval = currentUserId === targetUserId; - if (isSelfRemoval) { - if (currentUser.role === 'owner') { - throw new BaseException( - { code: 'OWNER_CANNOT_LEAVE', message: 'Владелец не может покинуть команду' }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; - const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; + const canRemove = this.policy.canRemove( + currentUser.role as TeamRole, + targetUser.role as TeamRole, + isSelfRemoval, + ); + + if (!canRemove) { + const errorCode = isSelfRemoval ? 'OWNER_CANNOT_LEAVE' : 'KICK_FORBIDDEN'; + const errorMessage = isSelfRemoval + ? 'Владелец не может покинуть команду без передачи прав' + : 'У вас недостаточно прав, чтобы исключить этого участника'; - if (!hasAuthority || !canKick) { - throw new BaseException( - { - code: 'KICK_FORBIDDEN', - message: 'У вас недостаточно прав, чтобы исключить этого участника', - details: [ - { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, - ], - }, - HttpStatus.FORBIDDEN, - ); - } + throw new BaseException( + { code: errorCode, message: errorMessage }, + HttpStatus.FORBIDDEN, + ); } try { @@ -73,6 +69,8 @@ export class RemoveTeamMemberUseCase { : `Участник успешно исключен из команды ${team.name}`, }; } catch (error) { + if (error instanceof BaseException) throw error; + throw new BaseException( { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, HttpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/teams/application/use-cases/members/update-team-member.use-case.ts new file mode 100644 index 0000000..84813dd --- /dev/null +++ b/src/teams/application/use-cases/members/update-team-member.use-case.ts @@ -0,0 +1,103 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { UpdateMemberDto } from '../../dtos'; +import { BaseException } from '@shared/error'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { TeamRole } from '@shared/entities'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly teamMemberPolicy: TeamMemberPolicy, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string, dto: UpdateMemberDto) { + if (currentUserId === targetUserId) { + throw new BaseException( + { code: 'SELF_EDIT_RESTRICTED', message: 'Вы не можете редактировать свои данные' }, + HttpStatus.BAD_REQUEST, + ); + } + + const team = await this.teamsRepo.findBySlug(slug); + 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 BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } + + const issuerRole = currentUser.role as TeamRole; + const targetRole = targetUser.role as TeamRole; + + if (!this.teamMemberPolicy.canManage(issuerRole, targetRole)) { + throw new BaseException( + { code: 'INSUFFICIENT_RANK', message: 'Ваш ранг должен быть выше ранга цели' }, + HttpStatus.FORBIDDEN, + ); + } + + if (dto.role) { + if (!this.teamMemberPolicy.canAssignRole(issuerRole, targetRole, dto.role)) { + throw new BaseException( + { + code: 'INVALID_ROLE_ASSIGNMENT', + message: 'У вас нет прав назначить выбранную роль', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + if (dto.status) { + if (!this.teamMemberPolicy.canChangeStatus(issuerRole, targetRole)) { + throw new BaseException( + { + code: 'INVALID_STATUS_CHANGE', + message: 'Вы не можете менять статус этого участника', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды ${team.name} успешно обновлены`, + }; + } catch (error) { + if (error instanceof BaseException) throw error; + + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/update-team-member.use-case.ts b/src/teams/application/use-cases/update-team-member.use-case.ts deleted file mode 100644 index 9ba21d4..0000000 --- a/src/teams/application/use-cases/update-team-member.use-case.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { UpdateMemberDto } from '../dtos'; -import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; - -@Injectable() -export class UpdateTeamMemberUseCase { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - ) {} - - async execute(slug: string, currentUserId: string, targetUserId: string, dto: UpdateMemberDto) { - const team = await this.teamsRepo.findBySlug(slug); - 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 BaseException( - { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, - HttpStatus.NOT_FOUND, - ); - } - - // 1. Проверка минимальной роли для редактирования - if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new BaseException( - { - code: 'ADMIN_ROLE_REQUIRED', - message: 'У вас нет прав на редактирование участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - // 2. Нельзя менять роль тому, кто выше или равен по весу - if ( - currentUserId !== targetUserId && - ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] - ) { - throw new BaseException( - { - code: 'INSUFFICIENT_RANK', - message: 'Вы не можете менять данные участника с равным или высшим рангом', - details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], - }, - HttpStatus.FORBIDDEN, - ); - } - - // 3. Защита Owner - if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { - throw new BaseException( - { - code: 'OWNER_PROTECTION_VIOLATION', - message: 'Нельзя изменить роль владельца через это меню', - }, - HttpStatus.BAD_REQUEST, - ); - } - - // 4. Нельзя назначить роль выше своей - if ( - dto.role && - ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && - currentUser.role !== 'owner' - ) { - throw new BaseException( - { - code: 'CANNOT_ASSIGN_HIGHER_ROLE', - message: 'Вы не можете назначить роль выше своей или равную своей', - }, - HttpStatus.FORBIDDEN, - ); - } - - 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, - ); - } - } -} diff --git a/src/teams/domain/policy/index.ts b/src/teams/domain/policy/index.ts new file mode 100644 index 0000000..f076bce --- /dev/null +++ b/src/teams/domain/policy/index.ts @@ -0,0 +1 @@ +export { TeamMemberPolicy } from './team-member.policy'; diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts new file mode 100644 index 0000000..f4c71e0 --- /dev/null +++ b/src/teams/domain/policy/team-member.policy.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY } from '@shared/constants'; +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class TeamMemberPolicy { + constructor() {} + + private getPriority(role: TeamRole): number { + return ROLE_PRIORITY[role] ?? 0; + } + + /** + * Может ли Инициатор вообще редактировать Цель? + */ + public canManage(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Минимальный порог для управления — администратор + if (this.getPriority(issuerRole) < ROLE_PRIORITY.admin) return false; + + // Нельзя редактировать того, кто равен или выше по рангу + return this.getPriority(issuerRole) > this.getPriority(targetRole); + } + + /** + * Может ли Инициатор назначить Цели новую роль? + */ + public canAssignRole( + issuerRole: TeamRole, + targetCurrentRole: TeamRole, + newRole: TeamRole, + ): boolean { + // 1. Проверка прав на управление целью + if (!this.canManage(issuerRole, targetCurrentRole)) return false; + + // 2. Роль Owner неприкосновенна (нельзя снять и нельзя назначить через обычный Update) + if (targetCurrentRole === 'owner' || newRole === 'owner') return false; + + // 3. Нельзя назначить роль выше своей или равную своей (если ты не владелец) + if (issuerRole !== 'owner' && this.getPriority(newRole) >= this.getPriority(issuerRole)) { + return false; + } + + return true; + } + + /** + * Может ли Инициатор менять статус (ban/block/active) Цели? + */ + public canChangeStatus(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Владельца нельзя забанить или деактивировать + if (targetRole === 'owner') return false; + + // В остальном работают стандартные правила иерархии + return this.canManage(issuerRole, targetRole); + } + + /** + * Может ли Инициатор удалить Цель (или самого себя)? + */ + canRemove(issuerRole: TeamRole, targetRole: TeamRole, isSelf: boolean): boolean { + if (isSelf) { + return issuerRole !== 'owner'; + } + + const issuerPrio = this.getPriority(issuerRole); + const targetPrio = this.getPriority(targetRole); + + return issuerPrio >= ROLE_PRIORITY.admin && issuerPrio > targetPrio; + } +} diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts index f02a9c9..06b83a1 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -1,6 +1,6 @@ import type { Team, NewTeam, NewTeamMember, Tag } from '../entities'; -type TResponse = { success: boolean; tags: number; teamId: string }; +type TResponse = { success: boolean; teamId: string }; export type RawMemberRow = { userId: string; diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts index b4e4294..a11c275 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -39,12 +39,12 @@ export class TeamsRepository implements ITeamsRepository { .values({ ...dto, ownerId }) .returning({ teamId: schema.teams.id }); - let insertedTagsCount = 0; - if (tags?.length) { + const names = tags.map((n) => ({ name: n })); + const insertedTags = await tx .insert(schema.tags) - .values(tags.map((name) => ({ name }))) + .values(names) .onConflictDoUpdate({ target: schema.tags.name, set: { name: sql`${schema.tags.name}` }, @@ -52,14 +52,9 @@ export class TeamsRepository implements ITeamsRepository { .returning({ id: schema.tags.id }); if (insertedTags.length > 0) { - await tx.insert(schema.teamsToTags).values( - insertedTags.map((tag) => ({ - teamId, - tagId: tag.id, - })), - ); - - insertedTagsCount = insertedTags.length; + const tags = insertedTags.map((t) => ({ teamId, tagId: t.id })); + + await tx.insert(schema.teamsToTags).values(tags); } } @@ -74,7 +69,6 @@ export class TeamsRepository implements ITeamsRepository { return { success: true, teamId, - tags: insertedTagsCount, }; }); }; @@ -88,12 +82,12 @@ export class TeamsRepository implements ITeamsRepository { .returning({ teamId: schema.teams.id }); if (tags?.length) { + // TODO: FEAT AT FEATURE } return { success: true, teamId, - tags: 0, }; }); }; diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 871afe7..11a7281 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -16,6 +16,7 @@ import { TeamQueues } from './domain/enums'; import { TeamsFacade } from './application/team.facade'; import { TeamQueries, TeamUseCases, TEAM_EXTERNAL_QUERIES } from './application/use-cases'; import { MediaModule } from '@shared/media'; +import { TeamMemberPolicy } from './domain/policy'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @@ -58,7 +59,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], + providers: [TeamMemberPolicy, REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], exports: [...TEAM_EXTERNAL_QUERIES], }) export class TeamsModule {} diff --git a/src/user/application/use-cases/register-user.use-case.ts b/src/user/application/use-cases/register-user.use-case.ts index 7e150f8..88167ee 100644 --- a/src/user/application/use-cases/register-user.use-case.ts +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -39,6 +39,10 @@ export class RegisterUserUseCase { return user; } catch (error) { + if (error instanceof BaseException) { + throw error; + } + throw new BaseException( { code: 'USER_REGISTRATION_FAILED', diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts index 4a43377..9ce8056 100644 --- a/src/user/application/use-cases/update-profile.use-case.ts +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -12,7 +12,16 @@ export class UpdateProfileUseCase { ) {} async execute(id: string, dto: UpdateProfileDto) { - const isUpdated = await this.userRepo.updateProfile(id, dto); + const entity = await this.userRepo.findById(id); + + if (!entity.user) { + throw new BaseException( + { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const isUpdated = await this.userRepo.updateProfile(entity.user.id, dto); if (!isUpdated) { throw new BaseException( @@ -27,6 +36,6 @@ export class UpdateProfileUseCase { eventType: 'PROFILE_UPDATED', }); - return { success: true }; + return { success: true, message: 'Профиль успешно обновлен' }; } } From 14dd4705f41bfebd5be83f19592f166d1d56dc0e Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 1 May 2026 22:14:15 +0300 Subject: [PATCH 9/9] refactor(teams): overhaul invitation flows --- .../invitions/accept-invitation.use-case.ts | 33 +++-- .../invitions/decline-invitation.use-case.ts | 112 ++++++++-------- .../invitions/get-invitation.query.ts | 56 +++++--- .../invitions/get-invitations.query.ts | 73 ++++++----- .../invitions/get-my-invites.use-case.ts | 28 +++- .../invitions/send-invitation.use-case.ts | 120 +++++++++++++----- .../invitions/update-invitation.use-case.ts | 76 +++++++---- src/teams/domain/policy/team-member.policy.ts | 21 ++- 8 files changed, 337 insertions(+), 182 deletions(-) diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts index af300ea..16864a2 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -29,7 +29,7 @@ export class AcceptInvitationUseCase { } const invite = JSON.parse(inviteRaw) as TeamInvite; - if (invite.email.toLowerCase() !== email.toLowerCase()) { + if (invite?.email?.toLowerCase() !== email.toLowerCase()) { throw new BaseException( { code: 'INVITE_EMAIL_MISMATCH', @@ -64,20 +64,31 @@ export class AcceptInvitationUseCase { joinedAt: new Date(), }); - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(invite.teamId), code); - multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); - await multi.exec(); + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(invite.teamId), code) + .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) + .exec(); return { success: true, message: 'Вы успешно присоединились к команде' }; } + private checkMemberStatus(member: any) { + if (member?.status === 'banned') { + // throw new BaseException({ code: 'MEMBER_BANNED' }, 403); + } + if (member?.status === 'active') { + // throw new BaseException({ code: 'ALREADY_MEMBER' }, 400); + } + } + private async cleanupInvite(code: string, teamId: string, email: string) { - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(teamId), code); - multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); - await multi.exec(); + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(teamId), code) + .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) + .exec(); } } diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts index b2231e1..5d4a4bc 100644 --- a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../../dtos/invitation.dto'; +import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class DeclineInvitationUseCase { @@ -16,73 +16,83 @@ export class DeclineInvitationUseCase { @InjectRedis() private readonly redis: Redis, ) {} - async execute(slug: string, code: string, userId: string) { + async execute(slug: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(slug); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + + await this.validateAccess(team.id, userId, userEmail, invite.email); + + await this.cleanupInvite(code, team.id, invite.email); + + return { + success: true, + message: 'Приглашение успешно удалено', + }; + } + + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { + return; + } + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) { + return; + } + + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для отмены этого приглашения', + }, + HttpStatus.FORBIDDEN, + ); + } + + private async getTeamOrThrow(slug: string) { const team = await this.teamsRepo.findBySlug(slug); - if (!team) { + if (!team) throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, HttpStatus.NOT_FOUND, ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'Только администраторы могут удалять приглашения', - details: [{ userId }], - }, - HttpStatus.FORBIDDEN, - ); - } + return team; + } + private async getInviteOrThrow(code: string) { const rawInvite = await this.redis.get(this.INVITES_KEY(code)); if (!rawInvite) { throw new BaseException( - { - code: 'INVITE_ALREADY_REMOVED', - message: 'Приглашение не найдено (возможно, оно уже было принято или удалено)', - details: [{ code }], - }, + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, HttpStatus.NOT_FOUND, ); } + return JSON.parse(rawInvite) as TeamInvite; + } - const invite = JSON.parse(rawInvite) as TeamInvite; - if (invite.teamId !== team.id) { + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { throw new BaseException( - { - code: 'ACCESS_DENIED', - message: 'Вы не можете удалить приглашение чужой команды', - }, + { code: 'ACCESS_DENIED', message: 'Ошибка доступа' }, HttpStatus.FORBIDDEN, ); } + } - try { - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(team.id), code); - multi.srem(this.USER_INVITES_KEY(invite.email), code); - await multi.exec(); - } catch (err) { - if (err instanceof BaseException) { - throw err; - } - - throw new BaseException( - { - code: 'INFRASTRUCTURE_ERROR', - message: 'Не удалось корректно удалить приглашение из системы', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { - success: true, - message: 'Приглашение отозвано администратором', - }; + private async cleanupInvite(code: string, teamId: string, email: string) { + await this.redis + .multi() + .del(this.INVITES_KEY(code)) + .srem(this.TEAM_INVITES_KEY(teamId), code) + .srem(this.USER_INVITES_KEY(email), code) + .exec(); } + ы; } diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/teams/application/use-cases/invitions/get-invitation.query.ts index 23f4332..90f1b22 100644 --- a/src/teams/application/use-cases/invitions/get-invitation.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -3,7 +3,7 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../../dtos/invitation.dto'; +import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class GetInvitationQuery { @@ -14,39 +14,59 @@ export class GetInvitationQuery { @InjectRedis() private readonly redis: Redis, ) {} - async execute(slug: string, code: string, userId: string) { + async execute(slug: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(slug); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + await this.validateAccess(team.id, userId, userEmail, invite.email); + + return { code, ...invite }; + } + + private async getTeamOrThrow(slug: string) { const team = await this.teamsRepo.findBySlug(slug); - if (!team) { + if (!team) throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, HttpStatus.NOT_FOUND, ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, - HttpStatus.FORBIDDEN, - ); - } + return team; + } + private async getInviteOrThrow(code: string) { const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { + if (!raw) throw new BaseException( - { code: 'INVITE_EXPIRED_OR_INVALID', message: 'Срок действия истек' }, + { code: 'INVITE_EXPIRED', message: 'Срок действия приглашения истек' }, HttpStatus.NOT_FOUND, ); - } + return JSON.parse(raw) as TeamInvite; + } - const invite = JSON.parse(raw) as TeamInvite; - if (invite.teamId !== team.id) { + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { throw new BaseException( { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, HttpStatus.NOT_FOUND, ); } + } - return { code, ...invite }; + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) return; + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) return; + + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав просмотра' }, + HttpStatus.FORBIDDEN, + ); } } diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index 4a45b97..11f7d6a 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -3,7 +3,6 @@ import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import Redis from 'ioredis'; -import { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() export class GetInvitationsQuery { @@ -15,44 +14,52 @@ export class GetInvitationsQuery { @InjectRedis() private readonly redis: Redis, ) {} - async execute(slug: string, userId?: string) { + async execute(slug: string, userId: string) { + const team = await this.getTeamOrThrow(slug); + await this.ensureAdminPermissions(team.id, userId); + + const teamKey = this.TEAM_INVITES_KEY(team.id); + const codes = await this.redis.smembers(teamKey); + if (!codes.length) return []; + + const results = await this.redis.mget(...codes.map(this.INVITES_KEY)); + + const { active, expired } = results.reduce( + (acc, raw, i) => { + if (raw) { + acc.active.push({ code: codes[i], ...JSON.parse(raw) }); + } else { + acc.expired.push(codes[i]); + } + return acc; + }, + { active: [], expired: [] }, + ); + + if (expired.length > 0) { + this.redis.srem(teamKey, ...expired).catch((e) => console.error('Cleanup error:', e)); + } + + return active; + } + + private async getTeamOrThrow(slug: string) { const team = await this.teamsRepo.findBySlug(slug); - if (!team) { + if (!team) throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, HttpStatus.NOT_FOUND, ); - } + return team; + } - if (userId) { - const member = await this.teamsRepo.findMember(team.id, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав управлять приглашениями', - }, - HttpStatus.FORBIDDEN, - ); - } + private async ensureAdminPermissions(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, + HttpStatus.FORBIDDEN, + ); } - - const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); - if (!codes.length) return []; - - const keys = codes.map((c) => this.INVITES_KEY(c)); - const invitesRaw = await this.redis.mget(...keys); - - return invitesRaw - .map((raw, idx) => { - if (!raw) return null; - try { - const invite = JSON.parse(raw) as TeamInvite; - return { code: codes[idx], ...invite }; - } catch { - return null; - } - }) - .filter((v): v is TeamInvite & { code: string } => v !== null); } } diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts index 58795c9..679e5e1 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -11,14 +11,32 @@ export class GetMyInvitesUseCase { ) {} async execute(email: string) { - const codes = await this.redis.smembers(`user:invites:${email}`); + const userKey = `user:invites:${email.toLowerCase()}`; + const codes = await this.redis.smembers(userKey); if (!codes.length) return []; - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + const inviteKeys = codes.map((c) => `inv:code:${c}`); + const results = await this.redis.mget(inviteKeys); - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); + const { activeInvites, expiredCodes } = results.reduce( + (acc, raw, i) => { + if (raw) { + acc.activeInvites.push(TeamMemberMapper.toPublicInvite(raw, codes[i])); + } else { + acc.expiredCodes.push(codes[i]); + } + return acc; + }, + { activeInvites: [], expiredCodes: [] }, + ); + + if (expiredCodes.length > 0) { + this.redis.srem(userKey, ...expiredCodes).catch((err) => { + console.error('Failed to cleanup expired invites:', err); + }); + } + + return activeInvites; } } diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index 415ee39..ffa3da8 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -9,8 +9,10 @@ import Redis from 'ioredis'; import { InviteMemberDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { generateSecret } from 'otplib'; -import { TeamInvite } from '../../dtos/invitation.dto'; +import type { TeamInvite } from '../../dtos/invitation.dto'; import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import type { TeamRole } from '@shared/entities'; @Injectable() export class SendInvitationUseCase { @@ -24,68 +26,116 @@ export class SendInvitationUseCase { @InjectRedis() private readonly redis: Redis, @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, private readonly cfg: ConfigService, + private readonly policy: TeamMemberPolicy, ) {} async execute(slug: string, inviterId: string, dto: InviteMemberDto) { + const team = await this.getTeamOrThrow(slug); + const inviter = await this.getInviterOrThrow(team.id, inviterId); + + this.validatePermissions(inviter.role as TeamRole, dto.role as TeamRole); + await this.ensureNotAlreadyMember(team.id, dto.email); + await this.ensureNoPendingInvite(team.id, dto.email); + + const code = generateSecret({ length: 8 }); + const inviteData = this.buildInviteData(team, inviter, dto); + + await this.saveInviteToCache(code, inviteData); + + await this.sendEmailNotification(code, team.name, dto.email); + + return { success: true, message: `Приглашение отправлено на ${dto.email.toLowerCase()}` }; + } + + private async getTeamOrThrow(slug: string) { const team = await this.teamsRepo.findBySlug(slug); - if (!team) { + if (!team) throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Team does not exist', - }, + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, HttpStatus.NOT_FOUND, ); - } + return team; + } + + private async getInviterOrThrow(teamId: string, userId: string) { + const inviter = await this.teamsRepo.findMember(teamId, userId); + if (!inviter) + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + return inviter; + } - const inviter = await this.teamsRepo.findMember(team.id, inviterId); - if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { + if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'Only admins or owners can invite new members', - }, + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, HttpStatus.FORBIDDEN, ); } + } - // TODO AVOID DUPLICATE INVITIONS + private async ensureNotAlreadyMember(teamId: string, email: string) { + const member = await this.teamsRepo.findMember(teamId, email); // Тут лучше искать по email в репо + if (member) + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'Уже в команде' }, + HttpStatus.BAD_REQUEST, + ); + } - const code = generateSecret({ length: 8 }); - const now = new Date(); - const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); + private async ensureNoPendingInvite(teamId: string, email: string) { + const activeCodes = await this.redis.smembers(this.USER_INVITES_KEY(email)); + if (activeCodes.length === 0) return; + + const invitesData = await this.redis.mget(activeCodes.map(this.INVITES_KEY)); + const hasDuplicate = invitesData + .filter((d): d is string => !!d) + .map((d) => JSON.parse(d) as TeamInvite) + .some((i) => i.teamId === teamId); + + if (hasDuplicate) { + throw new BaseException( + { code: 'INVITATION_ALREADY_SENT', message: 'Приглашение уже в пути' }, + HttpStatus.BAD_REQUEST, + ); + } + } - const inviteData: TeamInvite = { + private buildInviteData(team: any, inviter: any, dto: InviteMemberDto): TeamInvite { + const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); + return { teamId: team.id, teamName: team.name, teamAvatar: team.avatarUrl, - email: dto.email, - role: dto.role || 'member', - inviterId, + email: dto.email.toLowerCase(), + role: (dto.role || 'member') as TeamRole, + inviterId: inviter.userId, inviterName: inviter.firstName, createdAt: new Date().toISOString(), expiresAt: expiresAt.toISOString(), }; + } - const multi = this.redis.multi(); - multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); - multi.sadd(this.TEAM_INVITES_KEY(team.id), code); - multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); - await multi.exec(); + private async saveInviteToCache(code: string, data: TeamInvite) { + await this.redis + .multi() + .set(this.INVITES_KEY(code), JSON.stringify(data), 'EX', this.INVITE_TTL) + .sadd(this.TEAM_INVITES_KEY(data.teamId), code) + .sadd(this.USER_INVITES_KEY(data.email), code) + .exec(); + } - const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); - const FRONTEND_URL = origins[0]; - const event = new TeamInvitationEvent( - dto.email, - team.name, - `${FRONTEND_URL}/invites/accept?code=${code}`, - ); + private async sendEmailNotification(code: string, teamName: string, email: string) { + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS') || []; + const url = `${origins[0]}/invites/accept?code=${code}`; + const event = new TeamInvitationEvent(email, teamName, url); await this.mailQueue.add(TeamMailJobs.SEND_TEAM_INVITATION, event, { attempts: 3, backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: true, }); - - return { success: true, message: `Приглашение отправлено на ${dto.email}`, code }; } } diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts index d117435..3849e91 100644 --- a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -5,6 +5,8 @@ import Redis from 'ioredis'; import { UpdateInvitationDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { TeamInvite } from '../../dtos/invitation.dto'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { TeamRole } from '@shared/entities'; @Injectable() export class UpdateInvitationUseCase { @@ -13,9 +15,29 @@ export class UpdateInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @InjectRedis() private readonly redis: Redis, + private readonly policy: TeamMemberPolicy, ) {} async execute(slug: string, code: string, userId: string, dto: UpdateInvitationDto) { + const team = await this.getTeamOrThrow(slug); + const member = await this.getMemberOrThrow(team.id, userId); + + const key = this.INVITES_KEY(code); + const { invite, ttl } = await this.getInviteContextOrThrow(key); + + this.validateInviteOwnership(invite, team.id); + this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); + + invite.role = dto.role as TeamRole; + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + + return { + success: true, + message: 'Роль в приглашении успешно обновлена', + }; + } + + private async getTeamOrThrow(slug: string) { const team = await this.teamsRepo.findBySlug(slug); if (!team) { throw new BaseException( @@ -23,58 +45,56 @@ export class UpdateInvitationUseCase { HttpStatus.NOT_FOUND, ); } + return team; + } - const member = await this.teamsRepo.findMember(team.id, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + private async getMemberOrThrow(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав на редактирование приглашений в этой команде', - details: [{ requiredRoles: ['owner', 'admin'], currentRole: member?.role }], - }, + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, HttpStatus.FORBIDDEN, ); } + return member; + } - const key = this.INVITES_KEY(code); + private async getInviteContextOrThrow(key: string) { const [rawInvite, ttl] = await Promise.all([this.redis.get(key), this.redis.ttl(key)]); if (!rawInvite || ttl <= 0) { throw new BaseException( { code: 'INVITE_NOT_FOUND_OR_EXPIRED', - message: 'Приглашение не найдено или его срок действия уже истек', - details: [{ code }], + message: 'Приглашение не найдено или истекло', }, HttpStatus.NOT_FOUND, ); } - const invite = JSON.parse(rawInvite) as TeamInvite; + return { invite: JSON.parse(rawInvite) as TeamInvite, ttl }; + } - if (invite.teamId !== team.id) { + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { throw new BaseException( - { - code: 'INVITE_TEAM_MISMATCH', - message: 'Это приглашение принадлежит другой команде', - details: [{ inviteTeamId: invite.teamId, requestTeamId: team.id }], - }, + { code: 'INVITE_TEAM_MISMATCH', message: 'Приглашение принадлежит другой команде' }, HttpStatus.BAD_REQUEST, ); } + } - invite.role = dto.role; + private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { + const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); - if (ttl > 0) { - await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); - } else { - await this.redis.set(key, JSON.stringify(invite)); + if (!canUpdate) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для назначения этой роли', + }, + HttpStatus.FORBIDDEN, + ); } - - return { - success: true, - message: 'Приглашение успешно обновлено', - details: { code, role: invite.role, email: invite.email }, - }; } } diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts index f4c71e0..46a8c96 100644 --- a/src/teams/domain/policy/team-member.policy.ts +++ b/src/teams/domain/policy/team-member.policy.ts @@ -57,7 +57,7 @@ export class TeamMemberPolicy { /** * Может ли Инициатор удалить Цель (или самого себя)? */ - canRemove(issuerRole: TeamRole, targetRole: TeamRole, isSelf: boolean): boolean { + public canRemove(issuerRole: TeamRole, targetRole: TeamRole, isSelf: boolean): boolean { if (isSelf) { return issuerRole !== 'owner'; } @@ -67,4 +67,23 @@ export class TeamMemberPolicy { return issuerPrio >= ROLE_PRIORITY.admin && issuerPrio > targetPrio; } + + /** + * Может ли Инициатор приглашать новых участников с определенной ролью? + */ + public canInvite(issuerRole: TeamRole, newMemberRole: TeamRole): boolean { + const issuerPrio = this.getPriority(issuerRole); + const newRolePrio = this.getPriority(newMemberRole); + + // Только админы и выше могут приглашать + if (issuerPrio < ROLE_PRIORITY.admin) return false; + + // Нельзя пригласить кого-то на роль выше или равную своей (кроме owner) + if (issuerRole !== 'owner' && newRolePrio >= issuerPrio) return false; + + // Нельзя пригласить на роль owner через обычный инвайт + if (newMemberRole === 'owner') return false; + + return true; + } }