From 7035d482db8a352dbeee06a72dc30836859f26a2 Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 30 Apr 2026 01:30:33 +0300 Subject: [PATCH] refactor: apply DDD to teams module and update cross-module imports --- src/app.module.ts | 2 +- .../projects/commands/find-project.command.ts | 6 +- .../projects/mappers/projects.mapper.ts | 2 +- src/modules/projects/projects.module.ts | 2 +- .../projects/services/projects.service.ts | 10 +- src/modules/teams/commands/index.ts | 2 - src/modules/teams/controller/index.ts | 5 - src/modules/teams/controller/teams.swagger.ts | 386 ---------------- src/modules/teams/entities/index.ts | 3 - src/modules/teams/index.ts | 2 - src/modules/teams/services/index.ts | 5 - .../teams/services/invitations.service.ts | 412 ------------------ src/modules/teams/services/me.service.ts | 32 -- src/modules/teams/services/members.service.ts | 212 --------- .../teams/services/settings.service.ts | 82 ---- src/modules/teams/services/teams.service.ts | 217 --------- src/shared/adapters/mail/module.ts | 2 - src/shared/entities/index.ts | 2 +- src/shared/workers/enum.ts | 7 - src/shared/workers/index.ts | 2 - src/shared/workers/mail/index.ts | 1 - src/teams/application/controller/index.ts | 5 + .../controller/invitations/controller.ts} | 8 +- .../controller/invitations/swagger.ts | 154 +++++++ .../application/controller/me/controller.ts} | 8 +- .../application/controller/me/swagger.ts | 34 ++ .../controller/members/controller.ts} | 8 +- .../application/controller/members/swagger.ts | 89 ++++ .../controller/settings/controller.ts} | 18 +- .../controller/settings/swagger.ts | 74 ++++ .../controller/teams/controller.ts} | 22 +- .../application/controller/teams/swagger.ts | 87 ++++ .../teams => teams/application}/dtos/index.ts | 0 .../application}/dtos/invitation.dto.ts | 2 +- .../application}/dtos/member.dto.ts | 2 +- .../application}/dtos/team.dto.ts | 0 .../application}/mappers/index.ts | 0 .../application}/mappers/member.mapper.ts | 2 +- src/teams/application/team.facade.ts | 93 ++++ .../use-cases/accept-invitation.use-case.ts | 83 ++++ .../use-cases/check-team-slug.query.ts | 21 + .../use-cases/create-team.use-case.ts | 58 +++ .../use-cases/decline-invitation.use-case.ts | 88 ++++ .../use-cases/delete-team.use-case.ts | 61 +++ .../use-cases/find-team-member.query.ts} | 4 +- .../application/use-cases/find-team.query.ts} | 4 +- .../use-cases/get-all-tags.use-case.ts | 37 ++ .../use-cases/get-invitation.query.ts | 52 +++ .../use-cases/get-invitations.query.ts | 58 +++ .../use-cases/get-my-invites.use-case.ts | 24 + .../use-cases/get-my-teams.use-case.ts | 16 + .../use-cases/get-team-members.query.ts | 26 ++ .../use-cases/get-user-invites.use-case.ts | 24 + src/teams/application/use-cases/index.ts | 24 + .../use-cases/remove-team-member.use-case.ts | 82 ++++ .../use-cases/send-invitation.use-case.ts | 91 ++++ .../use-cases/sync-team-tags.use-case.ts | 45 ++ .../use-cases/update-invitation.use-case.ts | 80 ++++ .../use-cases/update-team-avatar.use-case.ts | 31 ++ .../use-cases/update-team-banner.use-case.ts | 31 ++ .../use-cases/update-team-member.use-case.ts | 100 +++++ .../use-cases/update-team.use-case.ts | 60 +++ src/teams/domain/entities/index.ts | 1 + .../domain}/entities/teams.domain.ts | 2 +- src/teams/domain/enums/index.ts | 1 + src/teams/domain/enums/mail-jobs.enum.ts | 7 + .../workers => teams/domain}/events/index.ts | 0 .../domain}/events/team-invitation.event.ts | 0 .../domain}/repository/index.ts | 1 - .../repository/teams.repository.interface.ts | 0 src/teams/index.ts | 2 + .../persistence/models}/enums.ts | 0 .../persistence/models/index.ts | 2 + .../persistence/models/teams.model.ts} | 0 .../persistence/repositories/index.ts | 1 + .../repositories}/teams.repository.ts | 21 +- src/teams/infrastructure/workers/index.ts | 1 + .../infrastructure/workers/mail.processor.ts} | 19 +- src/{modules => }/teams/teams.module.ts | 66 +-- 79 files changed, 1750 insertions(+), 1474 deletions(-) delete mode 100644 src/modules/teams/commands/index.ts delete mode 100644 src/modules/teams/controller/index.ts delete mode 100644 src/modules/teams/controller/teams.swagger.ts delete mode 100644 src/modules/teams/entities/index.ts delete mode 100644 src/modules/teams/index.ts delete mode 100644 src/modules/teams/services/index.ts delete mode 100644 src/modules/teams/services/invitations.service.ts delete mode 100644 src/modules/teams/services/me.service.ts delete mode 100644 src/modules/teams/services/members.service.ts delete mode 100644 src/modules/teams/services/settings.service.ts delete mode 100644 src/modules/teams/services/teams.service.ts delete mode 100644 src/shared/workers/enum.ts delete mode 100644 src/shared/workers/index.ts delete mode 100644 src/shared/workers/mail/index.ts create mode 100644 src/teams/application/controller/index.ts rename src/{modules/teams/controller/invitations.controller.ts => teams/application/controller/invitations/controller.ts} (90%) create mode 100644 src/teams/application/controller/invitations/swagger.ts rename src/{modules/teams/controller/me.controller.ts => teams/application/controller/me/controller.ts} (71%) create mode 100644 src/teams/application/controller/me/swagger.ts rename src/{modules/teams/controller/members.controller.ts => teams/application/controller/members/controller.ts} (84%) create mode 100644 src/teams/application/controller/members/swagger.ts rename src/{modules/teams/controller/settings.controller.ts => teams/application/controller/settings/controller.ts} (63%) create mode 100644 src/teams/application/controller/settings/swagger.ts rename src/{modules/teams/controller/teams.controller.ts => teams/application/controller/teams/controller.ts} (64%) create mode 100644 src/teams/application/controller/teams/swagger.ts rename src/{modules/teams => teams/application}/dtos/index.ts (100%) rename src/{modules/teams => teams/application}/dtos/invitation.dto.ts (95%) rename src/{modules/teams => teams/application}/dtos/member.dto.ts (97%) rename src/{modules/teams => teams/application}/dtos/team.dto.ts (100%) rename src/{modules/teams => teams/application}/mappers/index.ts (100%) rename src/{modules/teams => teams/application}/mappers/member.mapper.ts (96%) create mode 100644 src/teams/application/team.facade.ts create mode 100644 src/teams/application/use-cases/accept-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/check-team-slug.query.ts create mode 100644 src/teams/application/use-cases/create-team.use-case.ts create mode 100644 src/teams/application/use-cases/decline-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/delete-team.use-case.ts rename src/{modules/teams/commands/find-member.command.ts => teams/application/use-cases/find-team-member.query.ts} (76%) rename src/{modules/teams/commands/find-team.command.ts => teams/application/use-cases/find-team.query.ts} (75%) create mode 100644 src/teams/application/use-cases/get-all-tags.use-case.ts create mode 100644 src/teams/application/use-cases/get-invitation.query.ts create mode 100644 src/teams/application/use-cases/get-invitations.query.ts create mode 100644 src/teams/application/use-cases/get-my-invites.use-case.ts create mode 100644 src/teams/application/use-cases/get-my-teams.use-case.ts create mode 100644 src/teams/application/use-cases/get-team-members.query.ts create mode 100644 src/teams/application/use-cases/get-user-invites.use-case.ts create mode 100644 src/teams/application/use-cases/index.ts create mode 100644 src/teams/application/use-cases/remove-team-member.use-case.ts create mode 100644 src/teams/application/use-cases/send-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/sync-team-tags.use-case.ts create mode 100644 src/teams/application/use-cases/update-invitation.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-avatar.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-banner.use-case.ts create mode 100644 src/teams/application/use-cases/update-team-member.use-case.ts create mode 100644 src/teams/application/use-cases/update-team.use-case.ts create mode 100644 src/teams/domain/entities/index.ts rename src/{modules/teams => teams/domain}/entities/teams.domain.ts (87%) create mode 100644 src/teams/domain/enums/index.ts create mode 100644 src/teams/domain/enums/mail-jobs.enum.ts rename src/{shared/workers => teams/domain}/events/index.ts (100%) rename src/{shared/workers => teams/domain}/events/team-invitation.event.ts (100%) rename src/{modules/teams => teams/domain}/repository/index.ts (68%) rename src/{modules/teams => teams/domain}/repository/teams.repository.interface.ts (100%) create mode 100644 src/teams/index.ts rename src/{modules/teams/entities => teams/infrastructure/persistence/models}/enums.ts (100%) create mode 100644 src/teams/infrastructure/persistence/models/index.ts rename src/{modules/teams/entities/teams.entity.ts => teams/infrastructure/persistence/models/teams.model.ts} (100%) create mode 100644 src/teams/infrastructure/persistence/repositories/index.ts rename src/{modules/teams/repository => teams/infrastructure/persistence/repositories}/teams.repository.ts (93%) create mode 100644 src/teams/infrastructure/workers/index.ts rename src/{shared/workers/mail/worker.ts => teams/infrastructure/workers/mail.processor.ts} (70%) rename src/{modules => }/teams/teams.module.ts (57%) diff --git a/src/app.module.ts b/src/app.module.ts index 404fdf8..0d82338 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,7 @@ import { FastifyAdapter } from '@bull-board/fastify'; import { BullModule } from '@nestjs/bullmq'; import { MailModule } from '@shared/adapters/mail'; import { MigrationService } from '@shared/migration'; -import { TeamsModule } from './modules/teams'; +import { TeamsModule } from './teams'; import { ProjectsModule } from './modules/projects'; @Module({ diff --git a/src/modules/projects/commands/find-project.command.ts b/src/modules/projects/commands/find-project.command.ts index 099e8eb..a1d358b 100644 --- a/src/modules/projects/commands/find-project.command.ts +++ b/src/modules/projects/commands/find-project.command.ts @@ -1,6 +1,6 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; -import { FindTeamMemberCommand } from '@core/modules/teams'; +import { FindTeamMemberQuery } from '@core/teams'; import { createHash } from 'crypto'; import type { Project } from '../entities'; import { BaseException } from '@shared/error'; @@ -10,7 +10,7 @@ export class FindProjectCommand { constructor( @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, - private readonly findTeamMemberCommand: FindTeamMemberCommand, + private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} public async execute(projectId: string, userId?: string, shareToken?: string) { @@ -45,7 +45,7 @@ export class FindProjectCommand { ); } - const member = await this.findTeamMemberCommand.execute(project.teamId, userId); + const member = await this.findTeamMemberQ.execute(project.teamId, userId); if (!member) { throw new BaseException( diff --git a/src/modules/projects/mappers/projects.mapper.ts b/src/modules/projects/mappers/projects.mapper.ts index e63220e..708c1a2 100644 --- a/src/modules/projects/mappers/projects.mapper.ts +++ b/src/modules/projects/mappers/projects.mapper.ts @@ -1,6 +1,6 @@ -import type { RawMemberRow } from '@core/modules/teams/repository'; import type { Project } from '@shared/entities'; import { ROLE_PRIORITY } from '@shared/constants'; +import { RawMemberRow } from '@core/teams/domain/repository'; export class ProjectsMapper { public static toDetailResponse(project: Project, member?: RawMemberRow, token?: string) { diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts index daaeac6..fb78d1b 100644 --- a/src/modules/projects/projects.module.ts +++ b/src/modules/projects/projects.module.ts @@ -2,7 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { ProjectsService } from './services'; import { ProjectsController } from './controller'; import { ProjectsRepository } from './repository'; -import { TeamsModule } from '../teams'; +import { TeamsModule } from '../../teams'; import { FindProjectCommand } from './commands'; const REPOSITORY = { diff --git a/src/modules/projects/services/projects.service.ts b/src/modules/projects/services/projects.service.ts index 4ea0667..f15f6ce 100644 --- a/src/modules/projects/services/projects.service.ts +++ b/src/modules/projects/services/projects.service.ts @@ -1,20 +1,20 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { IProjectsRepository } from '../repository'; import type { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../dtos'; -import { FindTeamCommand, FindTeamMemberCommand } from '@core/modules/teams'; import { ROLE_PRIORITY } from '@shared/constants'; import { ProjectStatus } from '../entities'; import { ProjectsMapper } from '../mappers'; import { createHash, randomBytes } from 'crypto'; import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; @Injectable() export class ProjectsService { constructor( @Inject('IProjectsRepository') private readonly projectsRepo: IProjectsRepository, - private readonly findTeamCommand: FindTeamCommand, - private readonly findTeamMemberCommand: FindTeamMemberCommand, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, ) {} public create = async (userId: string, slug: string, dto: CreateProjectDto) => { @@ -254,7 +254,7 @@ export class ProjectsService { userId: string, minRole: keyof typeof ROLE_PRIORITY = 'viewer', ) { - const team = await this.findTeamCommand.execute(slug); + const team = await this.findTeamQ.execute(slug); if (!team) { throw new BaseException( { @@ -265,7 +265,7 @@ export class ProjectsService { ); } - const member = await this.findTeamMemberCommand.execute(team.id, userId); + const member = await this.findTeamMemberQ.execute(team.id, userId); if (!member) { throw new BaseException( { diff --git a/src/modules/teams/commands/index.ts b/src/modules/teams/commands/index.ts deleted file mode 100644 index 2292e4a..0000000 --- a/src/modules/teams/commands/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { FindTeamMemberCommand } from './find-member.command'; -export { FindTeamCommand } from './find-team.command'; diff --git a/src/modules/teams/controller/index.ts b/src/modules/teams/controller/index.ts deleted file mode 100644 index ac78b0a..0000000 --- a/src/modules/teams/controller/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeController } from './me.controller'; -export { TeamsController } from './teams.controller'; -export { TeamsMembersController } from './members.controller'; -export { TeamsSettingsController } from './settings.controller'; -export { TeamsInvitationsController } from './invitations.controller'; diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts deleted file mode 100644 index c770e62..0000000 --- a/src/modules/teams/controller/teams.swagger.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiConsumes } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; -import { - ApiBadRequest, - ApiConflict, - ApiForbidden, - ApiNotFound, - ApiUnauthorized, - ApiValidationError, -} from '@shared/error'; -import { - CreateTeamDto, - InviteMemberDto, - TeamInvitationResponse, - SyncTagsDto, - UpdateTeamDto, - TagResponse, - TeamMemberResponse, - CheckSlugResponse, - UpdateMemberDto, - UpdateInvitationDto, - UserTeamResponse, - UserInviteResponse, -} from '../dtos'; -import { FileUploadResponse } from '../../media/dtos'; - -export const CreateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Создать новую команду' }), - ApiBody({ type: CreateTeamDto.Output }), - ApiResponse({ - status: 201, - description: 'Команда успешно создана', - type: ActionResponse.Output, - }), - ApiConflict('Команда с таким slug уже существует'), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const CheckSlugSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Проверить доступность слага', - description: 'Проверяет, свободен ли уникальный адрес команды для использования.', - }), - ApiParam({ - name: 'slug', - description: 'Желаемый слаг команды', - example: 'my-super-team', - }), - ApiResponse({ - status: 200, - description: 'Результат проверки доступности', - type: CheckSlugResponse.Output, - }), - ApiUnauthorized(), - ); - -export const FindTeamsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список команд пользователя', - description: - 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', - }), - ApiResponse({ - status: 200, - description: 'Список команд получен', - type: [UserTeamResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindInvitesSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список входящих приглашений', - description: - 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', - }), - ApiResponse({ - status: 200, - description: 'Список приглашений успешно получен', - type: [UserInviteResponse.Output], - }), - ApiUnauthorized(), - ); - -export const FindOneTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), - ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), - ApiResponse({ - status: 200, - description: 'Данные команды получены', - type: Object, - }), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ); - -export const UpdateTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Обновить данные команды' }), - ApiBody({ type: UpdateTeamDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), - ApiResponse({ - status: 200, - description: 'Команда успешно обновлена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiValidationError(), - ApiUnauthorized(), - ); - -export const RemoveTeamSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить команду' }), - ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), - ApiResponse({ - status: 200, - description: 'Команда успешно удалена', - type: ActionResponse.Output, - }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const SyncTeamTagsSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Синхронизировать теги команды' }), - ApiBody({ type: SyncTagsDto.Output }), - ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), - ApiForbidden(), - ApiNotFound(), - ApiUnauthorized(), - ); - -export const GetAllTagsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех тегов с пагинацией', - description: - 'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.', - }), - ApiResponse({ - status: 200, - description: 'Список тегов успешно получен', - type: TagResponse.Output, - }), - ApiUnauthorized(), - ); - -export const GetMembersSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Получить список всех участников команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список участников получен', - type: [TeamMemberResponse.Output], - }), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const InviteMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Пригласить пользователя в команду по Email', - description: - 'Создает запись об участнике со статусом "pending".' + - ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + - ' Если нет — ему уйдет письмо на указанный Email.', - }), - ApiBody({ type: InviteMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), - ApiResponse({ - status: 201, - description: 'Инвайт создан и отправлен', - type: ActionResponse.Output, - }), - ApiValidationError('Некорректный формат Email или роль не поддерживается'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const UpdateMemberSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Изменить роль или статус участника', - description: - 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + - ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', - }), - ApiBody({ type: UpdateMemberDto.Output }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), - ApiResponse({ - status: 200, - description: 'Данные участника обновлены', - type: ActionResponse.Output, - }), - ApiNotFound('Участник или команда не найдены'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const RemoveMemberSwagger = () => - applyDecorators( - ApiOperation({ summary: 'Удалить участника из команды' }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'userId', description: 'ID пользователя' }), - ApiResponse({ - status: 200, - type: ActionResponse.Output, - description: 'Участник успешно удален', - }), - ApiNotFound(), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamAvatarSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить аватар команды', - description: 'Загрузка файла изображения для профиля команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Аватар команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const PatchTeamBannerSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить баннер команды', - description: 'Загрузка файла изображения для обложки (баннера) команды.', - }), - ApiConsumes('multipart/form-data'), - ApiBody({ - schema: { - type: 'object', - properties: { - file: { - type: 'string', - format: 'binary', - }, - }, - }, - }), - ApiResponse({ - status: 200, - description: 'Баннер команды успешно обновлен.', - type: FileUploadResponse.Output, - }), - ApiBadRequest('Файл не передан или имеет неверный формат'), - ApiNotFound('Команда не найдена'), - ApiUnauthorized(), - ApiForbidden(), - ); - -export const AcceptInviteSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Принять приглашение в команду', - description: - 'Активирует участие пользователя в команде по уникальному коду приглашения.' + - ' После успешного принятия статус участника меняется с "pending" на "active".' + - ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', - }), - ApiParam({ - name: 'code', - description: 'Уникальный код/токен приглашения (из ссылки или письма)', - example: '7df1-4a2b-9e8c', - }), - ApiResponse({ - status: 200, - description: 'Приглашение успешно принято. Пользователь теперь участник команды.', - type: ActionResponse.Output, - }), - ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), - ApiNotFound('Приглашение с таким кодом не найдено'), - ApiConflict('Пользователь уже является участником этой команды'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationsSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить список всех приглашений в команду', - description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiResponse({ - status: 200, - description: 'Список приглашений команды', - type: [TeamInvitationResponse.Output], - }), - ApiNotFound('Команда не найдена'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const GetTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Получить приглашение по коду', - description: - 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт найден', - type: TeamInvitationResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const UpdateTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Обновить приглашение (только роль)', - description: - 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiBody({ type: UpdateInvitationDto.Output }), - ApiResponse({ - status: 200, - description: 'Инвайт обновлён', - type: TeamInvitationResponse.Output, - }), - ApiValidationError(), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); - -export const DeleteTeamInvitationSwagger = () => - applyDecorators( - ApiOperation({ - summary: 'Удалить приглашение', - description: - 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', - }), - ApiParam({ name: 'slug', description: 'Слаг команды' }), - ApiParam({ name: 'code', description: 'Код инвайта' }), - ApiResponse({ - status: 200, - description: 'Инвайт удалён', - type: ActionResponse.Output, - }), - ApiNotFound('Инвайт или команда не найдены'), - ApiForbidden('Недостаточно прав (только owner/admin)'), - ApiUnauthorized(), - ); diff --git a/src/modules/teams/entities/index.ts b/src/modules/teams/entities/index.ts deleted file mode 100644 index f996b3f..0000000 --- a/src/modules/teams/entities/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { tags, teamsToTags, teams, teamMembers } from './teams.entity'; -export { roleEnum, statusEnum } from './enums'; -export * from './teams.domain'; diff --git a/src/modules/teams/index.ts b/src/modules/teams/index.ts deleted file mode 100644 index 7f616ae..0000000 --- a/src/modules/teams/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TeamsModule } from './teams.module'; -export { FindTeamCommand, FindTeamMemberCommand } from './commands'; diff --git a/src/modules/teams/services/index.ts b/src/modules/teams/services/index.ts deleted file mode 100644 index 1e5ca81..0000000 --- a/src/modules/teams/services/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MeService } from './me.service'; -export { TeamsService } from './teams.service'; -export { TeamMembersService } from './members.service'; -export { TeamsSettingsService } from './settings.service'; -export { TeamInvitationsService } from './invitations.service'; diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts deleted file mode 100644 index 75bf93a..0000000 --- a/src/modules/teams/services/invitations.service.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { generateSecret } from 'otplib'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { InjectQueue } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '@shared/workers'; -import { Queue } from 'bullmq'; -import { TeamInvitationEvent } from '@shared/workers/events'; -import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; -import { ConfigService } from '@nestjs/config'; -import { BaseException } from '@shared/error'; -import { TeamInvite } from '@core/modules/teams/dtos/invitation.dto'; - -@Injectable() -export class TeamInvitationsService { - private readonly INVITE_TTL = 86400; - private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; - private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; - private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; - - private assertCanManageInvites = async (teamId: string, userId: string) => { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав управлять приглашениями в этой команде', - }, - HttpStatus.FORBIDDEN, - ); - } - return member; - }; - - private parseInvite = (raw: string, code?: string) => { - try { - const invite = JSON.parse(raw) as TeamInvite; - return code ? { code, ...invite } : invite; - } catch { - throw new BaseException( - { - code: 'INVITE_DATA_CORRUPTED', - message: 'Данные приглашения повреждены', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - @InjectQueue(Queues.MAIL) - private readonly mailQueue: Queue, - private readonly cfg: ConfigService, - ) {} - - public getInvitations = async (slug: string, userId?: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (userId) { - await this.assertCanManageInvites(team.id, userId); - } - - const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); - if (!codes.length) return []; - - const keys = codes.map((c) => this.INVITES_KEY(c)); - const invitesRaw = await this.redis.mget(...keys); - - return invitesRaw - .map((raw, idx) => { - if (!raw) return null; - return this.parseInvite(raw, codes[idx]); - }) - .filter((v): v is TeamInvite => Boolean(v)); - }; - - public getInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw, code); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - return invite; - }; - - public updateInvitation = async ( - slug: string, - code: string, - userId: string, - dto: UpdateInvitationDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const key = this.INVITES_KEY(code); - const raw = await this.redis.get(key); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - const ttl = await this.redis.ttl(key); - if (ttl === -2) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - invite.role = dto.role; - - if (ttl > 0) { - await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); - } else { - await this.redis.set(key, JSON.stringify(invite)); - } - - return { code, ...invite }; - }; - - public declineInvitation = async (slug: string, code: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.assertCanManageInvites(team.id, userId); - - const raw = await this.redis.get(this.INVITES_KEY(code)); - if (!raw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.NOT_FOUND, - ); - } - - const invite = this.parseInvite(raw); - if (invite.teamId !== team.id) { - throw new BaseException( - { - code: 'INVITE_NOT_FOUND', - message: 'Приглашение не найдено', - }, - HttpStatus.NOT_FOUND, - ); - } - - await this.removeInvitation(team.id, code, invite.email); - - return { - success: true, - message: 'Приглашение удалено', - }; - }; - - public removeInvitation = async (teamId: string, code: string, email: string) => { - try { - const multi = this.redis.multi(); - multi.del(this.INVITES_KEY(code)); - multi.srem(this.TEAM_INVITES_KEY(teamId), code); - multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); - await multi.exec(); - } catch { - throw new BaseException( - { - code: 'REDIS_TRANSACTION_FAILED', - message: 'Не удалось удалить приглашение из системы', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { success: true }; - }; - - public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - const inviter = await this.teamsRepo.findMember(team.id, inviterId); - if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав приглашать новых участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - const code = generateSecret({ length: 8 }); - - const now = new Date(); - const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); - - const inviteData: TeamInvite = { - teamId: team.id, - teamName: team.name, - teamAvatar: team.avatarUrl, - email: dto.email, - role: dto.role || 'member', - inviterId, - inviterName: inviter.firstName, - createdAt: new Date().toISOString(), - expiresAt: expiresAt.toISOString(), - }; - - try { - const multi = this.redis.multi(); - multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); - multi.sadd(this.TEAM_INVITES_KEY(team.id), code); - multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); - await multi.exec(); - } catch (error) { - throw new BaseException( - { - code: 'REDIS_TRANSACTION_FAILED', - message: 'Не удалось создать приглашение в системе', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); - const FRONTEND_URL = origins[0]; - - const event = new TeamInvitationEvent( - dto.email, - team.name, - `${FRONTEND_URL}/invites/accept?code=${code}`, - ); - await this.mailQueue.add(MailJobs.SEND_TEAM_INVITATION, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: `Приглашение отправлено на ${dto.email}`, - code, - }; - }; - - public acceptInvite = async (code: string, userId: string, email: string) => { - const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); - if (!inviteRaw) { - throw new BaseException( - { - code: 'INVITE_EXPIRED_OR_INVALID', - message: 'Срок действия приглашения истек или код неверен', - }, - HttpStatus.GONE, - ); - } - - const invite = this.parseInvite(inviteRaw); - - if (invite.email.toLowerCase() !== email.toLowerCase()) { - throw new BaseException( - { - code: 'INVITE_EMAIL_MISMATCH', - message: 'Этот инвайт предназначен для другого почтового адреса', - details: [{ target: 'email', expected: invite.email, actual: email }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const member = await this.teamsRepo.findMember(invite.teamId, userId); - - if (member) { - if (member.status === 'banned') { - throw new BaseException( - { - code: 'MEMBER_BANNED', - message: 'Вы заблокированы в этой команде', - }, - HttpStatus.FORBIDDEN, - ); - } - - if (member.status === 'active') { - throw new BaseException( - { - code: 'ALREADY_MEMBER', - message: 'Вы уже являетесь участником этой команды', - }, - HttpStatus.BAD_REQUEST, - ); - } - } - - try { - await this.teamsRepo.addMember({ - teamId: invite.teamId, - userId, - role: invite.role, - status: 'active', - joinedAt: new Date(), - }); - - await this.removeInvitation(invite.teamId, code, email); - - return { - success: true, - message: 'Вы успешно присоединились к команде', - }; - } catch (error) { - throw new BaseException( - { - code: 'ACCEPT_INVITE_FAILED', - message: 'Ошибка при вступлении в команду', - details: [{ reason: error instanceof Error ? error.message : 'DB Error' }], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; -} diff --git a/src/modules/teams/services/me.service.ts b/src/modules/teams/services/me.service.ts deleted file mode 100644 index e0012b6..0000000 --- a/src/modules/teams/services/me.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { TeamMemberMapper } from '../mappers'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; - -@Injectable() -export class MeService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - ) {} - - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - }; - - public getAll = async (userId: string, pagination: Record) => { - const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); - }; -} diff --git a/src/modules/teams/services/members.service.ts b/src/modules/teams/services/members.service.ts deleted file mode 100644 index 9fca6e9..0000000 --- a/src/modules/teams/services/members.service.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import type { UpdateMemberDto } from '../dtos'; -import { TeamMemberMapper } from '../mappers'; -import { BaseException } from '@shared/error'; -import { ROLE_PRIORITY } from '@shared/constants'; - -@Injectable() -export class TeamMembersService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - ) {} - - public getMembers = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const members = await this.teamsRepo.findMembers(team.id); - return TeamMemberMapper.toList(members); - }; - - public updateMember = async ( - slug: string, - currentUserId: string, - targetUserId: string, - dto: UpdateMemberDto, - ) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); - - if (!currentUser || !targetUser) { - throw new BaseException( - { - code: 'MEMBER_NOT_FOUND', - message: 'Участник не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { - throw new BaseException( - { - code: 'ADMIN_ROLE_REQUIRED', - message: 'У вас нет прав на редактирование участников', - }, - HttpStatus.FORBIDDEN, - ); - } - - // Нельзя менять роль тому, кто выше тебя или равен тебе по весу - if ( - currentUserId !== targetUserId && - ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] - ) { - throw new BaseException( - { - code: 'INSUFFICIENT_RANK', - message: 'Вы не можете менять данные участника с равным или высшим рангом', - details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], - }, - HttpStatus.FORBIDDEN, - ); - } - - // Защита от потери овнера: нельзя разжаловать овнера в админа - if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { - throw new BaseException( - { - code: 'OWNER_PROTECTION_VIOLATION', - message: - 'Нельзя изменить роль владельца через это меню. Используйте передачу прав.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - // Нельзя назначить роль выше своей (Админ не может сделать кого-то Овнером) - if ( - dto.role && - ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && - currentUser.role !== 'owner' - ) { - throw new BaseException( - { - code: 'CANNOT_ASSIGN_HIGHER_ROLE', - message: 'Вы не можете назначить роль выше своей или равную своей', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); - return { - success: result, - message: `Данные участника команды "${team.name}" успешно обновлены`, - }; - } catch (error) { - throw new BaseException( - { - code: 'MEMBER_UPDATE_FAILED', - message: 'Ошибка при обновлении данных участника', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public removeMember = async (slug: string, currentUserId: string, targetUserId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); - - if (!targetUser) { - throw new BaseException( - { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, - HttpStatus.NOT_FOUND, - ); - } - if (!currentUser) { - throw new BaseException( - { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, - HttpStatus.FORBIDDEN, - ); - } - - const isSelfRemoval = currentUserId === targetUserId; - - if (isSelfRemoval) { - if (currentUser.role === 'owner') { - throw new BaseException( - { - code: 'OWNER_CANNOT_LEAVE', - message: - 'Владелец не может покинуть команду. Передайте права или удалите команду.', - }, - HttpStatus.BAD_REQUEST, - ); - } - } else { - const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; - const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; - - if (!hasAuthority || !canKick) { - throw new BaseException( - { - code: 'KICK_FORBIDDEN', - message: 'У вас недостаточно прав, чтобы исключить этого участника', - details: [ - { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, - ], - }, - HttpStatus.FORBIDDEN, - ); - } - } - - try { - const result = await this.teamsRepo.removeMember(team.id, targetUserId); - return { - success: result, - message: isSelfRemoval - ? `Вы успешно покинули команду ${team.name}` - : `Участник успешно исключен из команды ${team.name}`, - }; - } catch (error) { - throw new BaseException( - { - code: 'MEMBER_REMOVAL_FAILED', - message: 'Ошибка при удалении участника', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; -} diff --git a/src/modules/teams/services/settings.service.ts b/src/modules/teams/services/settings.service.ts deleted file mode 100644 index 15ee711..0000000 --- a/src/modules/teams/services/settings.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { ITeamMedia, TEAM_MEDIA_TOKEN, type FileUploadDto } from '../../media'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class TeamsSettingsService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @Inject(TEAM_MEDIA_TOKEN) - private readonly mediaService: ITeamMedia, - ) {} - - public updateTeamAvatar = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => - this.teamsRepo.updateTeamAvatar(team.id, url), - ); - }; - - public updateTeamBanner = async (slug: string, fileDto: FileUploadDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - details: [{ target: 'slug', value: slug }], - }, - HttpStatus.NOT_FOUND, - ); - } - - return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => - this.teamsRepo.updateTeamBanner(team.id, url), - ); - }; - - public syncTags = async (slug: string, tags: string[]) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: 'Команда не найдена', - }, - HttpStatus.NOT_FOUND, - ); - } - - const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))]; - const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); - - if (!isSynced) { - throw new BaseException( - { - code: 'TAGS_SYNC_FAILED', - message: 'Не удалось обновить теги команды. Попробуйте позже.', - details: [{ target: 'tags', count: normalizedTags.length }], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return { - success: true, - message: 'Теги команды обновлены', - }; - }; -} diff --git a/src/modules/teams/services/teams.service.ts b/src/modules/teams/services/teams.service.ts deleted file mode 100644 index f56ce3f..0000000 --- a/src/modules/teams/services/teams.service.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Inject, Injectable, HttpStatus } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; -import { FindTagsQuery } from '../dtos'; -import type { CreateTeamDto, UpdateTeamDto } from '../dtos'; -import { slugify } from 'transliteration'; -import { TeamMemberMapper } from '../mappers'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { BaseException } from '@shared/error'; - -@Injectable() -export class TeamsService { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - @InjectRedis() - private readonly redis: Redis, - ) {} - - public checkSlug = async (slug: string) => { - const available = await this.teamsRepo.isSlugAvailable(slug); - return { available }; - }; - - public getMyInvites = async (email: string) => { - const codes = await this.redis.smembers(`user:invites:${email}`); - - if (!codes.length) return []; - - const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); - - return results - .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) - .filter(Boolean); - }; - - public create = async (userId: string, dto: CreateTeamDto) => { - const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); - const existingTeam = await this.teamsRepo.findBySlug(baseSlug); - - if (existingTeam) { - throw new BaseException( - { - code: 'SLUG_ALREADY_EXISTS', - message: `Ссылка "${baseSlug}" уже занята другой командой`, - details: [{ target: 'slug', value: baseSlug }], - }, - HttpStatus.CONFLICT, - ); - } - - const { tags, ...teamData } = dto; - const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; - - try { - const result = await this.teamsRepo.create( - userId, - { - ...teamData, - slug: baseSlug, - }, - uniqueTags, - ); - - return { - ...result, - slug: baseSlug, - message: 'Команда успешно создана', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_CREATE_FAILED', - message: 'Не удалось создать команду', - details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public update = async (slug: string, userId: string, dto: UpdateTeamDto) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canEdit = member?.role === 'admin' || member?.role === 'owner'; - - if (!canEdit) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для редактирования этой команды', - details: [{ target: 'role', value: member?.role }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const { tags, ...data } = dto; - - try { - const result = await this.teamsRepo.update(team.id, data, tags); - - return { - ...result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_UPDATE_FAILED', - message: 'Ошибка при обновлении данных команды', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public remove = async (slug: string, userId: string) => { - const team = await this.teamsRepo.findBySlug(slug); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - - const canDelete = team.ownerId === userId || member?.role === 'owner'; - - if (!canDelete) { - throw new BaseException( - { - code: 'ONLY_OWNER_CAN_DELETE', - message: 'Только владелец может удалить команду', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.remove(team.id, userId); - - return { - success: result, - message: 'Данные команды успешно обновлены', - }; - } catch (error) { - throw new BaseException( - { - code: 'TEAM_DELETE_FAILED', - message: 'Не удалось удалить команду', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - }; - - public getAllTags = async (query: FindTagsQuery) => { - const safePage = Math.max(query.page ?? 1, 1); - const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); - const offset = (safePage - 1) * safeLimit; - - const { data, total } = await this.teamsRepo.findAllTags({ - search: query.search, - limit: safeLimit, - offset, - }); - - const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); - return { - data, - meta: { - hasNextPage: safePage < totalPages, - hasPrevPage: safePage > 1, - total, - totalPages, - page: safePage, - limit: safeLimit, - }, - }; - }; - - public getAll = async (userId: string, pagination: Record) => { - const teams = await this.teamsRepo.findByUser(userId, pagination); - return teams.map((t) => TeamMemberMapper.toUserTeam(t)); - }; - - public getOne = async (slug: string) => { - const team = await this.teamsRepo.findBySlug(slug); - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${slug} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - return team; - }; -} diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts index 50174b7..d70c47c 100644 --- a/src/shared/adapters/mail/module.ts +++ b/src/shared/adapters/mail/module.ts @@ -1,6 +1,5 @@ import { Global, Module } from '@nestjs/common'; import { MailAdapter } from './adapter'; -import { MailProcessor } from '@shared/workers'; @Global() @Module({ @@ -9,7 +8,6 @@ import { MailProcessor } from '@shared/workers'; provide: 'IMailPort', useClass: MailAdapter, }, - MailProcessor, ], exports: ['IMailPort'], }) diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index 61f7880..119ec4c 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,5 +1,5 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; -export * from '../../modules/teams/entities'; +export * from '../../teams/infrastructure/persistence/models'; export * from '../../modules/projects/entities'; diff --git a/src/shared/workers/enum.ts b/src/shared/workers/enum.ts deleted file mode 100644 index 433d08a..0000000 --- a/src/shared/workers/enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum Queues { - MAIL = 'MAIL_QUEUE', -} - -export enum MailJobs { - SEND_TEAM_INVITATION = 'SEND_TEAM_INVITATION', -} diff --git a/src/shared/workers/index.ts b/src/shared/workers/index.ts deleted file mode 100644 index c14cbc2..0000000 --- a/src/shared/workers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { MailJobs, Queues } from './enum'; -export { MailProcessor } from './mail'; diff --git a/src/shared/workers/mail/index.ts b/src/shared/workers/mail/index.ts deleted file mode 100644 index a059e2b..0000000 --- a/src/shared/workers/mail/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MailProcessor } from './worker'; diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts new file mode 100644 index 0000000..5e1b219 --- /dev/null +++ b/src/teams/application/controller/index.ts @@ -0,0 +1,5 @@ +export { MeController } from './me/controller'; +export { TeamsController } from './teams/controller'; +export { TeamsMembersController } from './members/controller'; +export { TeamsSettingsController } from './settings/controller'; +export { TeamsInvitationsController } from './invitations/controller'; diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/teams/application/controller/invitations/controller.ts similarity index 90% rename from src/modules/teams/controller/invitations.controller.ts rename to src/teams/application/controller/invitations/controller.ts index a4df155..ac507ea 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/teams/application/controller/invitations/controller.ts @@ -1,6 +1,5 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamInvitationsService } from '../services'; import { AcceptInviteSwagger, DeleteTeamInvitationSwagger, @@ -8,13 +7,14 @@ import { GetTeamInvitationsSwagger, InviteMemberSwagger, UpdateTeamInvitationSwagger, -} from './teams.swagger'; +} from './swagger'; import type { JwtPayload } from '@shared/types'; -import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; +import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) export class TeamsInvitationsController { - constructor(private readonly facade: TeamInvitationsService) {} + constructor(private readonly facade: TeamsFacade) {} @Get() @GetTeamInvitationsSwagger() diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/teams/application/controller/invitations/swagger.ts new file mode 100644 index 0000000..30f5dca --- /dev/null +++ b/src/teams/application/controller/invitations/swagger.ts @@ -0,0 +1,154 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { + InviteMemberDto, + TeamInvitationResponse, + UpdateInvitationDto, + UserInviteResponse, +} from '../../dtos'; + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды, в которую приглашаем' }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: [TeamInvitationResponse.Output], + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: TeamInvitationResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/me.controller.ts b/src/teams/application/controller/me/controller.ts similarity index 71% rename from src/modules/teams/controller/me.controller.ts rename to src/teams/application/controller/me/controller.ts index 9ec2f60..7c1098b 100644 --- a/src/modules/teams/controller/me.controller.ts +++ b/src/teams/application/controller/me/controller.ts @@ -1,18 +1,18 @@ import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { MeService } from '../services'; import { Get, Query } from '@nestjs/common'; -import { FindInvitesSwagger, FindTeamsSwagger } from './teams.swagger'; +import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; import type { JwtPayload } from '@shared/types'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { - constructor(private readonly facade: MeService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('teams') @FindTeamsSwagger() // TODO: ADD TO QUERY DTO async findMyTeams(@GetUserId() userId: string, @Query() query: any) { - return this.facade.getAll(userId, query); + return this.facade.getMyTeams(userId, query); } @Get('invites') diff --git a/src/teams/application/controller/me/swagger.ts b/src/teams/application/controller/me/swagger.ts new file mode 100644 index 0000000..8081737 --- /dev/null +++ b/src/teams/application/controller/me/swagger.ts @@ -0,0 +1,34 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized } from '@shared/error'; +import { UserTeamResponse, UserInviteResponse } from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/controller/members.controller.ts b/src/teams/application/controller/members/controller.ts similarity index 84% rename from src/modules/teams/controller/members.controller.ts rename to src/teams/application/controller/members/controller.ts index 1f908ad..b4e9b22 100644 --- a/src/modules/teams/controller/members.controller.ts +++ b/src/teams/application/controller/members/controller.ts @@ -1,12 +1,12 @@ import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { TeamMembersService } from '../services'; -import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './teams.swagger'; -import type { UpdateMemberDto } from '../dtos/member.dto'; +import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; +import type { UpdateMemberDto } from '../../dtos/member.dto'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams/:slug', 'Teams Members', true) export class TeamsMembersController { - constructor(private readonly facade: TeamMembersService) {} + constructor(private readonly facade: TeamsFacade) {} @Get('members') @GetMembersSwagger() diff --git a/src/teams/application/controller/members/swagger.ts b/src/teams/application/controller/members/swagger.ts new file mode 100644 index 0000000..92c1dcf --- /dev/null +++ b/src/teams/application/controller/members/swagger.ts @@ -0,0 +1,89 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { + TeamMemberResponse, + UpdateMemberDto, + UserTeamResponse, + UserInviteResponse, +} from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: [UserTeamResponse.Output], + }), + ApiUnauthorized(), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: [UserInviteResponse.Output], + }), + ApiUnauthorized(), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: [TeamMemberResponse.Output], + }), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/settings.controller.ts b/src/teams/application/controller/settings/controller.ts similarity index 63% rename from src/modules/teams/controller/settings.controller.ts rename to src/teams/application/controller/settings/controller.ts index 91484ab..16f8ac5 100644 --- a/src/modules/teams/controller/settings.controller.ts +++ b/src/teams/application/controller/settings/controller.ts @@ -1,17 +1,13 @@ import { Body, Param, Patch, Put } from '@nestjs/common'; import { ApiBaseController, ExtractFastifyFile } from '@shared/decorators'; -import { TeamsSettingsService } from '../services'; -import { - SyncTeamTagsSwagger, - PatchTeamAvatarSwagger, - PatchTeamBannerSwagger, -} from './teams.swagger'; -import type { FileUploadDto } from '../../media'; -import type { SyncTagsDto } from '../dtos'; +import { SyncTeamTagsSwagger, PatchTeamAvatarSwagger, PatchTeamBannerSwagger } from './swagger'; +import { SyncTagsDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; +import { FileUploadDto } from '@core/modules/media'; @ApiBaseController('teams/:slug', 'Teams Settings', true) export class TeamsSettingsController { - constructor(private readonly facade: TeamsSettingsService) {} + constructor(private readonly facade: TeamsFacade) {} @Put('tags') @SyncTeamTagsSwagger() @@ -25,7 +21,7 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamAvatar(slug, fileDto); + return this.facade.updateAvatar(slug, fileDto); } @Patch('banner') @@ -34,6 +30,6 @@ export class TeamsSettingsController { @ExtractFastifyFile() fileDto: FileUploadDto, @Param('slug') slug: string, ) { - return this.facade.updateTeamBanner(slug, fileDto); + return this.facade.updateBanner(slug, fileDto); } } diff --git a/src/teams/application/controller/settings/swagger.ts b/src/teams/application/controller/settings/swagger.ts new file mode 100644 index 0000000..19d90e3 --- /dev/null +++ b/src/teams/application/controller/settings/swagger.ts @@ -0,0 +1,74 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse, ApiConsumes } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { ApiBadRequest, ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { SyncTagsDto } from '../../dtos'; +import { FileUploadResponse } from '@core/modules/media'; + +export const SyncTeamTagsSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Синхронизировать теги команды' }), + ApiBody({ type: SyncTagsDto.Output }), + ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); + +export const PatchTeamAvatarSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить аватар команды', + description: 'Загрузка файла изображения для профиля команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Аватар команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); + +export const PatchTeamBannerSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить баннер команды', + description: 'Загрузка файла изображения для обложки (баннера) команды.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Баннер команды успешно обновлен.', + type: FileUploadResponse.Output, + }), + ApiBadRequest('Файл не передан или имеет неверный формат'), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ApiForbidden(), + ); diff --git a/src/modules/teams/controller/teams.controller.ts b/src/teams/application/controller/teams/controller.ts similarity index 64% rename from src/modules/teams/controller/teams.controller.ts rename to src/teams/application/controller/teams/controller.ts index 6d85b34..b4546e0 100644 --- a/src/modules/teams/controller/teams.controller.ts +++ b/src/teams/application/controller/teams/controller.ts @@ -1,23 +1,23 @@ import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { TeamsService } from '../services'; import { CreateTeamSwagger, FindOneTeamSwagger, RemoveTeamSwagger, UpdateTeamSwagger, CheckSlugSwagger, -} from './teams.swagger'; -import { CreateTeamDto } from '../dtos'; +} from './swagger'; +import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; @ApiBaseController('teams', 'Teams', true) export class TeamsController { - constructor(private readonly facade: TeamsService) {} + constructor(private readonly facade: TeamsFacade) {} @Post() @CreateTeamSwagger() async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { - return this.facade.create(userId, dto); + return this.facade.createTeam(userId, dto); } @Get('check-slug/:slug') @@ -29,19 +29,23 @@ export class TeamsController { @Get(':slug') @FindOneTeamSwagger() async findOne(@Param('slug') slug: string) { - return this.facade.getOne(slug); + return this.facade.getTeamBySlug(slug); } @Patch(':slug') @UpdateTeamSwagger() - async update(@Param('slug') slug: string, @GetUserId() userId: string, @Body() dto: any) { - return this.facade.update(slug, userId, dto); + async update( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: UpdateTeamDto, + ) { + return this.facade.updateTeam(slug, userId, dto); } @Delete(':slug') @RemoveTeamSwagger() @HttpCode(HttpStatus.OK) async remove(@Param('slug') slug: string, @GetUserId() userId: string) { - return this.facade.remove(slug, userId); + return this.facade.deleteTeam(slug, userId); } } diff --git a/src/teams/application/controller/teams/swagger.ts b/src/teams/application/controller/teams/swagger.ts new file mode 100644 index 0000000..fac00e5 --- /dev/null +++ b/src/teams/application/controller/teams/swagger.ts @@ -0,0 +1,87 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ActionResponse } from '@shared/dtos'; +import { + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { CreateTeamDto, UpdateTeamDto, CheckSlugResponse } from '../../dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: ActionResponse.Output, + }), + ApiConflict('Команда с таким slug уже существует'), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность слага', + description: 'Проверяет, свободен ли уникальный адрес команды для использования.', + }), + ApiParam({ + name: 'slug', + description: 'Желаемый слаг команды', + example: 'my-super-team', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки доступности', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }), + ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: Object, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }), + ApiResponse({ + status: 200, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/teams/application/dtos/index.ts similarity index 100% rename from src/modules/teams/dtos/index.ts rename to src/teams/application/dtos/index.ts diff --git a/src/modules/teams/dtos/invitation.dto.ts b/src/teams/application/dtos/invitation.dto.ts similarity index 95% rename from src/modules/teams/dtos/invitation.dto.ts rename to src/teams/application/dtos/invitation.dto.ts index d3908a9..9d7c2b8 100644 --- a/src/modules/teams/dtos/invitation.dto.ts +++ b/src/teams/application/dtos/invitation.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum, TeamRole } from '../entities/enums'; +import { roleEnum, TeamRole } from '../../infrastructure/persistence/models/enums'; export const UpdateInvitationSchema = z.object({ role: z diff --git a/src/modules/teams/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts similarity index 97% rename from src/modules/teams/dtos/member.dto.ts rename to src/teams/application/dtos/member.dto.ts index ac48ccd..2fe245d 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { roleEnum } from '../entities'; +import { roleEnum } from '@core/teams/infrastructure/persistence/models'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), diff --git a/src/modules/teams/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts similarity index 100% rename from src/modules/teams/dtos/team.dto.ts rename to src/teams/application/dtos/team.dto.ts diff --git a/src/modules/teams/mappers/index.ts b/src/teams/application/mappers/index.ts similarity index 100% rename from src/modules/teams/mappers/index.ts rename to src/teams/application/mappers/index.ts diff --git a/src/modules/teams/mappers/member.mapper.ts b/src/teams/application/mappers/member.mapper.ts similarity index 96% rename from src/modules/teams/mappers/member.mapper.ts rename to src/teams/application/mappers/member.mapper.ts index cf2f6f5..297f1e1 100644 --- a/src/modules/teams/mappers/member.mapper.ts +++ b/src/teams/application/mappers/member.mapper.ts @@ -1,4 +1,4 @@ -import type { RawMemberRow, RawMemberTeams } from '../repository'; +import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; export class TeamMemberMapper { public static toDetail(row: RawMemberRow) { diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts new file mode 100644 index 0000000..e2832ff --- /dev/null +++ b/src/teams/application/team.facade.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import * as UC from './use-cases'; +import type { + CreateTeamDto, + InviteMemberDto, + UpdateInvitationDto, + UpdateMemberDto, + UpdateTeamDto, +} from './dtos'; +import { FileUploadDto } from '@core/modules/media'; + +@Injectable() +export class TeamsFacade { + constructor( + private readonly findTeamQ: UC.FindTeamQuery, + private readonly getInvitationQ: UC.GetInvitationQuery, + private readonly getInvitationsQ: UC.GetInvitationsQuery, + private readonly getTeamMembersQ: UC.GetTeamMembersQuery, + private readonly checkSlugQ: UC.CheckTeamSlugQuery, + + private readonly createTeamUc: UC.CreateTeamUseCase, + private readonly deleteTeamUc: UC.DeleteTeamUseCase, + private readonly updateTeamUc: UC.UpdateTeamUseCase, + private readonly syncTagsUc: UC.SyncTeamTagsUseCase, + private readonly updateAvatarUc: UC.UpdateTeamAvatarUseCase, + private readonly updateBannerUc: UC.UpdateTeamBannerUseCase, + + private readonly updateMemberUc: UC.UpdateTeamMemberUseCase, + private readonly removeMemberUc: UC.RemoveTeamMemberUseCase, + private readonly sendInviteUc: UC.SendInvitationUseCase, + private readonly acceptInviteUc: UC.AcceptInvitationUseCase, + private readonly updateInvitationUc: UC.UpdateInvitationUseCase, + private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + + private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, + private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, + ) {} + + public checkSlug = (slug: string) => this.checkSlugQ.execute(slug); + + public getTeamBySlug = (slug: string) => this.findTeamQ.execute(slug); + + public getInvitation = (slug: string, code: string, userId: string) => + this.getInvitationQ.execute(slug, code, userId); + + public createTeam = (ownerId: string, dto: CreateTeamDto) => + this.createTeamUc.execute(ownerId, dto); + + public updateTeam = (slug: string, userId: string, dto: UpdateTeamDto) => + this.updateTeamUc.execute(slug, userId, dto); + + public deleteTeam = (slug: string, userId: string) => this.deleteTeamUc.execute(slug, userId); + + public getMembers = (slug: string) => this.getTeamMembersQ.execute(slug); + + public updateMember = (slug: string, curr: string, target: string, dto: UpdateMemberDto) => + this.updateMemberUc.execute(slug, curr, target, dto); + + public removeMember = (slug: string, curr: string, target: string) => + this.removeMemberUc.execute(slug, curr, target); + + public getInvitations = (slug: string, userId?: string) => + this.getInvitationsQ.execute(slug, userId); + + public invite = (slug: string, inviterId: string, dto: InviteMemberDto) => + this.sendInviteUc.execute(slug, inviterId, dto); + + public acceptInvite = (code: string, userId: string, email: string) => + this.acceptInviteUc.execute(code, userId, email); + + public declineInvitation = (slug: string, code: string, userId: string) => + this.declineInvitationUc.execute(slug, code, userId); + + public updateInvitation = ( + slug: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => this.updateInvitationUc.execute(slug, code, userId, dto); + + public updateAvatar = (slug: string, file: FileUploadDto) => + this.updateAvatarUc.execute(slug, file); + + public updateBanner = (slug: string, file: FileUploadDto) => + this.updateBannerUc.execute(slug, file); + + public syncTags = (slug: string, tags: string[]) => this.syncTagsUc.execute(slug, tags); + + public getMyTeams = (userId: string, pagination: any) => + this.getMyTeamsUc.execute(userId, pagination); + + public getMyInvites = (email: string) => this.getMyInvitesUc.execute(email); +} diff --git a/src/teams/application/use-cases/accept-invitation.use-case.ts b/src/teams/application/use-cases/accept-invitation.use-case.ts new file mode 100644 index 0000000..46fabe2 --- /dev/null +++ b/src/teams/application/use-cases/accept-invitation.use-case.ts @@ -0,0 +1,83 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import type { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class AcceptInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(code: string, userId: string, email: string) { + const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); + if (!inviteRaw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'The invitation link has expired or is no longer valid.', + }, + HttpStatus.GONE, + ); + } + + const invite = JSON.parse(inviteRaw) as TeamInvite; + if (invite.email.toLowerCase() !== email.toLowerCase()) { + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'This invitation was sent to a different email address.', + }, + HttpStatus.FORBIDDEN, + ); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + if (member) { + if (member.status === 'banned') { + throw new BaseException( + { code: 'MEMBER_BANNED', message: 'You are banned from this team.' }, + HttpStatus.FORBIDDEN, + ); + } + if (member.status === 'active') { + await this.cleanupInvite(code, invite.teamId, email); + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'You are already a member of this team.' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date(), + }); + + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(invite.teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + + return { success: true, message: 'Вы успешно присоединились к команде' }; + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + } +} diff --git a/src/teams/application/use-cases/check-team-slug.query.ts b/src/teams/application/use-cases/check-team-slug.query.ts new file mode 100644 index 0000000..2ad8ae8 --- /dev/null +++ b/src/teams/application/use-cases/check-team-slug.query.ts @@ -0,0 +1,21 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CheckTeamSlugQuery { + constructor(@Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository) {} + + async execute(slug: string) { + const normalizedSlug = slug.trim().toLowerCase(); + + const available = await this.teamsRepo.isSlugAvailable(normalizedSlug); + + return { + available, + message: available + ? `Slug "${normalizedSlug}" доступен для использования` + : `Slug "${normalizedSlug}" уже занят`, + details: { slug: normalizedSlug }, + }; + } +} diff --git a/src/teams/application/use-cases/create-team.use-case.ts b/src/teams/application/use-cases/create-team.use-case.ts new file mode 100644 index 0000000..d12ced0 --- /dev/null +++ b/src/teams/application/use-cases/create-team.use-case.ts @@ -0,0 +1,58 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CreateTeamDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { slugify } from 'transliteration'; + +@Injectable() +export class CreateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, dto: CreateTeamDto) { + const baseSlug = slugify(dto.slug || dto.name, { lowercase: true, separator: '-' }); + const existingTeam = await this.teamsRepo.findBySlug(baseSlug); + + if (existingTeam) { + throw new BaseException( + { + code: 'SLUG_ALREADY_EXISTS', + message: `Ссылка "${baseSlug}" уже занята другой командой`, + details: [{ target: 'slug', value: baseSlug }], + }, + HttpStatus.CONFLICT, + ); + } + + const { tags, ...teamData } = dto; + const uniqueTags = tags ? [...new Set(tags.map((tag) => tag.toLowerCase()))] : []; + + try { + const result = await this.teamsRepo.create( + userId, + { + ...teamData, + slug: baseSlug, + }, + uniqueTags, + ); + + return { + ...result, + slug: baseSlug, + message: 'Команда успешно создана', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/decline-invitation.use-case.ts b/src/teams/application/use-cases/decline-invitation.use-case.ts new file mode 100644 index 0000000..957ca1e --- /dev/null +++ b/src/teams/application/use-cases/decline-invitation.use-case.ts @@ -0,0 +1,88 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class DeclineInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Только администраторы могут удалять приглашения', + details: [{ userId }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const rawInvite = await this.redis.get(this.INVITES_KEY(code)); + if (!rawInvite) { + throw new BaseException( + { + code: 'INVITE_ALREADY_REMOVED', + message: 'Приглашение не найдено (возможно, оно уже было принято или удалено)', + details: [{ code }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(rawInvite) as TeamInvite; + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'ACCESS_DENIED', + message: 'Вы не можете удалить приглашение чужой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(team.id), code); + multi.srem(this.USER_INVITES_KEY(invite.email), code); + await multi.exec(); + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: 'INFRASTRUCTURE_ERROR', + message: 'Не удалось корректно удалить приглашение из системы', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Приглашение отозвано администратором', + }; + } +} diff --git a/src/teams/application/use-cases/delete-team.use-case.ts b/src/teams/application/use-cases/delete-team.use-case.ts new file mode 100644 index 0000000..b394542 --- /dev/null +++ b/src/teams/application/use-cases/delete-team.use-case.ts @@ -0,0 +1,61 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string) { + // 1. Ищем команду по слагу + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + // 2. Проверяем права (бизнес-логика удаления) + // Владелец определяется либо через ownerId в таблице команд, + // либо через роль 'owner' в таблице участников. + const member = await this.teamsRepo.findMember(team.id, userId); + const isOwner = team.ownerId === userId || member?.role === 'owner'; + + if (!isOwner) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + // 3. Выполняем удаление + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Команда успешно удалена', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/modules/teams/commands/find-member.command.ts b/src/teams/application/use-cases/find-team-member.query.ts similarity index 76% rename from src/modules/teams/commands/find-member.command.ts rename to src/teams/application/use-cases/find-team-member.query.ts index ee15c5e..ee38870 100644 --- a/src/modules/teams/commands/find-member.command.ts +++ b/src/teams/application/use-cases/find-team-member.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../domain/repository'; @Injectable() -export class FindTeamMemberCommand { +export class FindTeamMemberQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/modules/teams/commands/find-team.command.ts b/src/teams/application/use-cases/find-team.query.ts similarity index 75% rename from src/modules/teams/commands/find-team.command.ts rename to src/teams/application/use-cases/find-team.query.ts index f9d11a2..b7b7fa0 100644 --- a/src/modules/teams/commands/find-team.command.ts +++ b/src/teams/application/use-cases/find-team.query.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../repository'; +import { ITeamsRepository } from '../../domain/repository'; @Injectable() -export class FindTeamCommand { +export class FindTeamQuery { constructor( @Inject('ITeamsRepository') private readonly repository: ITeamsRepository, diff --git a/src/teams/application/use-cases/get-all-tags.use-case.ts b/src/teams/application/use-cases/get-all-tags.use-case.ts new file mode 100644 index 0000000..4e7890f --- /dev/null +++ b/src/teams/application/use-cases/get-all-tags.use-case.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { FindTagsQuery } from '../dtos'; +import { ITeamsRepository } from '@core/teams/domain/repository'; + +@Injectable() +export class GetAllTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(query: FindTagsQuery) { + const safePage = Math.max(query.page ?? 1, 1); + const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const offset = (safePage - 1) * safeLimit; + + const { data, total } = await this.teamsRepo.findAllTags({ + search: query.search, + limit: safeLimit, + offset, + }); + + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + + return { + data, + meta: { + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + total, + totalPages, + page: safePage, + limit: safeLimit, + }, + }; + } +} diff --git a/src/teams/application/use-cases/get-invitation.query.ts b/src/teams/application/use-cases/get-invitation.query.ts new file mode 100644 index 0000000..0abde02 --- /dev/null +++ b/src/teams/application/use-cases/get-invitation.query.ts @@ -0,0 +1,52 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationQuery { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, + HttpStatus.FORBIDDEN, + ); + } + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { code: 'INVITE_EXPIRED_OR_INVALID', message: 'Срок действия истек' }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(raw) as TeamInvite; + if (invite.teamId !== team.id) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + + return { code, ...invite }; + } +} diff --git a/src/teams/application/use-cases/get-invitations.query.ts b/src/teams/application/use-cases/get-invitations.query.ts new file mode 100644 index 0000000..5e7f50a --- /dev/null +++ b/src/teams/application/use-cases/get-invitations.query.ts @@ -0,0 +1,58 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import Redis from 'ioredis'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationsQuery { + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, userId?: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + if (userId) { + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав управлять приглашениями', + }, + HttpStatus.FORBIDDEN, + ); + } + } + + const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); + if (!codes.length) return []; + + const keys = codes.map((c) => this.INVITES_KEY(c)); + const invitesRaw = await this.redis.mget(...keys); + + return invitesRaw + .map((raw, idx) => { + if (!raw) return null; + try { + const invite = JSON.parse(raw) as TeamInvite; + return { code: codes[idx], ...invite }; + } catch { + return null; + } + }) + .filter((v): v is TeamInvite & { code: string } => v !== null); + } +} diff --git a/src/teams/application/use-cases/get-my-invites.use-case.ts b/src/teams/application/use-cases/get-my-invites.use-case.ts new file mode 100644 index 0000000..58795c9 --- /dev/null +++ b/src/teams/application/use-cases/get-my-invites.use-case.ts @@ -0,0 +1,24 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class GetMyInvitesUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(email: string) { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + } +} diff --git a/src/teams/application/use-cases/get-my-teams.use-case.ts b/src/teams/application/use-cases/get-my-teams.use-case.ts new file mode 100644 index 0000000..e7755f3 --- /dev/null +++ b/src/teams/application/use-cases/get-my-teams.use-case.ts @@ -0,0 +1,16 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetMyTeamsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, pagination: Record) { + const teams = await this.teamsRepo.findByUser(userId, pagination); + return teams.map((t) => TeamMemberMapper.toUserTeam(t)); + } +} diff --git a/src/teams/application/use-cases/get-team-members.query.ts b/src/teams/application/use-cases/get-team-members.query.ts new file mode 100644 index 0000000..b44572f --- /dev/null +++ b/src/teams/application/use-cases/get-team-members.query.ts @@ -0,0 +1,26 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetTeamMembersQuery { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${slug} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const members = await this.teamsRepo.findMembers(team.id); + return TeamMemberMapper.toList(members); + } +} diff --git a/src/teams/application/use-cases/get-user-invites.use-case.ts b/src/teams/application/use-cases/get-user-invites.use-case.ts new file mode 100644 index 0000000..9937531 --- /dev/null +++ b/src/teams/application/use-cases/get-user-invites.use-case.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { TeamMemberMapper } from '@core/teams/application/mappers'; + +@Injectable() +export class GetUserInvitesUseCase { + constructor( + @InjectRedis() + private readonly redis: Redis, + ) {} + + async execute(email: string) { + const codes = await this.redis.smembers(`user:invites:${email}`); + + if (!codes.length) return []; + + const results = await this.redis.mget(codes.map((c) => `inv:code:${c}`)); + + return results + .map((raw, i) => TeamMemberMapper.toPublicInvite(raw, codes[i])) + .filter(Boolean); + } +} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts new file mode 100644 index 0000000..d6624d6 --- /dev/null +++ b/src/teams/application/use-cases/index.ts @@ -0,0 +1,24 @@ +export { CheckTeamSlugQuery } from './check-team-slug.query'; +export { FindTeamQuery } from './find-team.query'; +export { FindTeamMemberQuery } from './find-team-member.query'; +export { GetInvitationQuery } from './get-invitation.query'; +export { GetInvitationsQuery } from './get-invitations.query'; +export { GetTeamMembersQuery } from './get-team-members.query'; + +export { AcceptInvitationUseCase } from './accept-invitation.use-case'; +export { CreateTeamUseCase } from './create-team.use-case'; +export { DeleteTeamUseCase } from './delete-team.use-case'; +export { GetAllTagsUseCase } from './get-all-tags.use-case'; +export { GetMyInvitesUseCase } from './get-my-invites.use-case'; +export { GetMyTeamsUseCase } from './get-my-teams.use-case'; +export { GetUserInvitesUseCase } from './get-user-invites.use-case'; +export { RemoveTeamMemberUseCase } from './remove-team-member.use-case'; +export { SendInvitationUseCase } from './send-invitation.use-case'; +export { SyncTeamTagsUseCase } from './sync-team-tags.use-case'; +export { UpdateTeamUseCase } from './update-team.use-case'; +export { UpdateTeamAvatarUseCase } from './update-team-avatar.use-case'; +export { UpdateTeamBannerUseCase } from './update-team-banner.use-case'; +export { UpdateTeamMemberUseCase } from './update-team-member.use-case'; + +export { UpdateInvitationUseCase } from './update-invitation.use-case'; +export { DeclineInvitationUseCase } from './decline-invitation.use-case'; diff --git a/src/teams/application/use-cases/remove-team-member.use-case.ts b/src/teams/application/use-cases/remove-team-member.use-case.ts new file mode 100644 index 0000000..835e3af --- /dev/null +++ b/src/teams/application/use-cases/remove-team-member.use-case.ts @@ -0,0 +1,82 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${slug} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } + + const isSelfRemoval = currentUserId === targetUserId; + + if (isSelfRemoval) { + if (currentUser.role === 'owner') { + throw new BaseException( + { code: 'OWNER_CANNOT_LEAVE', message: 'Владелец не может покинуть команду' }, + HttpStatus.BAD_REQUEST, + ); + } + } else { + const canKick = ROLE_PRIORITY[currentUser.role] > ROLE_PRIORITY[targetUser.role]; + const hasAuthority = ROLE_PRIORITY[currentUser.role] >= ROLE_PRIORITY.admin; + + if (!hasAuthority || !canKick) { + throw new BaseException( + { + code: 'KICK_FORBIDDEN', + message: 'У вас недостаточно прав, чтобы исключить этого участника', + details: [ + { reason: !hasAuthority ? 'Low authority' : 'Target rank too high' }, + ], + }, + HttpStatus.FORBIDDEN, + ); + } + } + + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + } catch (error) { + throw new BaseException( + { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/send-invitation.use-case.ts b/src/teams/application/use-cases/send-invitation.use-case.ts new file mode 100644 index 0000000..b8e9c19 --- /dev/null +++ b/src/teams/application/use-cases/send-invitation.use-case.ts @@ -0,0 +1,91 @@ +import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { InviteMemberDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { generateSecret } from 'otplib'; +import { TeamInvite } from '../dtos/invitation.dto'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; + +@Injectable() +export class SendInvitationUseCase { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + ) {} + + async execute(slug: string, inviterId: string, dto: InviteMemberDto) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Team does not exist', + }, + HttpStatus.NOT_FOUND, + ); + } + + const inviter = await this.teamsRepo.findMember(team.id, inviterId); + if (!inviter || (inviter.role !== 'owner' && inviter.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Only admins or owners can invite new members', + }, + HttpStatus.FORBIDDEN, + ); + } + + // TODO AVOID DUPLICATE INVITIONS + + const code = generateSecret({ length: 8 }); + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); + + const inviteData: TeamInvite = { + teamId: team.id, + teamName: team.name, + teamAvatar: team.avatarUrl, + email: dto.email, + role: dto.role || 'member', + inviterId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + const multi = this.redis.multi(); + multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); + multi.sadd(this.TEAM_INVITES_KEY(team.id), code); + multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); + await multi.exec(); + + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); + const FRONTEND_URL = origins[0]; + const event = new TeamInvitationEvent( + dto.email, + team.name, + `${FRONTEND_URL}/invites/accept?code=${code}`, + ); + + await this.mailQueue.add(TeamMailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + + return { success: true, message: `Приглашение отправлено на ${dto.email}`, code }; + } +} diff --git a/src/teams/application/use-cases/sync-team-tags.use-case.ts b/src/teams/application/use-cases/sync-team-tags.use-case.ts new file mode 100644 index 0000000..5199ad0 --- /dev/null +++ b/src/teams/application/use-cases/sync-team-tags.use-case.ts @@ -0,0 +1,45 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SyncTeamTagsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, tags: string[]) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + const normalizedTags = [...new Set(tags.map((t) => t.trim()).filter(Boolean))]; + + const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags); + + if (!isSynced) { + throw new BaseException( + { + code: 'TAGS_SYNC_FAILED', + message: 'Не удалось обновить теги команды', + details: [{ target: 'tags', count: normalizedTags.length }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Теги команды успешно обновлены', + }; + } +} diff --git a/src/teams/application/use-cases/update-invitation.use-case.ts b/src/teams/application/use-cases/update-invitation.use-case.ts new file mode 100644 index 0000000..c15f451 --- /dev/null +++ b/src/teams/application/use-cases/update-invitation.use-case.ts @@ -0,0 +1,80 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { UpdateInvitationDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { TeamInvite } from '../dtos/invitation.dto'; + +@Injectable() +export class UpdateInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @InjectRedis() private readonly redis: Redis, + ) {} + + async execute(slug: string, code: string, userId: string, dto: UpdateInvitationDto) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав на редактирование приглашений в этой команде', + details: [{ requiredRoles: ['owner', 'admin'], currentRole: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const key = this.INVITES_KEY(code); + const [rawInvite, ttl] = await Promise.all([this.redis.get(key), this.redis.ttl(key)]); + + if (!rawInvite || ttl <= 0) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND_OR_EXPIRED', + message: 'Приглашение не найдено или его срок действия уже истек', + details: [{ code }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = JSON.parse(rawInvite) as TeamInvite; + + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_TEAM_MISMATCH', + message: 'Это приглашение принадлежит другой команде', + details: [{ inviteTeamId: invite.teamId, requestTeamId: team.id }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + invite.role = dto.role; + + if (ttl > 0) { + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + } else { + await this.redis.set(key, JSON.stringify(invite)); + } + + return { + success: true, + message: 'Приглашение успешно обновлено', + details: { code, role: invite.role, email: invite.email }, + }; + } +} diff --git a/src/teams/application/use-cases/update-team-avatar.use-case.ts b/src/teams/application/use-cases/update-team-avatar.use-case.ts new file mode 100644 index 0000000..d1cf2f3 --- /dev/null +++ b/src/teams/application/use-cases/update-team-avatar.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamAvatarUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(slug: string, fileDto: FileUploadDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.mediaService.uploadTeamAvatar(team.id, fileDto, (url) => + this.teamsRepo.updateTeamAvatar(team.id, url), + ); + } +} diff --git a/src/teams/application/use-cases/update-team-banner.use-case.ts b/src/teams/application/use-cases/update-team-banner.use-case.ts new file mode 100644 index 0000000..6bf4a52 --- /dev/null +++ b/src/teams/application/use-cases/update-team-banner.use-case.ts @@ -0,0 +1,31 @@ +import { FileUploadDto, ITeamMedia, TEAM_MEDIA_TOKEN } from '@core/modules/media'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamBannerUseCase { + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(TEAM_MEDIA_TOKEN) private readonly mediaService: ITeamMedia, + ) {} + + async execute(slug: string, fileDto: FileUploadDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.mediaService.uploadTeamBanner(team.id, fileDto, (url) => + this.teamsRepo.updateTeamBanner(team.id, url), + ); + } +} diff --git a/src/teams/application/use-cases/update-team-member.use-case.ts b/src/teams/application/use-cases/update-team-member.use-case.ts new file mode 100644 index 0000000..9ba21d4 --- /dev/null +++ b/src/teams/application/use-cases/update-team-member.use-case.ts @@ -0,0 +1,100 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { UpdateMemberDto } from '../dtos'; +import { BaseException } from '@shared/error'; +import { ROLE_PRIORITY } from '@shared/constants'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, currentUserId: string, targetUserId: string, dto: UpdateMemberDto) { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${slug} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!currentUser || !targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + // 1. Проверка минимальной роли для редактирования + if (ROLE_PRIORITY[currentUser.role] < ROLE_PRIORITY.admin) { + throw new BaseException( + { + code: 'ADMIN_ROLE_REQUIRED', + message: 'У вас нет прав на редактирование участников', + }, + HttpStatus.FORBIDDEN, + ); + } + + // 2. Нельзя менять роль тому, кто выше или равен по весу + if ( + currentUserId !== targetUserId && + ROLE_PRIORITY[currentUser.role] <= ROLE_PRIORITY[targetUser.role] + ) { + throw new BaseException( + { + code: 'INSUFFICIENT_RANK', + message: 'Вы не можете менять данные участника с равным или высшим рангом', + details: [{ currentRole: currentUser.role, targetRole: targetUser.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + // 3. Защита Owner + if (targetUser.role === 'owner' && dto.role && dto.role !== 'owner') { + throw new BaseException( + { + code: 'OWNER_PROTECTION_VIOLATION', + message: 'Нельзя изменить роль владельца через это меню', + }, + HttpStatus.BAD_REQUEST, + ); + } + + // 4. Нельзя назначить роль выше своей + if ( + dto.role && + ROLE_PRIORITY[dto.role] >= ROLE_PRIORITY[currentUser.role] && + currentUser.role !== 'owner' + ) { + throw new BaseException( + { + code: 'CANNOT_ASSIGN_HIGHER_ROLE', + message: 'Вы не можете назначить роль выше своей или равную своей', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды "${team.name}" успешно обновлены`, + }; + } catch (error) { + throw new BaseException( + { code: 'MEMBER_UPDATE_FAILED', message: 'Ошибка при обновлении данных участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/update-team.use-case.ts b/src/teams/application/use-cases/update-team.use-case.ts new file mode 100644 index 0000000..7525c5a --- /dev/null +++ b/src/teams/application/use-cases/update-team.use-case.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { ITeamsRepository } from '../../domain/repository'; +import type { UpdateTeamDto } from '../dtos'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class UpdateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(slug: string, userId: string, dto: UpdateTeamDto) { + const team = await this.teamsRepo.findBySlug(slug); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${slug} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + const { tags, ...data } = dto; + + try { + const result = await this.teamsRepo.update(team.id, data, tags); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts new file mode 100644 index 0000000..40d100b --- /dev/null +++ b/src/teams/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './teams.domain'; diff --git a/src/modules/teams/entities/teams.domain.ts b/src/teams/domain/entities/teams.domain.ts similarity index 87% rename from src/modules/teams/entities/teams.domain.ts rename to src/teams/domain/entities/teams.domain.ts index 75c044b..9ffee16 100644 --- a/src/modules/teams/entities/teams.domain.ts +++ b/src/teams/domain/entities/teams.domain.ts @@ -1,5 +1,5 @@ import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; -import { teams, teamMembers, tags, teamsToTags } from './teams.entity'; +import { teams, teamMembers, tags, teamsToTags } from '../../infrastructure/persistence/models'; export type Team = InferSelectModel; export type NewTeam = InferInsertModel; diff --git a/src/teams/domain/enums/index.ts b/src/teams/domain/enums/index.ts new file mode 100644 index 0000000..4a72780 --- /dev/null +++ b/src/teams/domain/enums/index.ts @@ -0,0 +1 @@ +export { TeamMailJobs, TeamQueues } from './mail-jobs.enum'; diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/teams/domain/enums/mail-jobs.enum.ts new file mode 100644 index 0000000..a46d334 --- /dev/null +++ b/src/teams/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,7 @@ +export enum TeamQueues { + TEAM_MAIL = 'TEAM_MAIL_QUEUE', +} + +export enum TeamMailJobs { + SEND_TEAM_INVITATION = 'TEAM_SEND_TEAM_INVITATION', +} diff --git a/src/shared/workers/events/index.ts b/src/teams/domain/events/index.ts similarity index 100% rename from src/shared/workers/events/index.ts rename to src/teams/domain/events/index.ts diff --git a/src/shared/workers/events/team-invitation.event.ts b/src/teams/domain/events/team-invitation.event.ts similarity index 100% rename from src/shared/workers/events/team-invitation.event.ts rename to src/teams/domain/events/team-invitation.event.ts diff --git a/src/modules/teams/repository/index.ts b/src/teams/domain/repository/index.ts similarity index 68% rename from src/modules/teams/repository/index.ts rename to src/teams/domain/repository/index.ts index f78a0c8..0d97b36 100644 --- a/src/modules/teams/repository/index.ts +++ b/src/teams/domain/repository/index.ts @@ -1,4 +1,3 @@ -export { TeamsRepository } from './teams.repository'; export { ITeamsRepository, type RawMemberRow, diff --git a/src/modules/teams/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts similarity index 100% rename from src/modules/teams/repository/teams.repository.interface.ts rename to src/teams/domain/repository/teams.repository.interface.ts diff --git a/src/teams/index.ts b/src/teams/index.ts new file mode 100644 index 0000000..f4d6e9c --- /dev/null +++ b/src/teams/index.ts @@ -0,0 +1,2 @@ +export { TeamsModule } from './teams.module'; +export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/modules/teams/entities/enums.ts b/src/teams/infrastructure/persistence/models/enums.ts similarity index 100% rename from src/modules/teams/entities/enums.ts rename to src/teams/infrastructure/persistence/models/enums.ts diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts new file mode 100644 index 0000000..f97c6e3 --- /dev/null +++ b/src/teams/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { tags, teamMembers, teams, teamsToTags } from './teams.model'; +export { type TeamRole, roleEnum, statusEnum } from './enums'; diff --git a/src/modules/teams/entities/teams.entity.ts b/src/teams/infrastructure/persistence/models/teams.model.ts similarity index 100% rename from src/modules/teams/entities/teams.entity.ts rename to src/teams/infrastructure/persistence/models/teams.model.ts diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts new file mode 100644 index 0000000..259ca0a --- /dev/null +++ b/src/teams/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamsRepository } from './teams.repository'; diff --git a/src/modules/teams/repository/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts similarity index 93% rename from src/modules/teams/repository/teams.repository.ts rename to src/teams/infrastructure/persistence/repositories/teams.repository.ts index 59f078b..b4e4294 100644 --- a/src/modules/teams/repository/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -1,13 +1,12 @@ -import { Inject, Logger } from '@nestjs/common'; -import { ITeamsRepository } from './teams.repository.interface'; +import { Inject } from '@nestjs/common'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; -import * as schema from '../entities'; +import * as schema from '../models'; import * as scUsers from '@core/user/infrastructure/persistence/models'; import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'; +import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; +import { ITeamsRepository } from '@core/teams/domain/repository'; export class TeamsRepository implements ITeamsRepository { - private logger = new Logger(TeamsRepository.name); - constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, @@ -22,7 +21,7 @@ export class TeamsRepository implements ITeamsRepository { return result.length === 0; }; - public addMember = async (dto: schema.NewTeamMember) => { + public addMember = async (dto: NewTeamMember) => { const { rowCount } = await this.db .insert(schema.teamMembers) .values(dto) @@ -33,7 +32,7 @@ export class TeamsRepository implements ITeamsRepository { return (rowCount ?? 0) > 0; }; - public create = async (ownerId: string, dto: schema.NewTeam, tags?: string[]) => { + public create = async (ownerId: string, dto: NewTeam, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .insert(schema.teams) @@ -80,7 +79,7 @@ export class TeamsRepository implements ITeamsRepository { }); }; - public update = async (id: string, dto: Partial, tags?: string[]) => { + public update = async (id: string, dto: Partial, tags?: string[]) => { return this.db.transaction(async (tx) => { const [{ teamId }] = await tx .update(schema.teams) @@ -231,11 +230,7 @@ export class TeamsRepository implements ITeamsRepository { return true; }; - public updateMember = async ( - teamId: string, - userId: string, - dto: Partial, - ) => { + public updateMember = async (teamId: string, userId: string, dto: Partial) => { const { role, status } = dto; const data = { diff --git a/src/teams/infrastructure/workers/index.ts b/src/teams/infrastructure/workers/index.ts new file mode 100644 index 0000000..d20e25d --- /dev/null +++ b/src/teams/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/shared/workers/mail/worker.ts b/src/teams/infrastructure/workers/mail.processor.ts similarity index 70% rename from src/shared/workers/mail/worker.ts rename to src/teams/infrastructure/workers/mail.processor.ts index 3fe4d34..34320b9 100644 --- a/src/shared/workers/mail/worker.ts +++ b/src/teams/infrastructure/workers/mail.processor.ts @@ -1,11 +1,11 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; -import { MailJobs, Queues } from '../enum'; import type { Job } from 'bullmq'; import { IMailPort } from '@shared/adapters/mail'; import { Inject } from '@nestjs/common'; -import { TeamInvitationEvent } from '../events'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamQueues } from '@core/teams/domain/enums'; -@Processor(Queues.MAIL) +@Processor(TeamQueues.TEAM_MAIL) export class MailProcessor extends WorkerHost { constructor( @Inject('IMailPort') @@ -14,19 +14,11 @@ export class MailProcessor extends WorkerHost { super(); } - async process(job: Job): Promise; - async process(job: Job): Promise { + async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { - switch (job.name) { - case MailJobs.SEND_TEAM_INVITATION: - await this.sendTeamInvitation(job); - break; - default: - await job.log(`[WRN] No handler for job: ${job.name}`); - await job.updateProgress(100); - } + await this.sendTeamInvitation(job); await job.log(`[DONE] Job ${job.id} processed`); } catch (error) { @@ -34,6 +26,7 @@ export class MailProcessor extends WorkerHost { const errorStack = error instanceof Error ? error.stack : ''; await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { await job.log(errorStack); } diff --git a/src/modules/teams/teams.module.ts b/src/teams/teams.module.ts similarity index 57% rename from src/modules/teams/teams.module.ts rename to src/teams/teams.module.ts index 708f2b6..1e81ba6 100644 --- a/src/modules/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -5,26 +5,51 @@ import { TeamsMembersController, TeamsController, MeController, -} from './controller'; -import { MediaModule } from '../media'; -import { - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, -} from './services'; -import { TeamsRepository } from './repository'; +} from './application/controller'; import { RedisModule } from '@nestjs-modules/ioredis'; import { ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; -import { Queues } from '@shared/workers'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; -import { FindTeamCommand, FindTeamMemberCommand } from './commands'; +import { TeamsRepository } from './infrastructure/persistence/repositories'; +import { TeamQueues } from './domain/enums'; +import { MediaModule } from '@core/modules/media'; +import { TeamsFacade } from './application/team.facade'; + +import * as UC from './application/use-cases'; const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; +const QUERIES = [ + UC.FindTeamQuery, + UC.FindTeamMemberQuery, + UC.GetInvitationQuery, + UC.GetInvitationsQuery, + UC.GetTeamMembersQuery, + UC.GetMyInvitesUseCase, + UC.GetMyTeamsUseCase, + UC.GetUserInvitesUseCase, + UC.GetAllTagsUseCase, + UC.CheckTeamSlugQuery, +]; + +const USE_CASES = [ + UC.CreateTeamUseCase, + UC.DeleteTeamUseCase, + UC.UpdateTeamUseCase, + UC.UpdateTeamAvatarUseCase, + UC.UpdateTeamBannerUseCase, + UC.SyncTeamTagsUseCase, + UC.UpdateTeamMemberUseCase, + UC.RemoveTeamMemberUseCase, + UC.SendInvitationUseCase, + UC.AcceptInvitationUseCase, + UC.UpdateInvitationUseCase, + UC.DeclineInvitationUseCase, +]; + +const EXTERNAL_USE_CASES = [UC.FindTeamMemberQuery, UC.FindTeamQuery]; + @Module({ imports: [ MediaModule, @@ -50,10 +75,10 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; }, }), BullModule.registerQueue({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, }), BullBoardModule.forFeature({ - name: Queues.MAIL, + name: TeamQueues.TEAM_MAIL, adapter: BullMQAdapter, }), ], @@ -64,16 +89,7 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; TeamsController, MeController, ], - providers: [ - REPOSITORY, - MeService, - TeamsService, - TeamMembersService, - TeamsSettingsService, - TeamInvitationsService, - FindTeamCommand, - FindTeamMemberCommand, - ], - exports: [FindTeamCommand, FindTeamMemberCommand], + providers: [REPOSITORY, ...USE_CASES, ...QUERIES, TeamsFacade], + exports: [...EXTERNAL_USE_CASES], }) export class TeamsModule {}