From bbbbff2358bda1e57a3c9da2e4313c21e96888ae Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Jun 2026 17:16:27 +0300 Subject: [PATCH 1/6] refactor(area): fix area update --- .../use-cases/areas/update.use-case.ts | 154 ++++++++++-------- 1 file changed, 87 insertions(+), 67 deletions(-) diff --git a/src/area/application/use-cases/areas/update.use-case.ts b/src/area/application/use-cases/areas/update.use-case.ts index 246221f7..cbe19b01 100644 --- a/src/area/application/use-cases/areas/update.use-case.ts +++ b/src/area/application/use-cases/areas/update.use-case.ts @@ -7,7 +7,7 @@ import slugify from 'slugify'; import { UpdateAreaDto } from '../../dtos'; -import type { NewArea } from '../../../domain/entities'; +import type { Area } from '../../../domain/entities'; @Injectable() export class UpdateAreaUseCase { @@ -24,7 +24,7 @@ export class UpdateAreaUseCase { 'owner', ]); - const area = await this.areaRepo.findBySlug(project.id, key); + const area = await this.areaRepo.findBySlug(key, project.id); if (!area) { throw new BaseException( @@ -36,75 +36,13 @@ export class UpdateAreaUseCase { ); } - const updateData: Partial = { - updatedAt: new Date().toISOString(), - ...(dto.title && dto.title !== area.title && { title: dto.title.trim() }), - ...(dto.description && - dto.description !== area.description && { - description: dto.description?.trim() || null, - }), - ...(dto.descriptionHtml && - dto.descriptionHtml !== area.descriptionHtml && { - descriptionHtml: dto.descriptionHtml?.trim() || null, - }), - ...(dto.color && dto.color !== area.color && { color: dto.color || null }), - ...(dto.icon && dto.icon !== area.icon && { icon: dto.icon || null }), - ...(dto.defaultView && - dto.defaultView !== area.defaultView && { defaultView: dto.defaultView }), - ...(dto.position && - dto.position !== area.position && - dto.position >= 0 && { position: dto.position }), - ...(dto.maxTasksLimit && - dto.maxTasksLimit !== area.maxTasksLimit && - dto.maxTasksLimit > 0 && { maxTasksLimit: dto.maxTasksLimit }), - ...(dto.isLocked && dto.isLocked !== area.isLocked && { isLocked: dto.isLocked }), - }; - - let hasChanges = false; + const updateData = this.buildUpdateData(area, dto); if (dto.slug && dto.slug !== area.slug) { - let newSlug = dto.slug; - - if (newSlug) { - newSlug = slugify(newSlug, { - lower: true, - strict: true, - trim: true, - }); - - if (!newSlug) { - throw new BaseException( - { - code: AreaErrorCodes.SLUG_INVALID, - message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const existingArea = await this.areaRepo.findBySlug(project.id, newSlug); - if (existingArea && existingArea.id !== area.id) { - throw new BaseException( - { - code: AreaErrorCodes.SLUG_DUPLICATE, - message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], - }, - HttpStatus.CONFLICT, - ); - } - - updateData.slug = newSlug; - } else { - updateData.slug = slugify(updateData.title || area.title, { - lower: true, - strict: true, - trim: true, - }); - } - hasChanges = true; + await this.updateSlug(project.id, area, dto.slug, updateData); } - if (!hasChanges) { + if (Object.keys(updateData).length === 0) { return { success: true, message: 'Нет изменений для обновления', @@ -131,4 +69,86 @@ export class UpdateAreaUseCase { ); } } + + private buildUpdateData(area: Area, dto: UpdateAreaDto): Partial { + const updateData: Partial = {}; + + if (dto.title !== undefined && dto.title !== area.title) { + updateData.title = dto.title.trim(); + } + + if (dto.description !== undefined && dto.description !== area.description) { + updateData.description = dto.description?.trim() ?? null; + } + + if (dto.descriptionHtml !== undefined && dto.descriptionHtml !== area.descriptionHtml) { + updateData.descriptionHtml = dto.descriptionHtml?.trim() ?? null; + } + + if (dto.color !== undefined && dto.color !== area.color) { + updateData.color = dto.color ?? null; + } + + if (dto.icon !== undefined && dto.icon !== area.icon) { + updateData.icon = dto.icon ?? null; + } + + if (dto.defaultView !== undefined && dto.defaultView !== area.defaultView) { + updateData.defaultView = dto.defaultView; + } + + if (dto.position !== undefined && dto.position !== area.position && dto.position >= 0) { + updateData.position = dto.position; + } + + if ( + dto.maxTasksLimit && + dto.maxTasksLimit !== area.maxTasksLimit && + dto.maxTasksLimit > 0 + ) { + updateData.maxTasksLimit = dto.maxTasksLimit; + } + + if (dto.isLocked !== undefined && dto.isLocked !== area.isLocked) { + updateData.isLocked = dto.isLocked; + } + + return updateData; + } + + private async updateSlug( + projectId: string, + area: Area, + slug: string, + updateData: Partial, + ): Promise { + const newSlug = slugify(slug, { + lower: true, + strict: true, + trim: true, + }); + + if (!newSlug) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_INVALID, + message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existingArea = await this.areaRepo.findBySlug(projectId, slug); + if (existingArea && existingArea.id !== area.id) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_DUPLICATE, + message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + updateData.slug = slug; + } } From 9990d11fd6f2273ec3df3ea6333cf0523978db58 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Jun 2026 17:17:15 +0300 Subject: [PATCH 2/6] refactor(area): fix area creating and update response --- src/area/application/controllers/area/swagger.ts | 12 +++++++++--- src/area/application/dtos/area.dto.ts | 9 +++++++++ .../application/use-cases/areas/create.use-case.ts | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/area/application/controllers/area/swagger.ts b/src/area/application/controllers/area/swagger.ts index 0cf2579f..0729cfa4 100644 --- a/src/area/application/controllers/area/swagger.ts +++ b/src/area/application/controllers/area/swagger.ts @@ -10,7 +10,13 @@ import { import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { CreateAreaDto, UpdateAreaDto, AreaResponse, AreasResponse } from '../../dtos'; +import { + CreateAreaDto, + UpdateAreaDto, + AreaResponse, + AreasResponse, + CreateAreaResponse, +} from '../../dtos'; export const CreateAreaSwagger = () => applyDecorators( @@ -42,14 +48,14 @@ export const CreateAreaSwagger = () => ApiResponse({ status: 201, description: 'Область успешно создана', - type: ActionResponse.Output, + type: CreateAreaResponse.Output, }), ApiValidationError(), ApiUnauthorized(), ApiForbidden('Нет прав для создания области в этом проекте'), ApiConflict('Область с таким названием или slug уже существует'), - SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + SetMetadata(ZOD_RESPONSE_TOKEN, CreateAreaResponse), ); export const FindAllAreasSwagger = () => diff --git a/src/area/application/dtos/area.dto.ts b/src/area/application/dtos/area.dto.ts index 5490c0a6..56163974 100644 --- a/src/area/application/dtos/area.dto.ts +++ b/src/area/application/dtos/area.dto.ts @@ -1,4 +1,5 @@ import { DEFAULT_VIEWS } from '@core/area/domain/entities'; +import { ActionResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -65,6 +66,7 @@ export const AreaSchema = z.object({ maxTasksLimit: z .number() .int('Лимит задач должен быть целым числом') + .max(100000, 'Лимит задач не может превышать 100 000') .positive('Лимит задач должен быть положительным числом') .nullable() .optional() @@ -117,6 +119,12 @@ export const CreateAreaSchema = AreaSchema.omit({ }) .describe('Схема для создания новой области'); +export const CreateAreaResponseSchema = ActionResponseSchema.extend({ + slug: z.string( + 'URL-дружественный идентификатор (например: "development", "contract-approval")', + ), +}); + export const UpdateAreaSchema = CreateAreaSchema.partial() .refine((data) => Object.keys(data).length > 0, { error: 'Необходимо передать хотя бы одно поле для обновления', @@ -126,6 +134,7 @@ export const UpdateAreaSchema = CreateAreaSchema.partial() export const AreasSchema = z.array(AreaSchema); +export class CreateAreaResponse extends createZodDto(CreateAreaResponseSchema) {} export class AreaResponse extends createZodDto(AreaSchema) {} export class AreasResponse extends createZodDto(AreasSchema) {} export class CreateAreaDto extends createZodDto(CreateAreaSchema) {} diff --git a/src/area/application/use-cases/areas/create.use-case.ts b/src/area/application/use-cases/areas/create.use-case.ts index 3f0c8666..f878d36d 100644 --- a/src/area/application/use-cases/areas/create.use-case.ts +++ b/src/area/application/use-cases/areas/create.use-case.ts @@ -40,7 +40,7 @@ export class CreateAreaUseCase { ); } - const existingArea = await this.areaRepo.findBySlug(project.id, currentSlug); + const existingArea = await this.areaRepo.findBySlug(currentSlug, project.id); if (existingArea) { throw new BaseException( From f9bf9f93ad7e015afc2ef05d7f71be4b42f3fd70 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Jun 2026 17:18:34 +0300 Subject: [PATCH 3/6] refactor(area): fix deleting area --- src/area/application/use-cases/areas/delete.use-case.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/area/application/use-cases/areas/delete.use-case.ts b/src/area/application/use-cases/areas/delete.use-case.ts index 35490331..44ec14fd 100644 --- a/src/area/application/use-cases/areas/delete.use-case.ts +++ b/src/area/application/use-cases/areas/delete.use-case.ts @@ -19,7 +19,7 @@ export class DeleteAreaUseCase { 'owner', ]); - const area = await this.areaRepo.findBySlug(project.id, key); + const area = await this.areaRepo.findBySlug(key, project.id); if (!area) { throw new BaseException( From 97fcdc358b3a09b8447f522c451ee74b56cf99e3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Jun 2026 17:21:55 +0300 Subject: [PATCH 4/6] refactor(invitations): fix getting invitations query --- .../application/use-cases/invitions/get-invitations.query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index 66b9b7e3..a00a7689 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -28,7 +28,7 @@ export class GetInvitationsQuery { total: 0, totalPages: 0, page: 1, - limit: 0, + limit: 10, hasPrevPage: false, hasNextPage: false, }, From c491406beb65b2e6fffd0ff203cf226daf696258 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 23 Jun 2026 17:54:25 +0300 Subject: [PATCH 5/6] refactor(state): rename `orderIndex` to `position` --- src/area/application/dtos/state.dto.ts | 3 ++- src/area/application/use-cases/states/reorder.use-case.ts | 8 ++++++++ src/area/application/use-cases/states/update.use-case.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/area/application/dtos/state.dto.ts b/src/area/application/dtos/state.dto.ts index 58d7f4a5..7821306f 100644 --- a/src/area/application/dtos/state.dto.ts +++ b/src/area/application/dtos/state.dto.ts @@ -47,7 +47,7 @@ export const StateSchema = z.object({ .nullable() .optional() .describe('Emoji или иконка для визуального обозначения (например: "📋", "🚀", "✅")'), - orderIndex: z + position: z .number() .int('Порядковый номер должен быть целым числом') .min(0, 'Порядковый номер не может быть отрицательным') @@ -60,6 +60,7 @@ export const StateSchema = z.object({ maxTasksLimit: z .number() .int('Лимит задач должен быть целым числом') + .max(100000, 'Лимит задач должен быть целым числом') .positive('Лимит задач должен быть положительным числом') .nullable() .optional() diff --git a/src/area/application/use-cases/states/reorder.use-case.ts b/src/area/application/use-cases/states/reorder.use-case.ts index f053663d..451215c2 100644 --- a/src/area/application/use-cases/states/reorder.use-case.ts +++ b/src/area/application/use-cases/states/reorder.use-case.ts @@ -15,6 +15,14 @@ export class ReorderStateUseCase { ) {} async execute(slug: string, _dto: ReordersStatesDto, userId: string) { + throw new BaseException( + { + code: 'NOT_IMPLEMENTED', + message: 'Функция в разработке', + }, + HttpStatus.NOT_IMPLEMENTED, + ); + try { const area = await this.getAreaQ.execute({ key: slug }, userId); diff --git a/src/area/application/use-cases/states/update.use-case.ts b/src/area/application/use-cases/states/update.use-case.ts index 7d88bff3..fafc0cb9 100644 --- a/src/area/application/use-cases/states/update.use-case.ts +++ b/src/area/application/use-cases/states/update.use-case.ts @@ -16,6 +16,7 @@ export class UpdateStateUseCase { async execute(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { try { + //TODO: 500 for unique constraint const area = await this.getAreaQ.execute({ key: slug }, userId); const state = await this.stateRepo.findOne(area.id, stateId); From f8183d6f13f0075d1e03342cd30273a3e26db476 Mon Sep 17 00:00:00 2001 From: Maksym Berehovyi <108676512+maksberegovoi@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:02:31 +0300 Subject: [PATCH 6/6] Feat/casl (#101) * feat(authorization): implement role-based access control system * feat(teams): add ability-based access control for invitations, roles, and team management * refactor(teams): rename TEAMS to TEAM * refactor(teams): update imports --- infra/k6/package.json | 8 +- ...ams-invitations.js => team-invitations.js} | 0 .../k6/scenarios/{teams-me.js => team-me.js} | 0 .../{teams-members.js => team-members.js} | 0 infra/k6/scenarios/{teams.js => team.js} | 0 migrations/0007_add_team_members_roles.sql | 6 + migrations/meta/0007_snapshot.json | 2390 +++++++++++++++++ migrations/meta/_journal.json | 7 + package.json | 9 +- pnpm-lock.yaml | 38 + src/app.module.ts | 6 +- src/auth/auth.module.ts | 4 +- .../infrastructure/workers/user.processor.ts | 2 +- .../application/mappers/project.mapper.ts | 2 +- .../use-cases/member/add.use-case.ts | 2 +- .../use-cases/project/find-one.query.ts | 2 +- .../domain/policy/project-access.policy.ts | 5 +- src/project/project.module.ts | 4 +- src/shared/authorization/ability.factory.ts | 65 + .../authorization/authorization.module.ts | 10 + .../authorization/authorization.spec.ts | 525 ++++ .../permissions/admin.permissions.ts | 50 + src/shared/authorization/permissions/index.ts | 5 + .../permissions/member.permissions.ts | 19 + .../permissions/owner.permissions.ts | 35 + .../permissions/permissions-map.ts | 14 + .../permissions/viewer.permissions.ts | 29 + src/shared/authorization/types/action.enum.ts | 7 + .../authorization/types/app-ability.type.ts | 5 + .../types/permission-rule.interface.ts | 10 + .../authorization/types/subject.enum.ts | 10 + src/shared/constants/roles.constant.ts | 7 +- src/shared/entities/index.ts | 2 +- src/team/application/controllers/index.ts | 4 + .../controllers}/invitations/controller.ts | 8 +- .../controllers}/invitations/swagger.ts | 0 .../application/controllers}/me/controller.ts | 5 +- .../application/controllers}/me/swagger.ts | 3 +- .../controllers}/members/controller.ts | 9 +- .../controllers}/members/swagger.ts | 13 +- .../controllers/team}/controller.ts | 9 +- .../application/controllers/team}/swagger.ts | 9 +- src/{teams => team}/application/dtos/index.ts | 0 .../application/dtos/invitation.dto.ts | 0 .../application/dtos/member.dto.ts | 3 +- .../application/dtos/team.dto.ts | 0 .../application/mappers/index.ts | 0 .../application/mappers/member.mapper.ts | 0 .../application/team.facade.ts | 8 +- .../use-cases/base/create-team.use-case.ts | 8 +- .../use-cases/base/delete-team.use-case.ts | 66 + .../use-cases/base/find-team.query.ts | 6 +- .../use-cases/base/get-my-teams.use-case.ts | 11 +- .../use-cases/base/update-team.use-case.ts | 51 +- .../application/use-cases/index.ts | 6 +- .../invitions/accept-invitation.use-case.ts | 9 +- .../invitions/delete-invitation.use-case.ts} | 68 +- .../invitions/get-invitation.query.ts | 9 +- .../invitions/get-invitations.query.ts | 45 +- .../invitions/get-my-invites.use-case.ts | 3 +- .../invitions/send-invitation.use-case.ts | 57 +- .../invitions/update-invitation.use-case.ts | 82 +- .../members/find-team-member.query.ts | 6 +- .../members/get-team-members.query.ts | 13 +- .../members/remove-team-member.use-case.ts | 101 + .../members/update-team-member.use-case.ts | 138 + src/team/domain/entities/index.ts | 1 + .../domain/entities/team.domain.ts} | 0 src/{teams => team}/domain/enums/index.ts | 0 .../domain/enums/mail-jobs.enum.ts | 0 src/{teams => team}/domain/events/index.ts | 0 .../domain/events/team-invitation.event.ts | 0 src/{teams => team}/domain/policy/index.ts | 0 .../domain/policy/team-member.policy.ts | 2 +- src/team/domain/repository/index.ts | 5 + .../repository/team.repository.interface.ts} | 7 +- src/{teams => team}/index.ts | 2 +- .../infrastructure/listeners/index.ts | 0 .../listeners/update-media.listener.ts | 9 +- .../persistence/models/enums.ts | 4 +- .../persistence/models/index.ts | 2 + .../persistence/models/team.model.ts} | 0 .../persistence/repositories/index.ts | 1 + .../repositories/team.repository.ts} | 6 +- .../infrastructure/workers/index.ts | 0 .../infrastructure/workers/mail.processor.ts | 5 +- .../teams.module.ts => team/team.module.ts} | 27 +- src/teams/application/controller/index.ts | 4 - .../use-cases/base/delete-team.use-case.ts | 59 - .../members/remove-team-member.use-case.ts | 83 - .../members/update-team-member.use-case.ts | 107 - src/teams/domain/entities/index.ts | 1 - src/teams/domain/repository/index.ts | 5 - .../persistence/models/index.ts | 2 - .../persistence/repositories/index.ts | 1 - tsconfig.json | 4 +- 96 files changed, 3845 insertions(+), 520 deletions(-) rename infra/k6/scenarios/{teams-invitations.js => team-invitations.js} (100%) rename infra/k6/scenarios/{teams-me.js => team-me.js} (100%) rename infra/k6/scenarios/{teams-members.js => team-members.js} (100%) rename infra/k6/scenarios/{teams.js => team.js} (100%) create mode 100644 migrations/0007_add_team_members_roles.sql create mode 100644 migrations/meta/0007_snapshot.json create mode 100644 src/shared/authorization/ability.factory.ts create mode 100644 src/shared/authorization/authorization.module.ts create mode 100644 src/shared/authorization/authorization.spec.ts create mode 100644 src/shared/authorization/permissions/admin.permissions.ts create mode 100644 src/shared/authorization/permissions/index.ts create mode 100644 src/shared/authorization/permissions/member.permissions.ts create mode 100644 src/shared/authorization/permissions/owner.permissions.ts create mode 100644 src/shared/authorization/permissions/permissions-map.ts create mode 100644 src/shared/authorization/permissions/viewer.permissions.ts create mode 100644 src/shared/authorization/types/action.enum.ts create mode 100644 src/shared/authorization/types/app-ability.type.ts create mode 100644 src/shared/authorization/types/permission-rule.interface.ts create mode 100644 src/shared/authorization/types/subject.enum.ts create mode 100644 src/team/application/controllers/index.ts rename src/{teams/application/controller => team/application/controllers}/invitations/controller.ts (92%) rename src/{teams/application/controller => team/application/controllers}/invitations/swagger.ts (100%) rename src/{teams/application/controller => team/application/controllers}/me/controller.ts (83%) rename src/{teams/application/controller => team/application/controllers}/me/swagger.ts (94%) rename src/{teams/application/controller => team/application/controllers}/members/controller.ts (83%) rename src/{teams/application/controller => team/application/controllers}/members/swagger.ts (99%) rename src/{teams/application/controller/teams => team/application/controllers/team}/controller.ts (82%) rename src/{teams/application/controller/teams => team/application/controllers/team}/swagger.ts (94%) rename src/{teams => team}/application/dtos/index.ts (100%) rename src/{teams => team}/application/dtos/invitation.dto.ts (100%) rename src/{teams => team}/application/dtos/member.dto.ts (97%) rename src/{teams => team}/application/dtos/team.dto.ts (100%) rename src/{teams => team}/application/mappers/index.ts (100%) rename src/{teams => team}/application/mappers/member.mapper.ts (100%) rename src/{teams => team}/application/team.facade.ts (93%) rename src/{teams => team}/application/use-cases/base/create-team.use-case.ts (78%) create mode 100644 src/team/application/use-cases/base/delete-team.use-case.ts rename src/{teams => team}/application/use-cases/base/find-team.query.ts (62%) rename src/{teams => team}/application/use-cases/base/get-my-teams.use-case.ts (70%) rename src/{teams => team}/application/use-cases/base/update-team.use-case.ts (56%) rename src/{teams => team}/application/use-cases/index.ts (93%) rename src/{teams => team}/application/use-cases/invitions/accept-invitation.use-case.ts (92%) rename src/{teams/application/use-cases/invitions/decline-invitation.use-case.ts => team/application/use-cases/invitions/delete-invitation.use-case.ts} (57%) rename src/{teams => team}/application/use-cases/invitions/get-invitation.query.ts (89%) rename src/{teams => team}/application/use-cases/invitions/get-invitations.query.ts (70%) rename src/{teams => team}/application/use-cases/invitions/get-my-invites.use-case.ts (97%) rename src/{teams => team}/application/use-cases/invitions/send-invitation.use-case.ts (71%) rename src/{teams => team}/application/use-cases/invitions/update-invitation.use-case.ts (58%) rename src/{teams => team}/application/use-cases/members/find-team-member.query.ts (61%) rename src/{teams => team}/application/use-cases/members/get-team-members.query.ts (80%) create mode 100644 src/team/application/use-cases/members/remove-team-member.use-case.ts create mode 100644 src/team/application/use-cases/members/update-team-member.use-case.ts create mode 100644 src/team/domain/entities/index.ts rename src/{teams/domain/entities/teams.domain.ts => team/domain/entities/team.domain.ts} (100%) rename src/{teams => team}/domain/enums/index.ts (100%) rename src/{teams => team}/domain/enums/mail-jobs.enum.ts (100%) rename src/{teams => team}/domain/events/index.ts (100%) rename src/{teams => team}/domain/events/team-invitation.event.ts (100%) rename src/{teams => team}/domain/policy/index.ts (100%) rename src/{teams => team}/domain/policy/team-member.policy.ts (98%) create mode 100644 src/team/domain/repository/index.ts rename src/{teams/domain/repository/teams.repository.interface.ts => team/domain/repository/team.repository.interface.ts} (88%) rename src/{teams => team}/index.ts (62%) rename src/{teams => team}/infrastructure/listeners/index.ts (100%) rename src/{teams => team}/infrastructure/listeners/update-media.listener.ts (92%) rename src/{teams => team}/infrastructure/persistence/models/enums.ts (84%) create mode 100644 src/team/infrastructure/persistence/models/index.ts rename src/{teams/infrastructure/persistence/models/teams.model.ts => team/infrastructure/persistence/models/team.model.ts} (100%) create mode 100644 src/team/infrastructure/persistence/repositories/index.ts rename src/{teams/infrastructure/persistence/repositories/teams.repository.ts => team/infrastructure/persistence/repositories/team.repository.ts} (96%) rename src/{teams => team}/infrastructure/workers/index.ts (100%) rename src/{teams => team}/infrastructure/workers/mail.processor.ts (92%) rename src/{teams/teams.module.ts => team/team.module.ts} (56%) delete mode 100644 src/teams/application/controller/index.ts delete mode 100644 src/teams/application/use-cases/base/delete-team.use-case.ts delete mode 100644 src/teams/application/use-cases/members/remove-team-member.use-case.ts delete mode 100644 src/teams/application/use-cases/members/update-team-member.use-case.ts delete mode 100644 src/teams/domain/entities/index.ts delete mode 100644 src/teams/domain/repository/index.ts delete mode 100644 src/teams/infrastructure/persistence/models/index.ts delete mode 100644 src/teams/infrastructure/persistence/repositories/index.ts diff --git a/infra/k6/package.json b/infra/k6/package.json index cd1384a6..9f4ae9f8 100644 --- a/infra/k6/package.json +++ b/infra/k6/package.json @@ -5,10 +5,10 @@ "scripts": { "test:all": "k6 run scenarios/stress-full.js", "test:auth": "k6 run scenarios/auth.js", - "test:teams": "k6 run scenarios/teams.js", - "test:teams-members": "k6 run scenarios/teams-members.js", - "test:teams-invitations": "k6 run scenarios/teams-invitations.js", - "test:teams-me": "k6 run scenarios/teams-me.js", + "test:team": "k6 run scenarios/team.js", + "test:team-members": "k6 run scenarios/team-members.js", + "test:team-invitations": "k6 run scenarios/team-invitations.js", + "test:team-me": "k6 run scenarios/team-me.js", "test:projects": "k6 run scenarios/projects.js", "test:users": "k6 run scenarios/users.js", "test:boards:all": "k6 run scenarios/boards.js && k6 run scenarios/boards-columns.js && k6 run scenarios/boards-views.js", diff --git a/infra/k6/scenarios/teams-invitations.js b/infra/k6/scenarios/team-invitations.js similarity index 100% rename from infra/k6/scenarios/teams-invitations.js rename to infra/k6/scenarios/team-invitations.js diff --git a/infra/k6/scenarios/teams-me.js b/infra/k6/scenarios/team-me.js similarity index 100% rename from infra/k6/scenarios/teams-me.js rename to infra/k6/scenarios/team-me.js diff --git a/infra/k6/scenarios/teams-members.js b/infra/k6/scenarios/team-members.js similarity index 100% rename from infra/k6/scenarios/teams-members.js rename to infra/k6/scenarios/team-members.js diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/team.js similarity index 100% rename from infra/k6/scenarios/teams.js rename to infra/k6/scenarios/team.js diff --git a/migrations/0007_add_team_members_roles.sql b/migrations/0007_add_team_members_roles.sql new file mode 100644 index 00000000..96620d9a --- /dev/null +++ b/migrations/0007_add_team_members_roles.sql @@ -0,0 +1,6 @@ +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DATA TYPE text; +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DEFAULT 'member'::text; +DROP TYPE "base"."team_role"; +CREATE TYPE "base"."team_role" AS ENUM('owner', 'admin', 'member', 'viewer'); +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DEFAULT 'member'::"base"."team_role"; +ALTER TABLE "base"."team_members" ALTER COLUMN "role" SET DATA TYPE "base"."team_role" USING "role"::"base"."team_role"; \ No newline at end of file diff --git a/migrations/meta/0007_snapshot.json b/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..31b6c99c --- /dev/null +++ b/migrations/meta/0007_snapshot.json @@ -0,0 +1,2390 @@ +{ + "id": "46748910-a0ec-4fd3-bae5-acefb5e6660c", + "prevId": "ff097bac-bc33-47f1-a242-773529e37d66", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "category": { + "name": "category", + "type": "state_category", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_transition_to": { + "name": "auto_transition_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_enter": { + "name": "notify_on_enter", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notify_on_exit": { + "name": "notify_on_exit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_states_position": { + "name": "idx_states_position", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_title": { + "name": "idx_states_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_created_at": { + "name": "idx_states_created_at", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_search": { + "name": "idx_states_search", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_unique_title": { + "name": "idx_states_unique_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"states\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_deleted_at": { + "name": "idx_states_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"states\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "states_area_id_areas_id_fk": { + "name": "states_area_id_areas_id_fk", + "tableFrom": "states", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "states_created_by_users_id_fk": { + "name": "states_created_by_users_id_fk", + "tableFrom": "states", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.issues": { + "name": "issues", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "priority", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "type": { + "name": "type", + "type": "issue_type", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_id": { + "name": "state_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "story_points": { + "name": "story_points", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_issue_area_state": { + "name": "idx_issue_area_state", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_assignee": { + "name": "idx_issue_assignee", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_parent": { + "name": "idx_issue_parent", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_priority": { + "name": "idx_issue_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_type": { + "name": "idx_issue_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_search": { + "name": "idx_issue_search", + "columns": [ + { + "expression": "to_tsvector('english', COALESCE(\"title\", '') || ' ' || COALESCE(\"description\", ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"issues\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issues_area_id_areas_id_fk": { + "name": "issues_area_id_areas_id_fk", + "tableFrom": "issues", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issues_state_id_states_id_fk": { + "name": "issues_state_id_states_id_fk", + "tableFrom": "issues", + "tableTo": "states", + "schemaTo": "base", + "columnsFrom": [ + "state_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_assignee_id_users_id_fk": { + "name": "issues_assignee_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_reporter_id_users_id_fk": { + "name": "issues_reporter_id_users_id_fk", + "tableFrom": "issues", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "schemaTo": "base", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "no_self_parent": { + "name": "no_self_parent", + "value": "\"base\".\"issues\".\"parent_id\" IS NULL OR \"base\".\"issues\".\"parent_id\" != \"base\".\"issues\".\"id\"" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + }, + "base.issue_type": { + "name": "issue_type", + "schema": "base", + "values": [ + "bug", + "task", + "epic" + ] + }, + "base.priority": { + "name": "priority", + "schema": "base", + "values": [ + "critical", + "low", + "medium", + "high" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index f760a0f5..ffa45f72 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1781645047048, "tag": "0006_add_issue", "breakpoints": false + }, + { + "idx": 7, + "version": "7", + "when": 1782216398515, + "tag": "0007_add_team_members_roles", + "breakpoints": false } ] } \ No newline at end of file diff --git a/package.json b/package.json index 276012fa..39e18962 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ "prepare": "husky", "k6:all": "pnpm --filter @project/performance-tests test:all", "k6:auth": "pnpm --filter @project/performance-tests test:auth", - "k6:teams": "pnpm --filter @project/performance-tests test:teams", + "k6:team": "pnpm --filter @project/performance-tests test:team", "k6:projects": "pnpm --filter @project/performance-tests test:projects", - "k6:teams-members": "pnpm --filter @project/performance-tests test:teams-members", - "k6:teams-invitations": "pnpm --filter @project/performance-tests test:teams-invitations", - "k6:teams-me": "pnpm --filter @project/performance-tests test:teams-me", + "k6:team-members": "pnpm --filter @project/performance-tests test:team-members", + "k6:team-invitations": "pnpm --filter @project/performance-tests test:team-invitations", + "k6:team-me": "pnpm --filter @project/performance-tests test:team-me", "k6:users": "pnpm --filter @project/performance-tests test:users", "k6:boards": "pnpm --filter @project/performance-tests test:boards:all", "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", @@ -37,6 +37,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1029.0", "@aws-sdk/s3-request-presigner": "^3.1029.0", + "@casl/ability": "^7.0.0", "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e10d5fa..6d2c3162 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1029.0 version: 3.1029.0 + '@casl/ability': + specifier: ^7.0.0 + version: 7.0.0 '@fastify/compress': specifier: ^8.3.1 version: 8.3.1 @@ -481,6 +484,9 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@casl/ability@7.0.0': + resolution: {integrity: sha512-QhwRflkTucpdS2uw1XScrzLWbgLYJGvPoq2Xm5OjeRci3dwtPixxnjUKJ04Ss1ivNS9tZQ8y4sjWeelWsrwo4g==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2220,6 +2226,18 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ucast/core@2.0.0': + resolution: {integrity: sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q==} + + '@ucast/js@4.0.1': + resolution: {integrity: sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ==} + + '@ucast/mongo2js@2.0.0': + resolution: {integrity: sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ==} + + '@ucast/mongo@3.0.0': + resolution: {integrity: sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg==} + '@vitest/coverage-v8@4.1.4': resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} peerDependencies: @@ -5281,6 +5299,10 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@casl/ability@7.0.0': + dependencies: + '@ucast/mongo2js': 2.0.0 + '@colors/colors@1.5.0': optional: true @@ -6952,6 +6974,22 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@ucast/core@2.0.0': {} + + '@ucast/js@4.0.1': + dependencies: + '@ucast/core': 2.0.0 + + '@ucast/mongo2js@2.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@ucast/js': 4.0.1 + '@ucast/mongo': 3.0.0 + + '@ucast/mongo@3.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: '@bcoe/v8-coverage': 1.0.2 diff --git a/src/app.module.ts b/src/app.module.ts index 15a2c7fd..e1052aa5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { CacheModule } from '@shared/adapters/cache/module'; import { ICacheService } from '@shared/adapters/cache/ports'; import { MailModule } from '@shared/adapters/mail'; +import { AuthorizationModule } from '@shared/authorization/authorization.module'; import { GlobalExceptionFilter } from '@shared/error'; import { ZodValidationInterceptor } from '@shared/interceptors'; import { ZodValidationPipe } from 'nestjs-zod'; @@ -22,7 +23,7 @@ import { AuthModule } from './auth/auth.module'; import { IssueModule } from './issue'; import { ProjectModule } from './project'; import * as schema from './shared/entities'; -import { TeamsModule } from './teams'; +import { TeamModule } from './team'; import { UserModule } from './user'; @Module({ @@ -53,11 +54,12 @@ import { UserModule } from './user'; MailModule, AuthModule, UserModule, - TeamsModule, + TeamModule, ProjectModule, AreaModule, IssueModule, MetricsModule, + AuthorizationModule, HealthModule.registerAsync({ inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index eb318923..c75e30a6 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,5 @@ import { ProjectModule } from '@core/project'; -import { TeamsModule } from '@core/teams'; +import { TeamModule } from '@core/team'; import { UserModule } from '@core/user'; import { BullModule } from '@nestjs/bullmq'; import { forwardRef, Module } from '@nestjs/common'; @@ -43,7 +43,7 @@ const WORKERS = [MailProcessor, UserProcessor]; }), BullModule.registerQueue({ name: AuthQueues.AUTH_MAIL }, { name: AuthQueues.AUTH_USER }), forwardRef(() => UserModule), - TeamsModule, + TeamModule, ProjectModule, ], controllers: CONTROLLERS, diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts index fb61c30c..5907863c 100644 --- a/src/auth/infrastructure/workers/user.processor.ts +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -2,7 +2,7 @@ import { AuthQueues } from '@core/auth/domain/enums'; import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; import { CreateProjectUseCase } from '@core/project/application/use-cases'; -import { CreateTeamUseCase } from '@core/teams/application/use-cases'; +import { CreateTeamUseCase } from '@core/team/application/use-cases'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; import slugify from 'slugify'; diff --git a/src/project/application/mappers/project.mapper.ts b/src/project/application/mappers/project.mapper.ts index df3466fc..e4aeaa21 100644 --- a/src/project/application/mappers/project.mapper.ts +++ b/src/project/application/mappers/project.mapper.ts @@ -1,5 +1,5 @@ import type { Project } from '@core/project/domain/entities'; -import type { RawMemberRow } from '@core/teams/domain/repository'; +import type { RawMemberRow } from '@core/team/domain/repository'; export class ProjectMapper { public static toDetailResponse(project: Project, member?: RawMemberRow | null, token?: string) { diff --git a/src/project/application/use-cases/member/add.use-case.ts b/src/project/application/use-cases/member/add.use-case.ts index f528e863..6f253b80 100644 --- a/src/project/application/use-cases/member/add.use-case.ts +++ b/src/project/application/use-cases/member/add.use-case.ts @@ -2,10 +2,10 @@ import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/erro import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { IMemberRepository } from '@core/project/domain/repository'; import { MAX_MEMBERS_PER_PROJECT } from '@core/project/infrastructure/constants'; -import { FindTeamMemberQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { FindTeamMemberQuery } from '../../../../team'; import { AddProjectMemberDto } from '../../dtos'; @Injectable() diff --git a/src/project/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts index 3a4d67b8..25c9cc8c 100644 --- a/src/project/application/use-cases/project/find-one.query.ts +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; import { IProjectRepository } from '@core/project/domain/repository'; -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/team'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; diff --git a/src/project/domain/policy/project-access.policy.ts b/src/project/domain/policy/project-access.policy.ts index d11a4234..88df8e8b 100644 --- a/src/project/domain/policy/project-access.policy.ts +++ b/src/project/domain/policy/project-access.policy.ts @@ -1,9 +1,8 @@ -import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/team'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; +import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY, isTeamRole } from '@shared/constants'; import { BaseException } from '@shared/error'; -import { isTeamRole } from '../../../shared/constants/roles.constant'; import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; import { IMemberRepository, IProjectRepository } from '../repository'; diff --git a/src/project/project.module.ts b/src/project/project.module.ts index 66b81881..c01f1544 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -1,4 +1,4 @@ -import { TeamsModule } from '@core/teams'; +import { TeamModule } from '@core/team'; import { UserModule } from '@core/user'; import { forwardRef, Module } from '@nestjs/common'; @@ -9,7 +9,7 @@ import { POLICIES } from './domain/policy'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ - imports: [UserModule, forwardRef(() => TeamsModule)], + imports: [UserModule, forwardRef(() => TeamModule)], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], exports: [...EXPORT_USE_CASES, ...POLICIES], diff --git a/src/shared/authorization/ability.factory.ts b/src/shared/authorization/ability.factory.ts new file mode 100644 index 00000000..8a498e69 --- /dev/null +++ b/src/shared/authorization/ability.factory.ts @@ -0,0 +1,65 @@ +import { createMongoAbility, RawRuleOf } from '@casl/ability'; +import { Injectable } from '@nestjs/common'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; + +import { RawMemberRow } from '../../team/domain/repository'; + +import { ROLE_PERMISSIONS_MAP } from './permissions'; +import { AppAbility } from './types/app-ability.type'; + +import type { ConditionValue } from './types/permission-rule.interface'; + +@Injectable() +export class AbilityFactory { + constructor() {} + + createForTeamMember(member: RawMemberRow) { + const role = member.role; + const permissions = ROLE_PERMISSIONS_MAP[role]; + const rolePriority = ROLE_PRIORITY[role]; + + const rules: RawRuleOf[] = permissions.map((permission) => { + const conditions = this.resolveConditions(permission.conditions, member.userId); + + if ( + role !== 'owner' && + (permission.subject === Subject.TEAM_MEMBER || permission.subject === Subject.ROLE) + ) { + return { + action: permission.action, + subject: permission.subject, + conditions: { + ...conditions, + priority: { $lt: rolePriority }, + }, + }; + } + + return { + action: permission.action, + subject: permission.subject, + conditions, + }; + }); + + return createMongoAbility(rules); + } + + private resolveConditions( + conditions: Record | undefined, + userId: string, + ) { + if (!conditions) { + return {}; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(conditions)) { + result[key] = value === '$currentUser' ? userId : value; + } + + return result; + } +} diff --git a/src/shared/authorization/authorization.module.ts b/src/shared/authorization/authorization.module.ts new file mode 100644 index 00000000..5afe655f --- /dev/null +++ b/src/shared/authorization/authorization.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { AbilityFactory } from './ability.factory'; + +@Global() +@Module({ + providers: [AbilityFactory], + exports: [AbilityFactory], +}) +export class AuthorizationModule {} diff --git a/src/shared/authorization/authorization.spec.ts b/src/shared/authorization/authorization.spec.ts new file mode 100644 index 00000000..0f10cd8e --- /dev/null +++ b/src/shared/authorization/authorization.spec.ts @@ -0,0 +1,525 @@ +import { subject } from '@casl/ability'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { describe, expect } from 'vitest'; + +import { AbilityFactory } from './ability.factory'; + +import type { RawMemberRow } from '@core/team/domain/repository'; +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; + +describe('AuthorizationService - Permissions Matrix', () => { + const findTeamMemberMock = { + execute: vi.fn(), + }; + + const factory = new AbilityFactory(); + + const createAbilityFor = (role: TeamRole) => { + const mockMember: RawMemberRow = { + userId: '1', + role, + status: 'active', + joinedAt: null, + firstName: null, + lastName: null, + middleName: null, + avatarUrl: null, + }; + vi.spyOn(findTeamMemberMock, 'execute').mockResolvedValue(mockMember); + return factory.createForTeamMember(mockMember); + }; + + describe('Role: Owner', () => { + it('should MANAGE TEAM', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeTruthy(); + }); + + it('should MANAGE TEAM_MEMBER with any priority', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + + it('should MANAGE INVITE', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeTruthy(); + }); + + it('should MANAGE PROJECT', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeTruthy(); + }); + + it('should MANAGE TASK', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeTruthy(); + }); + + it('should MANAGE ROLE', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeTruthy(); + expect( + ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeTruthy(); + }); + + it('should MANAGE BILLING', () => { + const ability = createAbilityFor('owner'); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeTruthy(); + }); + }); + + describe('Role: Admin', () => { + it('should READ and UPDATE TEAM, but not DELETE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeTruthy(); + + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + }); + + it('should MANAGE TEAM_MEMBER with lower priority only (not higher or the same role)', () => { + const ability = createAbilityFor('admin'); + + // admin can manage members with lower role priority + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + + // cannot manage members with the same or higher role priority + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + }); + + it('should MANAGE INVITE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeTruthy(); + }); + + it('should MANAGE PROJECT', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeTruthy(); + }); + + it('should MANAGE TASK', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeTruthy(); + }); + + it('should MANAGE ROLE', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeTruthy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeTruthy(); + }); + + it('should NOT have access to CREATE/UPDATE-TO ROLE with the same or higher priority', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + + expect( + ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + expect( + ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + }); + + it('should have only READ access to BILLING', () => { + const ability = createAbilityFor('admin'); + + expect(ability.can(Action.READ, Subject.BILLING)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + }); + + describe('Role: Member', () => { + it('should MANAGE own TASK only', () => { + const ability = createAbilityFor('member'); + + expect( + ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' })), + ).toBeTruthy(); + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '2' }))).toBeFalsy(); + }); + + it('should NOT have access to TEAM', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + }); + + it('should NOT have access to TEAM_MEMBER', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect( + ability.can(Action.MANAGE, subject(Subject.TEAM_MEMBER, { priority: 0 })), + ).toBeFalsy(); + }); + + it('should NOT have access to INVITE', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeFalsy(); + }); + + it('should NOT have access to ROLE', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeFalsy(); + }); + + it('should have only READ access to PROJECT', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeFalsy(); + }); + + it('should NOT have access to BILLING', () => { + const ability = createAbilityFor('member'); + + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.READ, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + }); + + describe('Role: Viewer', () => { + it('should have only READ access to TEAM', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TEAM)).toBeTruthy(); + + expect(ability.can(Action.CREATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM)).toBeFalsy(); + }); + + it('should have only READ access to PROJECT', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.PROJECT)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.PROJECT)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.PROJECT)).toBeFalsy(); + }); + + it('should have NOT access to ROLE', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.ROLE)).toBeFalsy(); + }); + + it('should have NOT access to INVITE', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.INVITE)).toBeFalsy(); + }); + + it('should have NOT access to BILLING', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.BILLING)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.BILLING)).toBeFalsy(); + }); + + it('should have only READ access to TEAM_MEMBER', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TEAM_MEMBER)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TEAM_MEMBER)).toBeFalsy(); + }); + + it('should have only READ access to TASK (even own)', () => { + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.READ, Subject.TASK)).toBeTruthy(); + + expect(ability.can(Action.MANAGE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.CREATE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.UPDATE, Subject.TASK)).toBeFalsy(); + expect(ability.can(Action.DELETE, Subject.TASK)).toBeFalsy(); + + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.UPDATE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.DELETE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + expect(ability.can(Action.CREATE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + }); + }); + + describe('Role Priority - Admin cannot manage higher or equal priority members', () => { + it('should NOT allow admin to manage owner (priority 3 > 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeFalsy(); + }); + + it('should NOT allow admin to manage another admin (priority 2 === 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeFalsy(); + }); + + it('should allow admin to manage member (priority 1 < 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + }); + + it('should allow admin to manage viewer (priority 0 < 2)', () => { + const ability = createAbilityFor('admin'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + }); + + describe('Role Priority - Owner can manage all members', () => { + it('should allow owner to manage owner (priority 3 = 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['owner'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage admin (priority 2 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['admin'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage member (priority 1 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['member'] }), + ), + ).toBeTruthy(); + }); + + it('should allow owner to manage viewer (priority 0 < 3)', () => { + const ability = createAbilityFor('owner'); + + expect( + ability.can( + Action.MANAGE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY['viewer'] }), + ), + ).toBeTruthy(); + }); + }); + + describe('Fallback to viewer role', () => { + it('should grant no permissions when member is not found (defaults to viewer)', () => { + vi.spyOn(findTeamMemberMock, 'execute').mockResolvedValue(null); + const ability = createAbilityFor('viewer'); + + expect(ability.can(Action.MANAGE, Subject.TEAM)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.TEAM_MEMBER)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.INVITE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, Subject.ROLE)).toBeFalsy(); + expect(ability.can(Action.MANAGE, subject(Subject.TASK, { ownerId: '1' }))).toBeFalsy(); + }); + }); +}); diff --git a/src/shared/authorization/permissions/admin.permissions.ts b/src/shared/authorization/permissions/admin.permissions.ts new file mode 100644 index 00000000..737c2d7f --- /dev/null +++ b/src/shared/authorization/permissions/admin.permissions.ts @@ -0,0 +1,50 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const ADMIN_PERMISSIONS: PermissionRule[] = [ + { + action: Action.READ, + subject: Subject.TEAM, + }, + { + action: Action.UPDATE, + subject: Subject.TEAM, + }, + + //team members + { + action: Action.MANAGE, + subject: Subject.TEAM_MEMBER, + }, + + //project + { + action: Action.MANAGE, + subject: Subject.PROJECT, + }, + + //task + { + action: Action.MANAGE, + subject: Subject.TASK, + }, + + //invites + { + action: Action.MANAGE, + subject: Subject.INVITE, + }, + + //roles + { + action: Action.MANAGE, + subject: Subject.ROLE, + }, + + //billing + { + action: Action.READ, + subject: Subject.BILLING, + }, +]; diff --git a/src/shared/authorization/permissions/index.ts b/src/shared/authorization/permissions/index.ts new file mode 100644 index 00000000..24feaa3f --- /dev/null +++ b/src/shared/authorization/permissions/index.ts @@ -0,0 +1,5 @@ +export { OWNER_PERMISSIONS } from './owner.permissions'; +export { VIEWER_PERMISSIONS } from './viewer.permissions'; +export { ADMIN_PERMISSIONS } from './admin.permissions'; +export { MEMBER_PERMISSIONS } from './member.permissions'; +export { ROLE_PERMISSIONS_MAP } from './permissions-map'; diff --git a/src/shared/authorization/permissions/member.permissions.ts b/src/shared/authorization/permissions/member.permissions.ts new file mode 100644 index 00000000..d793b92e --- /dev/null +++ b/src/shared/authorization/permissions/member.permissions.ts @@ -0,0 +1,19 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const MEMBER_PERMISSIONS: PermissionRule[] = [ + { + action: Action.MANAGE, + subject: Subject.TASK, + conditions: { + ownerId: '$currentUser', + }, + }, + + //project + { + action: Action.READ, + subject: Subject.PROJECT, + }, +]; diff --git a/src/shared/authorization/permissions/owner.permissions.ts b/src/shared/authorization/permissions/owner.permissions.ts new file mode 100644 index 00000000..8039d9ef --- /dev/null +++ b/src/shared/authorization/permissions/owner.permissions.ts @@ -0,0 +1,35 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; + +export const OWNER_PERMISSIONS: PermissionRule[] = [ + { + action: Action.MANAGE, + subject: Subject.TASK, + }, + { + action: Action.MANAGE, + subject: Subject.PROJECT, + }, + { + action: Action.MANAGE, + subject: Subject.TEAM, + }, + { + action: Action.MANAGE, + subject: Subject.INVITE, + }, + { + action: Action.MANAGE, + subject: Subject.TEAM_MEMBER, + }, + { + action: Action.MANAGE, + subject: Subject.ROLE, + }, + { + action: Action.MANAGE, + subject: Subject.BILLING, + }, +]; diff --git a/src/shared/authorization/permissions/permissions-map.ts b/src/shared/authorization/permissions/permissions-map.ts new file mode 100644 index 00000000..3e8f74dc --- /dev/null +++ b/src/shared/authorization/permissions/permissions-map.ts @@ -0,0 +1,14 @@ +import { ADMIN_PERMISSIONS } from '@shared/authorization/permissions/admin.permissions'; +import { MEMBER_PERMISSIONS } from '@shared/authorization/permissions/member.permissions'; +import { OWNER_PERMISSIONS } from '@shared/authorization/permissions/owner.permissions'; +import { VIEWER_PERMISSIONS } from '@shared/authorization/permissions/viewer.permissions'; + +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; +import type { PermissionRule } from '@shared/authorization/types/permission-rule.interface'; + +export const ROLE_PERMISSIONS_MAP: Record = { + owner: OWNER_PERMISSIONS, + admin: ADMIN_PERMISSIONS, + member: MEMBER_PERMISSIONS, + viewer: VIEWER_PERMISSIONS, +}; diff --git a/src/shared/authorization/permissions/viewer.permissions.ts b/src/shared/authorization/permissions/viewer.permissions.ts new file mode 100644 index 00000000..53d9f9f9 --- /dev/null +++ b/src/shared/authorization/permissions/viewer.permissions.ts @@ -0,0 +1,29 @@ +import { Action } from '@shared/authorization/types/action.enum'; +import { type PermissionRule } from '@shared/authorization/types/permission-rule.interface'; +import { Subject } from '@shared/authorization/types/subject.enum'; + +export const VIEWER_PERMISSIONS: PermissionRule[] = [ + // team + { + action: Action.READ, + subject: Subject.TEAM, + }, + + //projects + { + action: Action.READ, + subject: Subject.PROJECT, + }, + + //tasks + { + action: Action.READ, + subject: Subject.TASK, + }, + + //team members + { + action: Action.READ, + subject: Subject.TEAM_MEMBER, + }, +]; diff --git a/src/shared/authorization/types/action.enum.ts b/src/shared/authorization/types/action.enum.ts new file mode 100644 index 00000000..a1be2795 --- /dev/null +++ b/src/shared/authorization/types/action.enum.ts @@ -0,0 +1,7 @@ +export enum Action { + MANAGE = 'manage', + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/src/shared/authorization/types/app-ability.type.ts b/src/shared/authorization/types/app-ability.type.ts new file mode 100644 index 00000000..877c2344 --- /dev/null +++ b/src/shared/authorization/types/app-ability.type.ts @@ -0,0 +1,5 @@ +import type { ForcedSubject, MongoAbility } from '@casl/ability'; +import type { Action } from '@shared/authorization/types/action.enum'; +import type { Subject } from '@shared/authorization/types/subject.enum'; + +export type AppAbility = MongoAbility<[Action, Subject | ForcedSubject]>; diff --git a/src/shared/authorization/types/permission-rule.interface.ts b/src/shared/authorization/types/permission-rule.interface.ts new file mode 100644 index 00000000..c91d9c4c --- /dev/null +++ b/src/shared/authorization/types/permission-rule.interface.ts @@ -0,0 +1,10 @@ +import { type Action } from './action.enum'; +import { type Subject } from './subject.enum'; + +export interface PermissionRule { + subject: Subject; + action: Action; + conditions?: Record; +} + +export type ConditionValue = '$currentUser'; diff --git a/src/shared/authorization/types/subject.enum.ts b/src/shared/authorization/types/subject.enum.ts new file mode 100644 index 00000000..6db3eb17 --- /dev/null +++ b/src/shared/authorization/types/subject.enum.ts @@ -0,0 +1,10 @@ +export enum Subject { + TEAM = 'Team', + TEAM_MEMBER = 'TeamMember', + ROLE = 'Role', + PROJECT = 'Project', + TASK = 'Task', + BILLING = 'Billing', + INVITE = 'Invite', + STATUS = 'Status', +} diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts index b50e0b4f..5069be22 100644 --- a/src/shared/constants/roles.constant.ts +++ b/src/shared/constants/roles.constant.ts @@ -1,11 +1,10 @@ -export const TEAM_ROLES = ['owner', 'admin', 'moderator', 'lead', 'member', 'viewer'] as const; -export type TeamRole = (typeof TEAM_ROLES)[number]; +import type { TeamRole } from '@core/team/infrastructure/persistence/models'; + +export const TEAM_ROLES = ['owner', 'admin', 'member', 'viewer'] as const; export const ROLE_PRIORITY: Record = { owner: 4, admin: 3, - lead: 2, - moderator: 2, member: 1, viewer: 0, }; diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index c385bea1..d46e2199 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -1,7 +1,7 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; -export * from '../../teams/infrastructure/persistence/models'; +export * from '../../team/infrastructure/persistence/models'; export * from '../../project/infrastructure/persistence/models'; export * from '../../area/infrastructure/persistence/models'; export * from '../../issue/infrastructure/persistence/models'; diff --git a/src/team/application/controllers/index.ts b/src/team/application/controllers/index.ts new file mode 100644 index 00000000..ca791609 --- /dev/null +++ b/src/team/application/controllers/index.ts @@ -0,0 +1,4 @@ +export { MeController } from './me/controller'; +export { TeamController } from './team/controller'; +export { TeamMembersController } from './members/controller'; +export { TeamInvitationsController } from './invitations/controller'; diff --git a/src/teams/application/controller/invitations/controller.ts b/src/team/application/controllers/invitations/controller.ts similarity index 92% rename from src/teams/application/controller/invitations/controller.ts rename to src/team/application/controllers/invitations/controller.ts index 5cedc7e7..82902af6 100644 --- a/src/teams/application/controller/invitations/controller.ts +++ b/src/team/application/controllers/invitations/controller.ts @@ -1,8 +1,8 @@ +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; import { AcceptInviteSwagger, @@ -16,8 +16,8 @@ import { import type { JwtPayload } from '@shared/types'; @ApiBaseController('teams/:teamId/invitations', 'Teams Invitations', true) -export class TeamsInvitationsController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamInvitationsController { + constructor(private readonly facade: TeamFacade) {} @Get() @GetTeamInvitationsSwagger() @@ -69,6 +69,6 @@ export class TeamsInvitationsController { @Param('code') code: string, @GetUser() user: JwtPayload, ) { - return this.facade.declineInvitation(teamId, code, user.sub, user.email); + return this.facade.declineInvitation(teamId, code, user.sub); } } diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/team/application/controllers/invitations/swagger.ts similarity index 100% rename from src/teams/application/controller/invitations/swagger.ts rename to src/team/application/controllers/invitations/swagger.ts diff --git a/src/teams/application/controller/me/controller.ts b/src/team/application/controllers/me/controller.ts similarity index 83% rename from src/teams/application/controller/me/controller.ts rename to src/team/application/controllers/me/controller.ts index 877808fd..15660af0 100644 --- a/src/teams/application/controller/me/controller.ts +++ b/src/team/application/controllers/me/controller.ts @@ -1,15 +1,14 @@ +import { TeamFacade } from '@core/team/application/team.facade'; import { Get } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; -import { TeamsFacade } from '../../team.facade'; - import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; import type { JwtPayload } from '@shared/types'; @ApiBaseController('users/me', 'Account Teams', true) export class MeController { - constructor(private readonly facade: TeamsFacade) {} + constructor(private readonly facade: TeamFacade) {} @Get('teams') @FindTeamsSwagger() diff --git a/src/teams/application/controller/me/swagger.ts b/src/team/application/controllers/me/swagger.ts similarity index 94% rename from src/teams/application/controller/me/swagger.ts rename to src/team/application/controllers/me/swagger.ts index fe316ad4..f540566e 100644 --- a/src/teams/application/controller/me/swagger.ts +++ b/src/team/application/controllers/me/swagger.ts @@ -1,10 +1,9 @@ +import { UserInvitesResponse, UserTeamsResponse } from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiUnauthorized } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; -import { UserTeamsResponse, UserInvitesResponse } from '../../dtos'; - export const FindTeamsSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/teams/application/controller/members/controller.ts b/src/team/application/controllers/members/controller.ts similarity index 83% rename from src/teams/application/controller/members/controller.ts rename to src/team/application/controllers/members/controller.ts index a558bd8a..42b75c30 100644 --- a/src/teams/application/controller/members/controller.ts +++ b/src/team/application/controllers/members/controller.ts @@ -1,14 +1,13 @@ +import { UpdateMemberDto } from '@core/team/application/dtos'; +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { UpdateMemberDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; - import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; @ApiBaseController('teams/:teamId', 'Teams Members', true) -export class TeamsMembersController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamMembersController { + constructor(private readonly facade: TeamFacade) {} @Get('members') @GetMembersSwagger() diff --git a/src/teams/application/controller/members/swagger.ts b/src/team/application/controllers/members/swagger.ts similarity index 99% rename from src/teams/application/controller/members/swagger.ts rename to src/team/application/controllers/members/swagger.ts index 0fda3d29..c1a90760 100644 --- a/src/teams/application/controller/members/swagger.ts +++ b/src/team/application/controllers/members/swagger.ts @@ -1,16 +1,15 @@ +import { + TeamMembersResponse, + UpdateMemberDto, + UserInvitesResponse, + UserTeamsResponse, +} from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { - UpdateMemberDto, - TeamMembersResponse, - UserTeamsResponse, - UserInvitesResponse, -} from '../../dtos'; - export const FindTeamsSwagger = () => applyDecorators( ApiOperation({ diff --git a/src/teams/application/controller/teams/controller.ts b/src/team/application/controllers/team/controller.ts similarity index 82% rename from src/teams/application/controller/teams/controller.ts rename to src/team/application/controllers/team/controller.ts index b3e872d4..2011b261 100644 --- a/src/teams/application/controller/teams/controller.ts +++ b/src/team/application/controllers/team/controller.ts @@ -1,9 +1,8 @@ +import { CreateTeamDto, UpdateTeamDto } from '@core/team/application/dtos'; +import { TeamFacade } from '@core/team/application/team.facade'; import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; -import { TeamsFacade } from '../../team.facade'; - import { CreateTeamSwagger, FindOneTeamSwagger, @@ -12,8 +11,8 @@ import { } from './swagger'; @ApiBaseController('teams', 'Teams', true) -export class TeamsController { - constructor(private readonly facade: TeamsFacade) {} +export class TeamController { + constructor(private readonly facade: TeamFacade) {} @Post() @CreateTeamSwagger() diff --git a/src/teams/application/controller/teams/swagger.ts b/src/team/application/controllers/team/swagger.ts similarity index 94% rename from src/teams/application/controller/teams/swagger.ts rename to src/team/application/controllers/team/swagger.ts index ea1dec4a..a6e14629 100644 --- a/src/teams/application/controller/teams/swagger.ts +++ b/src/team/application/controllers/team/swagger.ts @@ -1,12 +1,15 @@ -import { CreateTeamResponse } from '@core/teams/application/dtos/team.dto'; +import { + CreateTeamDto, + CreateTeamResponse, + TeamResponse, + UpdateTeamDto, +} from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; -import { CreateTeamDto, UpdateTeamDto, TeamResponse } from '../../dtos'; - export const CreateTeamSwagger = () => applyDecorators( ApiOperation({ summary: 'Создать новую команду' }), diff --git a/src/teams/application/dtos/index.ts b/src/team/application/dtos/index.ts similarity index 100% rename from src/teams/application/dtos/index.ts rename to src/team/application/dtos/index.ts diff --git a/src/teams/application/dtos/invitation.dto.ts b/src/team/application/dtos/invitation.dto.ts similarity index 100% rename from src/teams/application/dtos/invitation.dto.ts rename to src/team/application/dtos/invitation.dto.ts diff --git a/src/teams/application/dtos/member.dto.ts b/src/team/application/dtos/member.dto.ts similarity index 97% rename from src/teams/application/dtos/member.dto.ts rename to src/team/application/dtos/member.dto.ts index 9c2e6a0d..8b324f0f 100644 --- a/src/teams/application/dtos/member.dto.ts +++ b/src/team/application/dtos/member.dto.ts @@ -1,8 +1,9 @@ -import { roleEnum } from '@core/teams/infrastructure/persistence/models'; import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; +import { roleEnum } from '../../infrastructure/persistence/models'; + export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), role: z diff --git a/src/teams/application/dtos/team.dto.ts b/src/team/application/dtos/team.dto.ts similarity index 100% rename from src/teams/application/dtos/team.dto.ts rename to src/team/application/dtos/team.dto.ts diff --git a/src/teams/application/mappers/index.ts b/src/team/application/mappers/index.ts similarity index 100% rename from src/teams/application/mappers/index.ts rename to src/team/application/mappers/index.ts diff --git a/src/teams/application/mappers/member.mapper.ts b/src/team/application/mappers/member.mapper.ts similarity index 100% rename from src/teams/application/mappers/member.mapper.ts rename to src/team/application/mappers/member.mapper.ts diff --git a/src/teams/application/team.facade.ts b/src/team/application/team.facade.ts similarity index 93% rename from src/teams/application/team.facade.ts rename to src/team/application/team.facade.ts index c979f27c..c86cb18e 100644 --- a/src/teams/application/team.facade.ts +++ b/src/team/application/team.facade.ts @@ -10,7 +10,7 @@ import { import * as UC from './use-cases'; @Injectable() -export class TeamsFacade { +export class TeamFacade { constructor( private readonly findTeamQ: UC.FindTeamQuery, private readonly getInvitationQ: UC.GetInvitationQuery, @@ -26,7 +26,7 @@ export class TeamsFacade { private readonly sendInviteUc: UC.SendInvitationUseCase, private readonly acceptInviteUc: UC.AcceptInvitationUseCase, private readonly updateInvitationUc: UC.UpdateInvitationUseCase, - private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + private readonly deleteInvitationUc: UC.DeleteInvitationUseCase, private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, @@ -63,8 +63,8 @@ export class TeamsFacade { public acceptInvite = (code: string, userId: string, email: string) => this.acceptInviteUc.execute(code, userId, email); - public declineInvitation = (teamId: string, code: string, userId: string, userEmail: string) => - this.declineInvitationUc.execute(teamId, code, userId, userEmail); + public declineInvitation = (teamId: string, code: string, userId: string) => + this.deleteInvitationUc.execute(teamId, code, userId); public updateInvitation = ( teamId: string, diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/team/application/use-cases/base/create-team.use-case.ts similarity index 78% rename from src/teams/application/use-cases/base/create-team.use-case.ts rename to src/team/application/use-cases/base/create-team.use-case.ts index dec55f50..48bf2e84 100644 --- a/src/teams/application/use-cases/base/create-team.use-case.ts +++ b/src/team/application/use-cases/base/create-team.use-case.ts @@ -1,19 +1,19 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; import { CreateTeamDto } from '../../dtos'; @Injectable() export class CreateTeamUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, ) {} async execute(userId: string, dto: CreateTeamDto) { try { - const result = await this.teamsRepo.create(userId, dto); + const result = await this.teamRepo.create(userId, dto); return { ...result, diff --git a/src/team/application/use-cases/base/delete-team.use-case.ts b/src/team/application/use-cases/base/delete-team.use-case.ts new file mode 100644 index 00000000..44d95b10 --- /dev/null +++ b/src/team/application/use-cases/base/delete-team.use-case.ts @@ -0,0 +1,66 @@ +import { ITeamRepository, RawMemberRow } from '@core/team/domain/repository'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteTeamUseCase { + constructor( + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, + private readonly abilityFactory: AbilityFactory, + ) {} + + async execute(teamId: string, userId: string) { + const member = await this.teamRepo.findMember(teamId, userId); + + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: 'Команда не найдена или у вас нет к ней доступа', + }, + HttpStatus.FORBIDDEN, + ); + } + this.validateAccess(member); + + try { + const result = await this.teamRepo.remove(teamId, userId); + + return { + success: result, + message: 'Команда успешно удалена', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.DELETE, Subject.TEAM); + + if (!isAllow) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав', + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/teams/application/use-cases/base/find-team.query.ts b/src/team/application/use-cases/base/find-team.query.ts similarity index 62% rename from src/teams/application/use-cases/base/find-team.query.ts rename to src/team/application/use-cases/base/find-team.query.ts index 8b025d7f..7de46a2b 100644 --- a/src/teams/application/use-cases/base/find-team.query.ts +++ b/src/team/application/use-cases/base/find-team.query.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamRepository } from '../../../domain/repository'; @Injectable() export class FindTeamQuery { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, ) {} async execute(teamId: string) { diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/team/application/use-cases/base/get-my-teams.use-case.ts similarity index 70% rename from src/teams/application/use-cases/base/get-my-teams.use-case.ts rename to src/team/application/use-cases/base/get-my-teams.use-case.ts index 96d1b498..6c88f1d7 100644 --- a/src/teams/application/use-cases/base/get-my-teams.use-case.ts +++ b/src/team/application/use-cases/base/get-my-teams.use-case.ts @@ -1,18 +1,19 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TeamMemberMapper } from '../../../application/mappers'; +import { ITeamRepository } from '../../../domain/repository'; + @Injectable() export class GetMyTeamsUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly cfg: ConfigService, ) {} async execute(userId: string) { - const teams = await this.teamsRepo.findByUser(userId); + const teams = await this.teamRepo.findByUser(userId); const cdn = this.getCdnBaseUrl(); return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/team/application/use-cases/base/update-team.use-case.ts similarity index 56% rename from src/teams/application/use-cases/base/update-team.use-case.ts rename to src/team/application/use-cases/base/update-team.use-case.ts index 53c78e0a..ce3c7d1a 100644 --- a/src/teams/application/use-cases/base/update-team.use-case.ts +++ b/src/team/application/use-cases/base/update-team.use-case.ts @@ -1,45 +1,37 @@ import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateTeamDto } from '../../dtos'; @Injectable() export class UpdateTeamUseCase { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string, dto: UpdateTeamDto) { - const team = await this.teamsRepo.findById(teamId); + const member = await this.teamRepo.findMember(teamId, userId); - if (!team?.id) { + if (!member) { throw new BaseException( { - code: 'TEAM_NOT_FOUND', - message: `Команда ${teamId} не найдена`, - }, - 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 }], + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, }, HttpStatus.FORBIDDEN, ); } + this.validateAccess(member); + try { - const result = await this.teamsRepo.update(team.id, dto); + const result = await this.teamRepo.update(teamId, dto); return { ...result, @@ -59,4 +51,19 @@ export class UpdateTeamUseCase { ); } } + + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.UPDATE, Subject.TEAM); + + if (!isAllow) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + }, + HttpStatus.FORBIDDEN, + ); + } + } } diff --git a/src/teams/application/use-cases/index.ts b/src/team/application/use-cases/index.ts similarity index 93% rename from src/teams/application/use-cases/index.ts rename to src/team/application/use-cases/index.ts index ac91205d..0cf70ae1 100644 --- a/src/teams/application/use-cases/index.ts +++ b/src/team/application/use-cases/index.ts @@ -4,7 +4,7 @@ import { FindTeamQuery } from './base/find-team.query'; import { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; import { UpdateTeamUseCase } from './base/update-team.use-case'; import { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; -import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; +import { DeleteInvitationUseCase } from './invitions/delete-invitation.use-case'; import { GetInvitationQuery } from './invitions/get-invitation.query'; import { GetInvitationsQuery } from './invitions/get-invitations.query'; import { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; @@ -34,7 +34,7 @@ export const TeamUseCases = [ UpdateTeamUseCase, UpdateTeamMemberUseCase, UpdateInvitationUseCase, - DeclineInvitationUseCase, + DeleteInvitationUseCase, ]; export const TEAM_EXTERNAL_QUERIES = [FindTeamQuery, FindTeamMemberQuery]; @@ -56,4 +56,4 @@ export { SendInvitationUseCase } from './invitions/send-invitation.use-case'; export { UpdateTeamUseCase } from './base/update-team.use-case'; export { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; export { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; -export { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; +export { DeleteInvitationUseCase } from './invitions/delete-invitation.use-case'; diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/team/application/use-cases/invitions/accept-invitation.use-case.ts similarity index 92% rename from src/teams/application/use-cases/invitions/accept-invitation.use-case.ts rename to src/team/application/use-cases/invitions/accept-invitation.use-case.ts index 94e9a308..eff6ac08 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/accept-invitation.use-case.ts @@ -1,9 +1,10 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() @@ -13,7 +14,7 @@ export class AcceptInvitationUseCase { private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} @@ -40,7 +41,7 @@ export class AcceptInvitationUseCase { ); } - const member = await this.teamsRepo.findMember(invite.teamId, userId); + const member = await this.teamRepo.findMember(invite.teamId, userId); if (member) { if (member.status === 'banned') { throw new BaseException( @@ -57,7 +58,7 @@ export class AcceptInvitationUseCase { } } - await this.teamsRepo.addMember({ + await this.teamRepo.addMember({ teamId: invite.teamId, userId, role: invite.role, diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/team/application/use-cases/invitions/delete-invitation.use-case.ts similarity index 57% rename from src/teams/application/use-cases/invitions/decline-invitation.use-case.ts rename to src/team/application/use-cases/invitions/delete-invitation.use-case.ts index 4ded7102..36aec939 100644 --- a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/delete-invitation.use-case.ts @@ -1,31 +1,45 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() -export class DeclineInvitationUseCase { +export class DeleteInvitationUseCase { 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, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + private readonly abilityFactory: AbilityFactory, ) {} - async execute(teamId: string, code: string, userId: string, userEmail: string) { - const team = await this.getTeamOrThrow(teamId); + async execute(teamId: string, code: string, userId: string) { const invite = await this.getInviteOrThrow(code); + this.validateInviteOwnership(invite, teamId); - this.validateInviteOwnership(invite, team.id); + const member = await this.teamRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } - await this.validateAccess(team.id, userId, userEmail, invite.email); + this.validateAccess(member); - await this.cleanupInvite(code, team.id, invite.email); + await this.cleanupInvite(code, teamId, invite.email); return { success: true, @@ -33,39 +47,19 @@ export class DeclineInvitationUseCase { }; } - private async validateAccess( - teamId: string, - userId: string, - currentUserEmail: string, - inviteEmail: string, - ) { - if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { - return; - } - - const member = await this.teamsRepo.findMember(teamId, userId); - if (member && (member.role === 'owner' || member.role === 'admin')) { - return; - } - - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас нет прав для отмены этого приглашения', - }, - HttpStatus.FORBIDDEN, - ); - } + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.DELETE, Subject.INVITE); - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { + if (!isAllow) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для удаления приглашений', + }, + HttpStatus.FORBIDDEN, ); } - return team; } private async getInviteOrThrow(code: string) { diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/team/application/use-cases/invitions/get-invitation.query.ts similarity index 89% rename from src/teams/application/use-cases/invitions/get-invitation.query.ts rename to src/team/application/use-cases/invitions/get-invitation.query.ts index 43a8c528..ec61cafb 100644 --- a/src/teams/application/use-cases/invitions/get-invitation.query.ts +++ b/src/team/application/use-cases/invitions/get-invitation.query.ts @@ -1,9 +1,10 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { ITeamRepository } from '../../../domain/repository'; + import type { TeamInvite } from '../../dtos/invitation.dto'; @Injectable() @@ -11,7 +12,7 @@ export class GetInvitationQuery { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} @@ -26,7 +27,7 @@ export class GetInvitationQuery { } private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, @@ -66,7 +67,7 @@ export class GetInvitationQuery { return; } - const member = await this.teamsRepo.findMember(teamId, userId); + const member = await this.teamRepo.findMember(teamId, userId); if (member && (member.role === 'owner' || member.role === 'admin')) { return; } diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/team/application/use-cases/invitions/get-invitations.query.ts similarity index 70% rename from src/teams/application/use-cases/invitions/get-invitations.query.ts rename to src/team/application/use-cases/invitions/get-invitations.query.ts index a00a7689..f226041a 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/team/application/use-cases/invitions/get-invitations.query.ts @@ -1,25 +1,41 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + @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, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, userId: string) { - const team = await this.getTeamOrThrow(teamId); - await this.ensureAdminPermissions(team.id, userId); + const member = await this.teamRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } - const teamKey = this.TEAM_INVITES_KEY(team.id); + this.validateAccess(member); + + const teamKey = this.TEAM_INVITES_KEY(teamId); const codes = await this.cacheService.getCollection(teamKey); + if (!codes.length) { return { // TODO: реализовать полноценную пагинацию для инвайтов команды. @@ -67,27 +83,18 @@ export class GetInvitationsQuery { total: active.length, totalPages: active.length ? 1 : 0, page: 1, - limit: active.length, + limit: 10, hasPrevPage: false, hasNextPage: false, }, }; } - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { - throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, - ); - } - return team; - } + private validateAccess(member: RawMemberRow) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can(Action.READ, Subject.INVITE); - private async ensureAdminPermissions(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + if (!isAllow) { throw new BaseException( { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, HttpStatus.FORBIDDEN, diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts similarity index 97% rename from src/teams/application/use-cases/invitions/get-my-invites.use-case.ts rename to src/team/application/use-cases/invitions/get-my-invites.use-case.ts index 6c176699..92e431c0 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts @@ -1,8 +1,9 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; import { Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { TeamMemberMapper } from '../../../application/mappers'; + @Injectable() export class GetMyInvitesUseCase { constructor( diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/team/application/use-cases/invitions/send-invitation.use-case.ts similarity index 71% rename from src/teams/application/use-cases/invitions/send-invitation.use-case.ts rename to src/team/application/use-cases/invitions/send-invitation.use-case.ts index 875f3191..5800646b 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/send-invitation.use-case.ts @@ -1,17 +1,21 @@ -import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; -import { TeamInvitationEvent } from '@core/teams/domain/events'; -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { subject } from '@casl/ability'; import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; import { ImageHelper } from '@shared/utils'; import { Queue } from 'bullmq'; import { generateSecret } from 'otplib'; +import { TeamMailJobs, TeamQueues } from '../../../domain/enums'; +import { TeamInvitationEvent } from '../../../domain/events'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { InviteMemberDto, type TeamInvite } from '../../dtos'; import type { TeamRole } from '@shared/entities'; @@ -24,20 +28,21 @@ export class SendInvitationUseCase { private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, private readonly cfg: ConfigService, - private readonly policy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, inviterId: string, dto: InviteMemberDto) { const team = await this.getTeamOrThrow(teamId); - const inviter = await this.getInviterOrThrow(team.id, inviterId); + const inviter = await this.getInviterOrThrow(teamId, inviterId); - this.validatePermissions(inviter.role as TeamRole, dto.role as TeamRole); - await this.ensureNotAlreadyMember(team.id, dto.email); - await this.ensureNoPendingInvite(team.id, dto.email); + this.validateAccess(inviter, dto.role); + + await this.ensureNotAlreadyMember(teamId, dto.email); + await this.ensureNoPendingInvite(teamId, dto.email); const code = generateSecret({ length: 8 }); const inviteData = this.buildInviteData(team, inviter, dto); @@ -50,7 +55,7 @@ export class SendInvitationUseCase { } private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, @@ -61,7 +66,7 @@ export class SendInvitationUseCase { } private async getInviterOrThrow(teamId: string, userId: string) { - const inviter = await this.teamsRepo.findMember(teamId, userId); + const inviter = await this.teamRepo.findMember(teamId, userId); if (!inviter) { throw new BaseException( { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, @@ -71,17 +76,37 @@ export class SendInvitationUseCase { return inviter; } - private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { - if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { + private validateAccess(member: RawMemberRow, targetRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const canInvite = ability.can(Action.CREATE, Subject.INVITE); + const canAssignRole = ability.can( + Action.CREATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[targetRole] }), + ); + + if (!canInvite) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Недостаточно прав чтобы приглашать пользователей в команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (!canAssignRole) { throw new BaseException( - { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Недостаточно прав чтобы пригласить пользователя в команду с ролью ${targetRole}`, + }, HttpStatus.FORBIDDEN, ); } } private async ensureNotAlreadyMember(teamId: string, email: string) { - const member = await this.teamsRepo.findMember(teamId, email); // Тут лучше искать по email в репо + const member = await this.teamRepo.findMember(teamId, email); // Тут лучше искать по email в репо if (member) { throw new BaseException( { code: 'ALREADY_MEMBER', message: 'Уже в команде' }, diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/team/application/use-cases/invitions/update-invitation.use-case.ts similarity index 58% rename from src/teams/application/use-cases/invitions/update-invitation.use-case.ts rename to src/team/application/use-cases/invitions/update-invitation.use-case.ts index aeaebdda..511238f3 100644 --- a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/update-invitation.use-case.ts @@ -1,38 +1,50 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; +import { subject } from '@casl/ability'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { TeamRole } from '@shared/entities'; import { BaseException } from '@shared/error'; +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { UpdateInvitationDto } from '../../dtos'; import { TeamInvite } from '../../dtos/invitation.dto'; -import type { TeamRole } from '../../../infrastructure/persistence/models'; - @Injectable() export class UpdateInvitationUseCase { private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; constructor( - @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') private readonly teamRepo: ITeamRepository, @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, - private readonly policy: TeamMemberPolicy, + private readonly abilityFactory: AbilityFactory, ) {} async execute(teamId: string, code: string, userId: string, dto: UpdateInvitationDto) { - const team = await this.getTeamOrThrow(teamId); - const member = await this.getMemberOrThrow(team.id, userId); - const key = this.INVITES_KEY(code); + const { invite, ttlSeconds } = await this.getInviteContextOrThrow(key); + this.validateInviteOwnership(invite, teamId); - this.validateInviteOwnership(invite, team.id); - this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); + const member = await this.teamRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } + + this.validateAccess(member, dto.role); const updatedInvite = { ...invite, - role: dto.role as TeamRole, + role: dto.role, }; await this.cacheService.setOne(key, JSON.stringify(updatedInvite), ttlSeconds); @@ -43,26 +55,34 @@ export class UpdateInvitationUseCase { }; } - private async getTeamOrThrow(teamId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { + private validateAccess(member: RawMemberRow, targetRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const canUpdateInvites = ability.can(Action.UPDATE, Subject.INVITE); + + if (!canUpdateInvites) { throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, - HttpStatus.NOT_FOUND, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для обновления приглашений', + }, + HttpStatus.FORBIDDEN, ); } - return team; - } - private async getMemberOrThrow(teamId: string, userId: string) { - const member = await this.teamsRepo.findMember(teamId, userId); - if (!member) { + const canAssignCurrentRole = ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[targetRole] }), + ); + + if (!canAssignCurrentRole) { throw new BaseException( - { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас недостаточно прав чтобы назначить роль ${targetRole}`, + }, HttpStatus.FORBIDDEN, ); } - return member; } private async getInviteContextOrThrow(key: string) { @@ -89,18 +109,4 @@ export class UpdateInvitationUseCase { ); } } - - private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { - const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); - - if (!canUpdate) { - throw new BaseException( - { - code: 'INSUFFICIENT_PERMISSIONS', - message: 'У вас недостаточно прав для назначения этой роли', - }, - HttpStatus.FORBIDDEN, - ); - } - } } diff --git a/src/teams/application/use-cases/members/find-team-member.query.ts b/src/team/application/use-cases/members/find-team-member.query.ts similarity index 61% rename from src/teams/application/use-cases/members/find-team-member.query.ts rename to src/team/application/use-cases/members/find-team-member.query.ts index 5f82597e..6e66aeee 100644 --- a/src/teams/application/use-cases/members/find-team-member.query.ts +++ b/src/team/application/use-cases/members/find-team-member.query.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ITeamsRepository } from '../../../domain/repository'; +import { ITeamRepository } from '../../../domain/repository'; @Injectable() export class FindTeamMemberQuery { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, ) {} async execute(teamId: string, userId: string) { diff --git a/src/teams/application/use-cases/members/get-team-members.query.ts b/src/team/application/use-cases/members/get-team-members.query.ts similarity index 80% rename from src/teams/application/use-cases/members/get-team-members.query.ts rename to src/team/application/use-cases/members/get-team-members.query.ts index ccf02a81..f0a33ead 100644 --- a/src/teams/application/use-cases/members/get-team-members.query.ts +++ b/src/team/application/use-cases/members/get-team-members.query.ts @@ -1,19 +1,20 @@ -import { TeamMemberMapper } from '@core/teams/application/mappers'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; +import { TeamMemberMapper } from '../../../application/mappers'; +import { ITeamRepository } from '../../../domain/repository'; + @Injectable() export class GetTeamMembersQuery { constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, private readonly cfg: ConfigService, ) {} async execute(teamId: string) { - const team = await this.teamsRepo.findById(teamId); + const team = await this.teamRepo.findById(teamId); if (!team) { throw new BaseException( @@ -22,7 +23,7 @@ export class GetTeamMembersQuery { ); } const cdn = this.getCdnBaseUrl(); - const members = await this.teamsRepo.findMembers(team.id); + const members = await this.teamRepo.findMembers(team.id); const data = TeamMemberMapper.toList(members, cdn); return { diff --git a/src/team/application/use-cases/members/remove-team-member.use-case.ts b/src/team/application/use-cases/members/remove-team-member.use-case.ts new file mode 100644 index 00000000..c6702872 --- /dev/null +++ b/src/team/application/use-cases/members/remove-team-member.use-case.ts @@ -0,0 +1,101 @@ +import { subject } from '@casl/ability'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { BaseException } from '@shared/error'; + +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; + +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, + private readonly abilityFactory: AbilityFactory, + ) {} + + async execute(teamId: string, currentUserId: string, targetUserId: string) { + //TODO: move to policy + this.isSelfRemoval(currentUserId, targetUserId); + + const member = await this.teamRepo.findMember(teamId, currentUserId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } + + const targetUser = await this.teamRepo.findMember(teamId, targetUserId); + + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + this.validateAccess(member, targetUser.role); + + try { + const success = await this.teamRepo.removeMember(teamId, targetUserId); + if (!success) { + this.errorDuringRemoving(); + } + + return { + success: true, + message: `Участник успешно исключен из команды`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + return this.errorDuringRemoving(); + } + } + + private errorDuringRemoving() { + throw new BaseException( + { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + private isSelfRemoval(currentUserId: string, targetUserId: string) { + const isSelf = currentUserId === targetUserId; + + if (isSelf) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Вы не можете удалить самого себя' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validateAccess(member: RawMemberRow, targetUserRole: TeamRole) { + const ability = this.abilityFactory.createForTeamMember(member); + const isAllow = ability.can( + Action.DELETE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY[targetUserRole] }), + ); + + if (!isAllow) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для удаления этого участника команды', + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/team/application/use-cases/members/update-team-member.use-case.ts b/src/team/application/use-cases/members/update-team-member.use-case.ts new file mode 100644 index 00000000..0850d865 --- /dev/null +++ b/src/team/application/use-cases/members/update-team-member.use-case.ts @@ -0,0 +1,138 @@ +import { subject } from '@casl/ability'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { AbilityFactory } from '@shared/authorization/ability.factory'; +import { Action } from '@shared/authorization/types/action.enum'; +import { Subject } from '@shared/authorization/types/subject.enum'; +import { ROLE_PRIORITY } from '@shared/constants'; +import { TeamRole } from '@shared/entities'; +import { BaseException } from '@shared/error'; + +import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; +import { UpdateMemberDto } from '../../dtos'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamRepository') + private readonly teamRepo: ITeamRepository, + private readonly abilityFactory: AbilityFactory, + ) {} + + async execute( + teamId: string, + currentUserId: string, + targetUserId: string, + dto: UpdateMemberDto, + ) { + //TODO: move to policy + this.isSelfRemoval(currentUserId, targetUserId); + + const member = await this.teamRepo.findMember(teamId, currentUserId); + if (!member) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND_OR_FORBIDDEN', + message: `У вас нет прав или команда не найдена`, + }, + HttpStatus.FORBIDDEN, + ); + } + + const targetUser = await this.teamRepo.findMember(teamId, targetUserId); + + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + this.validateAccess(member, targetUser.role, dto); + + try { + const result = await this.teamRepo.updateMember(teamId, targetUserId, dto); + return { + success: result, + message: `Данные участника команды успешно обновлены`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private isSelfRemoval(currentUserId: string, targetUserId: string) { + const isSelf = currentUserId === targetUserId; + + if (isSelf) { + throw new BaseException( + { + code: 'SELF_EDIT_RESTRICTED', + message: 'Вы не можете редактировать свои данные', + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validateAccess(member: RawMemberRow, targetUserRole: TeamRole, dto: UpdateMemberDto) { + const ability = this.abilityFactory.createForTeamMember(member); + const canUpdate = ability.can( + Action.UPDATE, + subject(Subject.TEAM_MEMBER, { priority: ROLE_PRIORITY[targetUserRole] }), + ); + + if (!canUpdate) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав на управление этим участником', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (dto.role) { + const canAssignCurrentRole = ability.can( + Action.UPDATE, + subject(Subject.ROLE, { priority: ROLE_PRIORITY[dto.role] }), + ); + + if (!canAssignCurrentRole) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас нет прав назначить роль ${dto.role}`, + }, + HttpStatus.FORBIDDEN, + ); + } + } + + if (dto.status) { + const canAssignStatus = ability.can( + Action.UPDATE, + subject(Subject.STATUS, { priority: ROLE_PRIORITY[targetUserRole] }), + ); + + if (!canAssignStatus) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `У вас нет прав назначить статус этому участнику`, + }, + HttpStatus.FORBIDDEN, + ); + } + } + } +} diff --git a/src/team/domain/entities/index.ts b/src/team/domain/entities/index.ts new file mode 100644 index 00000000..0b6f2532 --- /dev/null +++ b/src/team/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './team.domain'; diff --git a/src/teams/domain/entities/teams.domain.ts b/src/team/domain/entities/team.domain.ts similarity index 100% rename from src/teams/domain/entities/teams.domain.ts rename to src/team/domain/entities/team.domain.ts diff --git a/src/teams/domain/enums/index.ts b/src/team/domain/enums/index.ts similarity index 100% rename from src/teams/domain/enums/index.ts rename to src/team/domain/enums/index.ts diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/team/domain/enums/mail-jobs.enum.ts similarity index 100% rename from src/teams/domain/enums/mail-jobs.enum.ts rename to src/team/domain/enums/mail-jobs.enum.ts diff --git a/src/teams/domain/events/index.ts b/src/team/domain/events/index.ts similarity index 100% rename from src/teams/domain/events/index.ts rename to src/team/domain/events/index.ts diff --git a/src/teams/domain/events/team-invitation.event.ts b/src/team/domain/events/team-invitation.event.ts similarity index 100% rename from src/teams/domain/events/team-invitation.event.ts rename to src/team/domain/events/team-invitation.event.ts diff --git a/src/teams/domain/policy/index.ts b/src/team/domain/policy/index.ts similarity index 100% rename from src/teams/domain/policy/index.ts rename to src/team/domain/policy/index.ts diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/team/domain/policy/team-member.policy.ts similarity index 98% rename from src/teams/domain/policy/team-member.policy.ts rename to src/team/domain/policy/team-member.policy.ts index 2a726e24..655800ac 100644 --- a/src/teams/domain/policy/team-member.policy.ts +++ b/src/team/domain/policy/team-member.policy.ts @@ -115,6 +115,6 @@ export class TeamMemberPolicy { * const canUpdate = policy.canUpdateMedia('admin'); // true */ public canUpdateMedia(issuerRole: TeamRole): boolean { - return this.getPriority(issuerRole) >= (ROLE_PRIORITY['moderator'] ?? 2); + return this.getPriority(issuerRole) >= (ROLE_PRIORITY['admin'] ?? 2); } } diff --git a/src/team/domain/repository/index.ts b/src/team/domain/repository/index.ts new file mode 100644 index 00000000..9473cfe9 --- /dev/null +++ b/src/team/domain/repository/index.ts @@ -0,0 +1,5 @@ +export { + ITeamRepository, + type RawMemberRow, + type RawMemberTeams, +} from './team.repository.interface'; diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/team/domain/repository/team.repository.interface.ts similarity index 88% rename from src/teams/domain/repository/teams.repository.interface.ts rename to src/team/domain/repository/team.repository.interface.ts index aa958cf6..e6bafbaf 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/team/domain/repository/team.repository.interface.ts @@ -1,11 +1,12 @@ +import type { TeamRole, TeamMemberStatus } from '../../infrastructure/persistence/models'; import type { Team, NewTeam, NewTeamMember } from '../entities'; type TResponse = { readonly success: boolean; readonly teamId: string }; export type RawMemberRow = { readonly userId: string; - readonly role: string; - readonly status: string; + readonly role: TeamRole; + readonly status: TeamMemberStatus; readonly joinedAt: string | null; readonly firstName: string | null; readonly lastName: string | null; @@ -23,7 +24,7 @@ export type RawMemberTeams = { readonly joinedAt: string | null; }; -export interface ITeamsRepository { +export interface ITeamRepository { create(ownerId: string, dto: NewTeam): Promise; update(id: string, dto: Partial): Promise; remove(id: string, userId: string): Promise; diff --git a/src/teams/index.ts b/src/team/index.ts similarity index 62% rename from src/teams/index.ts rename to src/team/index.ts index f4d6e9c9..aa23e704 100644 --- a/src/teams/index.ts +++ b/src/team/index.ts @@ -1,2 +1,2 @@ -export { TeamsModule } from './teams.module'; +export { TeamModule } from './team.module'; export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/teams/infrastructure/listeners/index.ts b/src/team/infrastructure/listeners/index.ts similarity index 100% rename from src/teams/infrastructure/listeners/index.ts rename to src/team/infrastructure/listeners/index.ts diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/team/infrastructure/listeners/update-media.listener.ts similarity index 92% rename from src/teams/infrastructure/listeners/update-media.listener.ts rename to src/team/infrastructure/listeners/update-media.listener.ts index b3d92471..c2bfdcc2 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/team/infrastructure/listeners/update-media.listener.ts @@ -1,17 +1,18 @@ import { MEDIA_JOBS, MEDIA_QUEUES, UpdateMediaTeam } from '@core/media'; -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; import { type Job, UnrecoverableError } from 'bullmq'; +import { TeamMemberPolicy } from '../../domain/policy'; +import { ITeamRepository } from '../../domain/repository'; + import type { TeamRole } from '@shared/entities'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) export class UpdateTeamMediaListener extends WorkerHost { constructor( - @Inject('ITeamsRepository') - private readonly repository: ITeamsRepository, + @Inject('ITeamRepository') + private readonly repository: ITeamRepository, private readonly policy: TeamMemberPolicy, ) { super(); diff --git a/src/teams/infrastructure/persistence/models/enums.ts b/src/team/infrastructure/persistence/models/enums.ts similarity index 84% rename from src/teams/infrastructure/persistence/models/enums.ts rename to src/team/infrastructure/persistence/models/enums.ts index 2dba2b2a..c006798c 100644 --- a/src/teams/infrastructure/persistence/models/enums.ts +++ b/src/team/infrastructure/persistence/models/enums.ts @@ -3,8 +3,6 @@ import { baseSchema } from '@shared/entities'; export const roleEnum = baseSchema.enum('team_role', [ 'owner', 'admin', // управление юзерами, настройками - 'lead', // управление проектами - 'moderator', // чистка контента/сообщений 'member', // обычный работяга 'viewer', // просто смотрит ]); @@ -15,3 +13,5 @@ export const statusEnum = baseSchema.enum('member_status', [ 'banned', // Заблокирован не может вернуться по инвайту 'inactive', // Доступ закрыт, но запись сохранена ]); + +export type TeamMemberStatus = (typeof statusEnum.enumValues)[number]; diff --git a/src/team/infrastructure/persistence/models/index.ts b/src/team/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..20898564 --- /dev/null +++ b/src/team/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { teamMembers, teams } from './team.model'; +export { type TeamRole, type TeamMemberStatus, roleEnum, statusEnum } from './enums'; diff --git a/src/teams/infrastructure/persistence/models/teams.model.ts b/src/team/infrastructure/persistence/models/team.model.ts similarity index 100% rename from src/teams/infrastructure/persistence/models/teams.model.ts rename to src/team/infrastructure/persistence/models/team.model.ts diff --git a/src/team/infrastructure/persistence/repositories/index.ts b/src/team/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..b87bc704 --- /dev/null +++ b/src/team/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamRepository } from './team.repository'; diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/team/infrastructure/persistence/repositories/team.repository.ts similarity index 96% rename from src/teams/infrastructure/persistence/repositories/teams.repository.ts rename to src/team/infrastructure/persistence/repositories/team.repository.ts index cc119359..fdf4e4d1 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/team/infrastructure/persistence/repositories/team.repository.ts @@ -1,14 +1,14 @@ -import { ITeamsRepository } from '@core/teams/domain/repository'; import * as scUsers from '@core/user/infrastructure/persistence/models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject } from '@nestjs/common'; import { and, desc, eq, isNull } from 'drizzle-orm'; +import { ITeamRepository } from '../../../domain/repository'; import * as schema from '../models'; -import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; +import type { NewTeam, NewTeamMember, Team, TeamMember } from '../../../domain/entities'; -export class TeamsRepository implements ITeamsRepository { +export class TeamRepository implements ITeamRepository { constructor( @Inject(DATABASE_SERVICE) private readonly db: DatabaseService, diff --git a/src/teams/infrastructure/workers/index.ts b/src/team/infrastructure/workers/index.ts similarity index 100% rename from src/teams/infrastructure/workers/index.ts rename to src/team/infrastructure/workers/index.ts diff --git a/src/teams/infrastructure/workers/mail.processor.ts b/src/team/infrastructure/workers/mail.processor.ts similarity index 92% rename from src/teams/infrastructure/workers/mail.processor.ts rename to src/team/infrastructure/workers/mail.processor.ts index ec6c0655..07d29c3d 100644 --- a/src/teams/infrastructure/workers/mail.processor.ts +++ b/src/team/infrastructure/workers/mail.processor.ts @@ -1,9 +1,10 @@ -import { TeamQueues } from '@core/teams/domain/enums'; -import { TeamInvitationEvent } from '@core/teams/domain/events'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; import { IMailPort } from '@shared/adapters/mail'; +import { TeamQueues } from '../../domain/enums'; +import { TeamInvitationEvent } from '../../domain/events'; + import type { Job } from 'bullmq'; @Processor(TeamQueues.TEAM_MAIL) diff --git a/src/teams/teams.module.ts b/src/team/team.module.ts similarity index 56% rename from src/teams/teams.module.ts rename to src/team/team.module.ts index 92b39574..24a18c59 100644 --- a/src/teams/teams.module.ts +++ b/src/team/team.module.ts @@ -1,14 +1,13 @@ -import { MailProcessor } from '@core/teams/infrastructure/workers'; import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; import { - TeamsInvitationsController, - TeamsMembersController, - TeamsController, + TeamInvitationsController, + TeamMembersController, + TeamController, MeController, -} from './application/controller'; -import { TeamsFacade } from './application/team.facade'; +} from './application/controllers'; +import { TeamFacade } from './application/team.facade'; import { TeamQueries, TeamUseCases, @@ -18,9 +17,10 @@ import { import { TeamQueues } from './domain/enums'; import { TeamMemberPolicy } from './domain/policy'; import { LISTENERS } from './infrastructure/listeners'; -import { TeamsRepository } from './infrastructure/persistence/repositories'; +import { TeamRepository } from './infrastructure/persistence/repositories'; +import { MailProcessor } from './infrastructure/workers'; -const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; +const REPOSITORY = { provide: 'ITeamRepository', useClass: TeamRepository }; @Module({ imports: [ @@ -28,21 +28,16 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; name: TeamQueues.TEAM_MAIL, }), ], - controllers: [ - TeamsInvitationsController, - TeamsMembersController, - TeamsController, - MeController, - ], + controllers: [TeamInvitationsController, TeamMembersController, TeamController, MeController], providers: [ TeamMemberPolicy, REPOSITORY, ...LISTENERS, ...TeamUseCases, ...TeamQueries, - TeamsFacade, + TeamFacade, MailProcessor, ], exports: [...TEAM_EXTERNAL_QUERIES, ...TEAM_EXTERNAL_COMMANDS], }) -export class TeamsModule {} +export class TeamModule {} diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts deleted file mode 100644 index cb32def7..00000000 --- a/src/teams/application/controller/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { MeController } from './me/controller'; -export { TeamsController } from './teams/controller'; -export { TeamsMembersController } from './members/controller'; -export { TeamsInvitationsController } from './invitations/controller'; diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts deleted file mode 100644 index c56459b5..00000000 --- a/src/teams/application/use-cases/base/delete-team.use-case.ts +++ /dev/null @@ -1,59 +0,0 @@ -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(teamId: string, userId: string) { - const team = await this.teamsRepo.findById(teamId); - - if (!team) { - throw new BaseException( - { - code: 'TEAM_NOT_FOUND', - message: `Команда ${teamId} не найдена`, - }, - HttpStatus.NOT_FOUND, - ); - } - - const member = await this.teamsRepo.findMember(team.id, userId); - const isOwner = team.ownerId === userId || member?.role === 'owner'; - - if (!isOwner) { - throw new BaseException( - { - code: 'ONLY_OWNER_CAN_DELETE', - message: 'Только владелец может удалить команду', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.remove(team.id, userId); - - return { - success: result, - message: 'Команда успешно удалена', - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } - - throw new BaseException( - { - code: 'TEAM_DELETE_FAILED', - message: 'Не удалось удалить команду', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } -} diff --git a/src/teams/application/use-cases/members/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts deleted file mode 100644 index d5a875a9..00000000 --- a/src/teams/application/use-cases/members/remove-team-member.use-case.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -import type { TeamRole } from '@shared/entities'; - -@Injectable() -export class RemoveTeamMemberUseCase { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - private readonly policy: TeamMemberPolicy, - ) {} - - async execute(teamId: string, currentUserId: string, targetUserId: string) { - const team = await this.teamsRepo.findById(teamId); - if (!team) { - throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, - HttpStatus.NOT_FOUND, - ); - } - - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); - - if (!targetUser) { - throw new BaseException( - { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, - HttpStatus.NOT_FOUND, - ); - } - - if (!currentUser) { - throw new BaseException( - { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, - HttpStatus.FORBIDDEN, - ); - } - - const isSelfRemoval = currentUserId === targetUserId; - - const canRemove = this.policy.canRemove( - currentUser.role as TeamRole, - targetUser.role as TeamRole, - isSelfRemoval, - ); - - if (!canRemove) { - const errorCode = isSelfRemoval ? 'OWNER_CANNOT_LEAVE' : 'KICK_FORBIDDEN'; - const errorMessage = isSelfRemoval - ? 'Владелец не может покинуть команду без передачи прав' - : 'У вас недостаточно прав, чтобы исключить этого участника'; - - throw new BaseException( - { code: errorCode, message: errorMessage }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.removeMember(team.id, targetUserId); - return { - success: result, - message: isSelfRemoval - ? `Вы успешно покинули команду ${team.name}` - : `Участник успешно исключен из команды ${team.name}`, - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } - - throw new BaseException( - { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } -} diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/teams/application/use-cases/members/update-team-member.use-case.ts deleted file mode 100644 index 9b8eb5c8..00000000 --- a/src/teams/application/use-cases/members/update-team-member.use-case.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { TeamMemberPolicy } from '@core/teams/domain/policy'; -import { ITeamsRepository } from '@core/teams/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { TeamRole } from '@shared/entities'; -import { BaseException } from '@shared/error'; - -import { UpdateMemberDto } from '../../dtos'; - -@Injectable() -export class UpdateTeamMemberUseCase { - constructor( - @Inject('ITeamsRepository') - private readonly teamsRepo: ITeamsRepository, - private readonly teamMemberPolicy: TeamMemberPolicy, - ) {} - - async execute( - teamId: string, - currentUserId: string, - targetUserId: string, - dto: UpdateMemberDto, - ) { - if (currentUserId === targetUserId) { - throw new BaseException( - { code: 'SELF_EDIT_RESTRICTED', message: 'Вы не можете редактировать свои данные' }, - HttpStatus.BAD_REQUEST, - ); - } - - const team = await this.teamsRepo.findById(teamId); - if (!team) { - throw new BaseException( - { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, - HttpStatus.NOT_FOUND, - ); - } - - const [currentUser, targetUser] = await Promise.all([ - this.teamsRepo.findMember(team.id, currentUserId), - this.teamsRepo.findMember(team.id, targetUserId), - ]); - - if (!targetUser) { - throw new BaseException( - { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, - HttpStatus.NOT_FOUND, - ); - } - - if (!currentUser) { - throw new BaseException( - { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, - HttpStatus.FORBIDDEN, - ); - } - - const issuerRole = currentUser.role as TeamRole; - const targetRole = targetUser.role as TeamRole; - - if (!this.teamMemberPolicy.canManage(issuerRole, targetRole)) { - throw new BaseException( - { code: 'INSUFFICIENT_RANK', message: 'Ваш ранг должен быть выше ранга цели' }, - HttpStatus.FORBIDDEN, - ); - } - - if (dto.role && !this.teamMemberPolicy.canAssignRole(issuerRole, targetRole, dto.role)) { - throw new BaseException( - { - code: 'INVALID_ROLE_ASSIGNMENT', - message: 'У вас нет прав назначить выбранную роль', - }, - HttpStatus.FORBIDDEN, - ); - } - - if (dto.status && !this.teamMemberPolicy.canChangeStatus(issuerRole, targetRole)) { - throw new BaseException( - { - code: 'INVALID_STATUS_CHANGE', - message: 'Вы не можете менять статус этого участника', - }, - HttpStatus.FORBIDDEN, - ); - } - - try { - const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); - return { - success: result, - message: `Данные участника команды ${team.name} успешно обновлены`, - }; - } catch (error) { - if (error instanceof BaseException) { - throw error; - } - - throw new BaseException( - { - code: 'MEMBER_UPDATE_FAILED', - message: 'Ошибка при обновлении данных участника', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } -} diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts deleted file mode 100644 index 40d100b7..00000000 --- a/src/teams/domain/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './teams.domain'; diff --git a/src/teams/domain/repository/index.ts b/src/teams/domain/repository/index.ts deleted file mode 100644 index 0d97b361..00000000 --- a/src/teams/domain/repository/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - ITeamsRepository, - type RawMemberRow, - type RawMemberTeams, -} from './teams.repository.interface'; diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts deleted file mode 100644 index 2a40eb06..00000000 --- a/src/teams/infrastructure/persistence/models/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { teamMembers, teams } from './teams.model'; -export { type TeamRole, roleEnum, statusEnum } from './enums'; diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts deleted file mode 100644 index 259ca0a5..00000000 --- a/src/teams/infrastructure/persistence/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamsRepository } from './teams.repository'; diff --git a/tsconfig.json b/tsconfig.json index d175afda..627f45b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,7 +48,9 @@ "@libs/s3": ["./libs/s3/src"], "@libs/s3/*": ["./libs/s3/src/*"], "@shared/*": ["./src/shared/*"], - "@core/*": ["./src/*"] + "@core/*": ["./src/*"], + "@casl/ability": ["./node_modules/@casl/ability/dist/types"], + "@casl/ability/extra": ["./node_modules/@casl/extra/dist/types/extra"] } }, "include": [