diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml index 3836bf7..50ce996 100644 --- a/infra/dev/compose.dev.yaml +++ b/infra/dev/compose.dev.yaml @@ -1,4 +1,4 @@ -version: "3.9" +version: '3.9' name: task-tracker-api @@ -10,7 +10,7 @@ services: env_file: - .env ports: - - "3000:3000" + - '3000:3000' depends_on: database: condition: service_healthy @@ -21,10 +21,10 @@ services: deploy: resources: limits: - cpus: "2.0" + cpus: '2.0' memory: 1024M reservations: - cpus: "0.5" + cpus: '0.5' memory: 256M database: @@ -39,22 +39,21 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} ports: - - "6000:5432" + - '6000:5432' volumes: - postgres_data:/var/lib/postgresql/data networks: - backend healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\" -q - || exit 1", + test: [ + 'CMD-SHELL', + 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q + || exit 1', ] interval: 5s timeout: 5s retries: 5 - profiles: [ "infra" ] + profiles: ['infra'] redis: hostname: redis @@ -62,18 +61,18 @@ services: image: redis:7-alpine restart: always ports: - - "${REDIS_PORT:-6999}:6379" + - '${REDIS_PORT:-6999}:6379' command: redis-server --save 60 1 --loglevel notice volumes: - redis_data:/data networks: - backend healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 5 - profiles: [ "infra" ] + profiles: ['infra'] minio: hostname: minio @@ -84,14 +83,14 @@ services: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} ports: - - "9000:9000" # API - - "9001:9001" # Console (UI) + - '9000:9000' # API + - '9001:9001' # Console (UI) command: server /data --console-address ":9001" volumes: - minio_data:/data networks: - backend - profiles: [ "infra" ] + profiles: ['infra'] minio-init: image: minio/mc:latest @@ -102,7 +101,7 @@ services: MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} networks: - backend - profiles: [ "infra" ] + profiles: ['infra'] entrypoint: > /bin/sh -c " sleep 5; mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; mc mb myminio/${S3_BUCKET_NAME} 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/modules/app/app.module.ts b/src/app.module.ts similarity index 80% rename from src/modules/app/app.module.ts rename to src/app.module.ts index 7d796ef..0270abf 100644 --- a/src/modules/app/app.module.ts +++ b/src/app.module.ts @@ -2,22 +2,20 @@ 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 './user'; import { GlobalExceptionFilter } from '@shared/error'; -import { AuthModule } from '../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 { MigrationService } from '@shared/migration'; -import { TeamsModule } from '../teams'; -import { ProjectsModule } from '../projects'; +import { MailModule } from '@shared/adapters/mail'; +import { TeamsModule } from './teams'; +import { ProjectsModule } from './projects'; @Module({ imports: [ @@ -62,12 +60,7 @@ import { ProjectsModule } from '../projects'; HealthModule.register('gateway'), ], 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 89% rename from src/modules/auth/controller/auth.controller.ts rename to src/auth/application/controller/auth/controller.ts index 9280fd5..d2f43c3 100644 --- a/src/modules/auth/controller/auth.controller.ts +++ b/src/auth/application/controller/auth/controller.ts @@ -1,21 +1,21 @@ import { Body, HttpCode, HttpStatus, 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'; import { ApiBaseController } from '@shared/decorators'; @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, @@ -85,7 +85,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..fe044cb --- /dev/null +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -0,0 +1,65 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import * as argon from 'argon2'; +import Redis from 'ioredis'; +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 updatePasswordUserUseCase: UpdatePasswordUseCase, + ) {} + + 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.updatePasswordUserUseCase.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..450edfb --- /dev/null +++ b/src/auth/application/use-cases/index.ts @@ -0,0 +1,30 @@ +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/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..f9a853e --- /dev/null +++ b/src/auth/application/use-cases/refresh-tokens.use-case.ts @@ -0,0 +1,74 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +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 { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQuery: FindUserQuery, + ) {} + + 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 entity = await this.findUserQuery.execute({ id: session.userId }); + + if (!entity?.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: entity.user.id, + ...metadata, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }); + + const { access, refresh } = await this.tokenService.generateTokens( + entity.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..f930b56 --- /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 { 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 { + constructor( + @InjectRedis() + private readonly redis: Redis, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: ResetPasswordDto) { + const entity = await this.findUserQuery.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..05c4d04 --- /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 { 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 { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQuery: FindUserQuery, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserQuery.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..e0b1d61 --- /dev/null +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -0,0 +1,90 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { verify as verifyOTP } from 'otplib'; +import { RegisterUserUseCase } from '@core/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 registerUserUseCase: RegisterUserUseCase, + ) {} + + 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); + + if (!userData) { + throw new BaseException( + { + code: 'INTERNAL_DATA_CORRUPTION', + message: 'Ошибка целостности данных. Попробуйте начать регистрацию заново.', + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + 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.registerUserUseCase.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..a893b5e --- /dev/null +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -0,0 +1,85 @@ +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 { FindUserQuery } from '@core/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 findUserQuery: FindUserQuery, + ) {} + + 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.findUserQuery.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 74% rename from src/modules/auth/auth.module.ts rename to src/auth/auth.module.ts index 1ea71d4..3aec5b8 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,16 +1,22 @@ -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/user'; +import { AuthController, AuthRecoveryController } from './application/controller'; +import { AuthFacade } from './application/auth.facade'; +import { AuthUseCases } 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 WORKERS = [MailProcessor]; const REPOSITORY = { provide: 'ISessionRepository', @@ -61,22 +67,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, + ...AuthUseCases, BearerStrategy, - AuthRecoveryService, + REPOSITORY, + 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/shared/workers/events/index.ts b/src/auth/domain/events/index.ts similarity index 65% rename from src/shared/workers/events/index.ts rename to src/auth/domain/events/index.ts index 6430cb9..61a6360 100644 --- a/src/shared/workers/events/index.ts +++ b/src/auth/domain/events/index.ts @@ -1,3 +1,2 @@ export { RegisterCodeEvent } from './register-code.event'; export { ResetPasswordEvent } from './reset-password.event'; -export { TeamInvitationEvent } from './team-invitation.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 97% rename from src/modules/auth/services/token.service.ts rename to src/auth/infrastructure/security/token.service.ts index 72930b1..a3f2480 100644 --- a/src/modules/auth/services/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/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/shared/workers/mail/worker.ts b/src/auth/infrastructure/workers/mail.processor.ts similarity index 70% rename from src/shared/workers/mail/worker.ts rename to src/auth/infrastructure/workers/mail.processor.ts index 3487606..3e4a926 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/auth/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 { RegisterCodeEvent, ResetPasswordEvent, TeamInvitationEvent } from '../events'; +import { RegisterCodeEvent, ResetPasswordEvent } from '../../domain/events'; +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; -@Processor(Queues.MAIL) +@Processor(AuthQueues.AUTH_MAIL) export class MailProcessor extends WorkerHost { constructor( @Inject('IMailPort') @@ -16,21 +16,17 @@ export class MailProcessor extends WorkerHost { 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: + case AuthMailJobs.SEND_REGISTER_CODE: await this.sendRegisterCode(job); break; - case MailJobs.SEND_RESET_PASSWORD: + case AuthMailJobs.SEND_RESET_PASSWORD: await this.sendResetPassCode(job); break; - 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); @@ -73,16 +69,4 @@ export class MailProcessor extends WorkerHost { await job.log(`Reset link delivered to ${email}`); await job.updateProgress(100); }; - - private sendTeamInvitation = async (job: Job) => { - const { email, teamName, inviteUrl } = job.data; - - await job.log(`Sending team(${teamName}) invitation link to: ${email}`); - await job.updateProgress(30); - - await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); - - await job.log(`Team invitation link delivered to ${email}`); - await job.updateProgress(100); - }; } 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/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 ba6312c..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 { user } = await this.findUserCommand.execute({ email: dto.email }); - - if (!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: 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/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts deleted file mode 100644 index 099e8eb..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 { FindTeamMemberCommand } from '@core/modules/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 findTeamMemberCommand: FindTeamMemberCommand, - ) {} - - 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.findTeamMemberCommand.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 daaeac6..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 4ea0667..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 { 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'; - -@Injectable() -export class ProjectsService { - constructor( - @Inject('IProjectsRepository') - private readonly projectsRepo: IProjectsRepository, - private readonly findTeamCommand: FindTeamCommand, - private readonly findTeamMemberCommand: FindTeamMemberCommand, - ) {} - - 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.findTeamCommand.execute(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.findTeamMemberCommand.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/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/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/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 93% rename from src/modules/projects/mappers/projects.mapper.ts rename to src/projects/application/mappers/projects.mapper.ts index e63220e..4aa1a12 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/projects/application/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'; +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/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..d70c47c --- /dev/null +++ b/src/shared/adapters/mail/module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { MailAdapter } from './adapter'; + +@Global() +@Module({ + providers: [ + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ], + exports: ['IMailPort'], +}) +export class MailModule {} 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/entities/index.ts b/src/shared/entities/index.ts index 676f897..b50a6a2 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 '../../modules/teams/entities'; -export * from '../../modules/projects/entities'; +export * from '../../user/infrastructure/persistence/models'; +export * from '../../auth/infrastructure/persistence/models'; +export * from '../../teams/infrastructure/persistence/models'; +export * from '../../projects/infrastructure/persistence/models'; 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/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'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts deleted file mode 100644 index 863d67a..0000000 --- a/src/shared/workers/enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum Queues { - MAIL = 'MAIL_QUEUE', -} - -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/index.ts b/src/shared/workers/index.ts deleted file mode 100644 index 2111275..0000000 --- a/src/shared/workers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MailJobs, Queues } from './enum'; -export { RegisterCodeEvent } from './events'; -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..43c18ba 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 '@shared/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..88e409f --- /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 '@shared/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 94% rename from src/modules/teams/dtos/member.dto.ts rename to src/teams/application/dtos/member.dto.ts index ac48ccd..1e9fe35 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 пользователя, которого нужно пригласить'), @@ -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/modules/teams/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts similarity index 90% rename from src/modules/teams/dtos/team.dto.ts rename to src/teams/application/dtos/team.dto.ts index 4fe2fb8..b96c9c4 100644 --- a/src/modules/teams/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/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..e768d31 --- /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 '@shared/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/base/check-team-slug.query.ts b/src/teams/application/use-cases/base/check-team-slug.query.ts new file mode 100644 index 0000000..46a3083 --- /dev/null +++ b/src/teams/application/use-cases/base/check-team-slug.query.ts @@ -0,0 +1,20 @@ +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} уже занят`, + }; + } +} diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/teams/application/use-cases/base/create-team.use-case.ts new file mode 100644 index 0000000..c6b8cc7 --- /dev/null +++ b/src/teams/application/use-cases/base/create-team.use-case.ts @@ -0,0 +1,60 @@ +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) { + if (error instanceof BaseException) throw 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/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts new file mode 100644 index 0000000..134bdf6 --- /dev/null +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -0,0 +1,58 @@ +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) { + 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 isOwner = team.ownerId === userId || member?.role === 'owner'; + + if (!isOwner) { + 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) { + if (error instanceof BaseException) throw 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-team.command.ts b/src/teams/application/use-cases/base/find-team.query.ts similarity index 75% rename from src/modules/teams/commands/find-team.command.ts rename to src/teams/application/use-cases/base/find-team.query.ts index f9d11a2..58bae3c 100644 --- a/src/modules/teams/commands/find-team.command.ts +++ b/src/teams/application/use-cases/base/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/base/get-all-tags.use-case.ts b/src/teams/application/use-cases/base/get-all-tags.use-case.ts new file mode 100644 index 0000000..de51e75 --- /dev/null +++ b/src/teams/application/use-cases/base/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/base/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts new file mode 100644 index 0000000..e7755f3 --- /dev/null +++ b/src/teams/application/use-cases/base/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/base/sync-team-tags.use-case.ts b/src/teams/application/use-cases/base/sync-team-tags.use-case.ts new file mode 100644 index 0000000..5199ad0 --- /dev/null +++ b/src/teams/application/use-cases/base/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/base/update-team-avatar.use-case.ts b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts new file mode 100644 index 0000000..955b340 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team-avatar.use-case.ts @@ -0,0 +1,31 @@ +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'; + +@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/base/update-team-banner.use-case.ts b/src/teams/application/use-cases/base/update-team-banner.use-case.ts new file mode 100644 index 0000000..97c0218 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team-banner.use-case.ts @@ -0,0 +1,31 @@ +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'; + +@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/base/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts new file mode 100644 index 0000000..745d438 --- /dev/null +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -0,0 +1,62 @@ +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?.id) { + 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) { + if (error instanceof BaseException) throw 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/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts new file mode 100644 index 0000000..9a63685 --- /dev/null +++ b/src/teams/application/use-cases/index.ts @@ -0,0 +1,75 @@ +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 './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, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + SyncTeamTagsUseCase, + UpdateTeamUseCase, + UpdateTeamAvatarUseCase, + UpdateTeamBannerUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +}; + +export const TeamQueries = [ + CheckTeamSlugQuery, + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetAllTagsUseCase, + GetMyInvitesUseCase, + GetMyTeamsUseCase, +]; + +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/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts new file mode 100644 index 0000000..16864a2 --- /dev/null +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -0,0 +1,94 @@ +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(), + }); + + 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) { + 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 new file mode 100644 index 0000000..5d4a4bc --- /dev/null +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -0,0 +1,98 @@ +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 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, 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) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async getInviteOrThrow(code: string) { + const rawInvite = await this.redis.get(this.INVITES_KEY(code)); + if (!rawInvite) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + return JSON.parse(rawInvite) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Ошибка доступа' }, + HttpStatus.FORBIDDEN, + ); + } + } + + 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 new file mode 100644 index 0000000..90f1b22 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -0,0 +1,72 @@ +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 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, 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) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + private async getInviteOrThrow(code: string) { + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) + throw new BaseException( + { code: 'INVITE_EXPIRED', message: 'Срок действия приглашения истек' }, + HttpStatus.NOT_FOUND, + ); + return JSON.parse(raw) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + } + + 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 new file mode 100644 index 0000000..11f7d6a --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -0,0 +1,65 @@ +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'; + +@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.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) + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + return team; + } + + 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, + ); + } + } +} 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 new file mode 100644 index 0000000..679e5e1 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -0,0 +1,42 @@ +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 userKey = `user:invites:${email.toLowerCase()}`; + const codes = await this.redis.smembers(userKey); + + if (!codes.length) return []; + + const inviteKeys = codes.map((c) => `inv:code:${c}`); + const results = await this.redis.mget(inviteKeys); + + 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 new file mode 100644 index 0000000..ffa3da8 --- /dev/null +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -0,0 +1,141 @@ +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 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 { + 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, + 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) + throw new BaseException( + { 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; + } + + private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { + if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, + HttpStatus.FORBIDDEN, + ); + } + } + + 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, + ); + } + + 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, + ); + } + } + + 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.toLowerCase(), + role: (dto.role || 'member') as TeamRole, + inviterId: inviter.userId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + } + + 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(); + } + + 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, + }); + } +} 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 new file mode 100644 index 0000000..3849e91 --- /dev/null +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -0,0 +1,100 @@ +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'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { TeamRole } from '@shared/entities'; + +@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, + 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( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getMemberOrThrow(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + } + return member; + } + + 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: 'Приглашение не найдено или истекло', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { invite: JSON.parse(rawInvite) as TeamInvite, ttl }; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_TEAM_MISMATCH', message: 'Приглашение принадлежит другой команде' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { + const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); + + if (!canUpdate) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для назначения этой роли', + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/modules/teams/commands/find-member.command.ts b/src/teams/application/use-cases/members/find-team-member.query.ts similarity index 75% rename from src/modules/teams/commands/find-member.command.ts rename to src/teams/application/use-cases/members/find-team-member.query.ts index ee15c5e..291ce9f 100644 --- a/src/modules/teams/commands/find-member.command.ts +++ b/src/teams/application/use-cases/members/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/teams/application/use-cases/members/get-team-members.query.ts b/src/teams/application/use-cases/members/get-team-members.query.ts new file mode 100644 index 0000000..b44572f --- /dev/null +++ b/src/teams/application/use-cases/members/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/members/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts new file mode 100644 index 0000000..a8c219f --- /dev/null +++ b/src/teams/application/use-cases/members/remove-team-member.use-case.ts @@ -0,0 +1,80 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { TeamRole } from '@shared/entities'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly policy: TeamMemberPolicy, + ) {} + + 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; + + 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 + ? 'Владелец не может покинуть команду без передачи прав' + : 'У вас недостаточно прав, чтобы исключить этого участника'; + + throw new BaseException( + { code: errorCode, message: errorMessage }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${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/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/teams/domain/events/index.ts b/src/teams/domain/events/index.ts new file mode 100644 index 0000000..f0cfd4e --- /dev/null +++ b/src/teams/domain/events/index.ts @@ -0,0 +1 @@ +export { TeamInvitationEvent } from './team-invitation.event'; 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/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..46a8c96 --- /dev/null +++ b/src/teams/domain/policy/team-member.policy.ts @@ -0,0 +1,89 @@ +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); + } + + /** + * Может ли Инициатор удалить Цель (или самого себя)? + */ + public 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; + } + + /** + * Может ли Инициатор приглашать новых участников с определенной ролью? + */ + 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; + } +} 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 96% rename from src/modules/teams/repository/teams.repository.interface.ts rename to src/teams/domain/repository/teams.repository.interface.ts index f02a9c9..06b83a1 100644 --- a/src/modules/teams/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/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 88% rename from src/modules/teams/repository/teams.repository.ts rename to src/teams/infrastructure/persistence/repositories/teams.repository.ts index b880554..a11c275 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 scUsers from '@core/modules/user/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,19 +32,19 @@ 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) .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}` }, @@ -53,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); } } @@ -75,12 +69,11 @@ export class TeamsRepository implements ITeamsRepository { return { success: true, teamId, - tags: insertedTagsCount, }; }); }; - 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) @@ -89,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, }; }); }; @@ -231,11 +224,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/teams/infrastructure/workers/mail.processor.ts b/src/teams/infrastructure/workers/mail.processor.ts new file mode 100644 index 0000000..34320b9 --- /dev/null +++ b/src/teams/infrastructure/workers/mail.processor.ts @@ -0,0 +1,49 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import type { Job } from 'bullmq'; +import { IMailPort } from '@shared/adapters/mail'; +import { Inject } from '@nestjs/common'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamQueues } from '@core/teams/domain/enums'; + +@Processor(TeamQueues.TEAM_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + await this.sendTeamInvitation(job); + + 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 sendTeamInvitation = async (job: Job) => { + const { email, teamName, inviteUrl } = job.data; + + await job.log(`Sending team(${teamName}) invitation link to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); + + await job.log(`Team invitation link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/modules/teams/teams.module.ts b/src/teams/teams.module.ts similarity index 70% rename from src/modules/teams/teams.module.ts rename to src/teams/teams.module.ts index 708f2b6..11a7281 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -5,23 +5,18 @@ 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 { 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 }; @@ -50,10 +45,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 +59,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [ - REPOSITORY, - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, - FindTeamCommand, - FindTeamMemberCommand, - ], - exports: [FindTeamCommand, FindTeamMemberCommand], + providers: [TeamMemberPolicy, REPOSITORY, ...TeamUseCases, ...TeamQueries, TeamsFacade], + exports: [...TEAM_EXTERNAL_QUERIES], }) export class TeamsModule {} 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..e32273b 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 '@shared/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..2939769 --- /dev/null +++ b/src/user/application/use-cases/index.ts @@ -0,0 +1,31 @@ +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/modules/user/commands/create.command.ts b/src/user/application/use-cases/register-user.use-case.ts similarity index 62% rename from src/modules/user/commands/create.command.ts rename to src/user/application/use-cases/register-user.use-case.ts index 97861b4..88167ee 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,26 @@ 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) { + if (error instanceof BaseException) { + throw 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..9ce8056 --- /dev/null +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -0,0 +1,41 @@ +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 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( + { code: 'PROFILE_UPDATE_FAILED', message: 'Не удалось обновить данные' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + }); + + return { success: true, message: 'Профиль успешно обновлен' }; + } +} 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..dd79784 --- /dev/null +++ b/src/user/application/use-cases/upload-avatar.use-case.ts @@ -0,0 +1,29 @@ +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'; + +@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..4be6829 --- /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 '@shared/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..62df1a2 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MediaModule } from '@shared/media'; +import { UserRepository } from './infrastructure/persistence/repositories'; +import { UserController, UserSettingsController } from './application/controller'; +import { UserFacade } from './application/user.facade'; +import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; + +const REPOSITORY = { + provide: 'IUserRepository', + useClass: UserRepository, +}; + +@Module({ + imports: [MediaModule], + controllers: [UserController, UserSettingsController], + providers: [...UserUseCases, ...UserQueries, REPOSITORY, UserFacade], + exports: [...USER_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)', () => {