From 0215229756f8664401574079a4a18426b09f42d5 Mon Sep 17 00:00:00 2001 From: Dmitrij Kolotusha Date: Fri, 13 Mar 2026 16:10:25 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(pack):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/types/src/teams/index.ts | 1 + packages/types/src/teams/schemas/change-role.schema.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 packages/types/src/teams/schemas/change-role.schema.ts diff --git a/packages/types/src/teams/index.ts b/packages/types/src/teams/index.ts index 4b61690f..232aad8c 100644 --- a/packages/types/src/teams/index.ts +++ b/packages/types/src/teams/index.ts @@ -1,4 +1,5 @@ export * from './constants/team-role.constants' export * from './schemas/create-team.schema' export * from './schemas/update-team.schema' +export * from './schemas/change-role.schema' export * from './types/team.types' diff --git a/packages/types/src/teams/schemas/change-role.schema.ts b/packages/types/src/teams/schemas/change-role.schema.ts new file mode 100644 index 00000000..f9d3e54a --- /dev/null +++ b/packages/types/src/teams/schemas/change-role.schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const changeRoleSchema = z.object({ + role: z.enum(['ADMIN', 'MEMBER'], { + message: + 'Роль должна быть ADMIN или MEMBER. Назначить OWNER через этот эндпоинт нельзя', + }), +}) + +export type ChangeRole = z.infer From 6cda0130fd6a9169fafbc7766cafcb4112fd5648 Mon Sep 17 00:00:00 2001 From: Dmitrij Kolotusha Date: Fri, 13 Mar 2026 16:15:28 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(api):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20DTO=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D1=85=20?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/teams/members/dto/change-role.dto.ts | 12 +++++++ .../teams/members/dto/member-response.dto.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 apps/api/src/teams/members/dto/change-role.dto.ts create mode 100644 apps/api/src/teams/members/dto/member-response.dto.ts diff --git a/apps/api/src/teams/members/dto/change-role.dto.ts b/apps/api/src/teams/members/dto/change-role.dto.ts new file mode 100644 index 00000000..65b7074b --- /dev/null +++ b/apps/api/src/teams/members/dto/change-role.dto.ts @@ -0,0 +1,12 @@ +import { createZodDto } from 'nestjs-zod' +import { changeRoleSchema } from '@repo/types' +import { ApiProperty } from '@nestjs/swagger' + +export class ChangeRoleDto extends createZodDto(changeRoleSchema) { + @ApiProperty({ + enum: ['ADMIN', 'MEMBER'], + example: 'ADMIN', + description: 'Новая роль участника. OWNER назначить нельзя', + }) + role: 'ADMIN' | 'MEMBER' +} diff --git a/apps/api/src/teams/members/dto/member-response.dto.ts b/apps/api/src/teams/members/dto/member-response.dto.ts new file mode 100644 index 00000000..9fdf5cab --- /dev/null +++ b/apps/api/src/teams/members/dto/member-response.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger' +import { TeamRole } from '@repo/types' + +export class MemberUserDto { + @ApiProperty({ example: 'uuid-user-1' }) + id: string + + @ApiProperty({ example: 'Иван Иванов', nullable: true }) + name: string | null + + @ApiProperty({ example: 'user@example.com' }) + email: string +} + +export class MemberResponse { + @ApiProperty({ example: 'uuid-member-1' }) + id: string + + @ApiProperty({ example: 'uuid-team-1' }) + teamId: string + + @ApiProperty({ example: 'uuid-user-1' }) + userId: string + + @ApiProperty({ type: MemberUserDto }) + user: MemberUserDto + + @ApiProperty({ enum: ['OWNER', 'ADMIN', 'MEMBER'], example: 'MEMBER' }) + role: TeamRole + + @ApiProperty({ example: '2024-01-01T00:00:00.000Z' }) + joinedAt: Date +} From 6f53b82edd51348d1c52260d3d5fb9d3640e4a09 Mon Sep 17 00:00:00 2001 From: Dmitrij Kolotusha Date: Fri, 13 Mar 2026 16:22:49 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(api):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20service=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/teams/members/team-members.service.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 apps/api/src/teams/members/team-members.service.ts diff --git a/apps/api/src/teams/members/team-members.service.ts b/apps/api/src/teams/members/team-members.service.ts new file mode 100644 index 00000000..6d7f1fed --- /dev/null +++ b/apps/api/src/teams/members/team-members.service.ts @@ -0,0 +1,142 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../../prisma/prisma.service' +import { ChangeRoleDto } from './dto/change-role.dto' +import { TeamRole } from '@repo/types' + +const ROLE_WEIGHT: Record = { + OWNER: 3, + ADMIN: 2, + MEMBER: 1, +} + +@Injectable() +export class TeamMembersService { + constructor(private readonly prisma: PrismaService) {} + + async getMembers(teamId: string, userId: string) { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + include: { + members: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }, + }, + }) + + if (!team) { + throw new NotFoundException('Команда не найдена') + } + + const isMember = team.members.some((m) => m.userId === userId) + if (!isMember) { + throw new ForbiddenException('Вы не являетесь участником этой команды') + } + + return team.members + } + + async changeRole( + teamId: string, + actorId: string, + targetUserId: string, + dto: ChangeRoleDto, + ) { + if (actorId === targetUserId) { + throw new ForbiddenException('Нельзя изменить свою собственную роль') + } + + const [actor, target] = await Promise.all([ + this.prisma.teamMember.findUnique({ + where: { teamId_userId: { teamId, userId: actorId } }, + }), + this.prisma.teamMember.findUnique({ + where: { teamId_userId: { teamId, userId: targetUserId } }, + }), + ]) + + if (!actor) { + throw new ForbiddenException('Вы не являетесь участником этой команды') + } + + if (!target) { + throw new NotFoundException('Участник не найден в команде') + } + + // OWNER не может быть понижен + if (target.role === 'OWNER') { + throw new ForbiddenException('Нельзя изменить роль владельца команды') + } + + // ADMIN не может назначить роль выше своей (т.е. не может дать OWNER) + if (ROLE_WEIGHT[actor.role] <= ROLE_WEIGHT[dto.role as TeamRole]) { + // actor.role = MEMBER: не может назначать вообще + // actor.role = ADMIN: не может назначить OWNER (но OWNER в dto недоступен — схема запрещает) + if (actor.role !== 'OWNER' && actor.role !== 'ADMIN') { + throw new ForbiddenException('Недостаточно прав для изменения ролей') + } + } + + // только OWNER или ADMIN могут менять роли + if (actor.role !== 'OWNER' && actor.role !== 'ADMIN') { + throw new ForbiddenException('Недостаточно прав для изменения ролей') + } + + return this.prisma.teamMember.update({ + where: { teamId_userId: { teamId, userId: targetUserId } }, + data: { role: dto.role }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }) + } + + async removeMember(teamId: string, actorId: string, targetUserId: string) { + const isSelfLeave = actorId === targetUserId + + const [actor, target] = await Promise.all([ + this.prisma.teamMember.findUnique({ + where: { teamId_userId: { teamId, userId: actorId } }, + }), + this.prisma.teamMember.findUnique({ + where: { teamId_userId: { teamId, userId: targetUserId } }, + }), + ]) + + if (!actor) { + throw new ForbiddenException('Вы не являетесь участником этой команды') + } + + if (!target) { + throw new NotFoundException('Участник не найден в команде') + } + + // OWNER не может быть удалён никем + if (target.role === 'OWNER') { + throw new ForbiddenException('Владелец команды не может быть удалён') + } + + // Если не самоуход — нужны права OWNER или ADMIN + if (!isSelfLeave && actor.role !== 'OWNER' && actor.role !== 'ADMIN') { + throw new ForbiddenException('Недостаточно прав для исключения участника') + } + + // ADMIN не может удалить другого ADMIN (только OWNER может) + if (!isSelfLeave && actor.role === 'ADMIN' && target.role === 'ADMIN') { + throw new ForbiddenException( + 'Администратор не может исключить другого администратора', + ) + } + + await this.prisma.teamMember.delete({ + where: { teamId_userId: { teamId, userId: targetUserId } }, + }) + + return { + message: isSelfLeave ? 'Вы покинули команду' : 'Участник успешно исключён', + success: true, + } + } +} From 5e5c028d0a84822b97092ca08d6123a24cdf2025 Mon Sep 17 00:00:00 2001 From: Dmitrij Kolotusha Date: Fri, 13 Mar 2026 16:26:02 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(api):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20controller=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teams/members/team-members.controller.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/api/src/teams/members/team-members.controller.ts diff --git a/apps/api/src/teams/members/team-members.controller.ts b/apps/api/src/teams/members/team-members.controller.ts new file mode 100644 index 00000000..d6339fe7 --- /dev/null +++ b/apps/api/src/teams/members/team-members.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, +} from '@nestjs/common' +import { + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger' + +import { TeamMembersService } from './team-members.service' +import { ChangeRoleDto } from './dto/change-role.dto' +import { MemberResponse } from './dto/member-response.dto' +import { Authorization } from '../../auth/decorators/authorization.decorator' +import { Authorized } from '../../auth/decorators/authorized.decorator' + +@ApiTags('Team Members') +@ApiBearerAuth() +@Authorization() +@Controller('teams/:id/members') +export class TeamMembersController { + constructor(private readonly teamMembersService: TeamMembersService) {} + + @ApiOperation({ summary: 'Список участников команды' }) + @ApiOkResponse({ type: [MemberResponse], description: 'Список участников' }) + @ApiForbiddenResponse({ description: 'Вы не являетесь участником этой команды' }) + @ApiNotFoundResponse({ description: 'Команда не найдена' }) + @Get() + getMembers(@Param('id') teamId: string, @Authorized('id') userId: string) { + return this.teamMembersService.getMembers(teamId, userId) + } + + @ApiOperation({ summary: 'Изменить роль участника (OWNER / ADMIN)' }) + @ApiOkResponse({ type: MemberResponse, description: 'Роль изменена' }) + @ApiForbiddenResponse({ + description: 'Недостаточно прав или нельзя изменить роль OWNER', + }) + @ApiNotFoundResponse({ description: 'Команда или участник не найден' }) + @Patch(':userId/role') + @HttpCode(HttpStatus.OK) + changeRole( + @Param('id') teamId: string, + @Param('userId') targetUserId: string, + @Authorized('id') actorId: string, + @Body() dto: ChangeRoleDto, + ) { + return this.teamMembersService.changeRole(teamId, actorId, targetUserId, dto) + } + + @ApiOperation({ summary: 'Исключить участника или покинуть команду' }) + @ApiOkResponse({ + description: 'Участник исключён / вы покинули команду', + schema: { example: { message: 'Участник успешно исключён', success: true } }, + }) + @ApiForbiddenResponse({ description: 'Недостаточно прав или OWNER нельзя удалить' }) + @ApiNotFoundResponse({ description: 'Команда или участник не найден' }) + @Delete(':userId') + removeMember( + @Param('id') teamId: string, + @Param('userId') targetUserId: string, + @Authorized('id') actorId: string, + ) { + return this.teamMembersService.removeMember(teamId, actorId, targetUserId) + } +} From 72a66ed02e1e246693d7177c0a2cf9f513de37ae Mon Sep 17 00:00:00 2001 From: Dmitrij Kolotusha Date: Fri, 13 Mar 2026 16:28:17 +0300 Subject: [PATCH 5/5] =?UTF-8?q?feat(api):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20TeamMembersM?= =?UTF-8?q?odule=20=D0=B2=20TeamsModule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/teams/members/team-members.module.ts | 9 +++++++++ apps/api/src/teams/teams.module.ts | 2 ++ 2 files changed, 11 insertions(+) create mode 100644 apps/api/src/teams/members/team-members.module.ts diff --git a/apps/api/src/teams/members/team-members.module.ts b/apps/api/src/teams/members/team-members.module.ts new file mode 100644 index 00000000..5ae749b2 --- /dev/null +++ b/apps/api/src/teams/members/team-members.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { TeamMembersController } from './team-members.controller' +import { TeamMembersService } from './team-members.service' + +@Module({ + controllers: [TeamMembersController], + providers: [TeamMembersService], +}) +export class TeamMembersModule {} diff --git a/apps/api/src/teams/teams.module.ts b/apps/api/src/teams/teams.module.ts index de7202a7..a7ee7fbd 100644 --- a/apps/api/src/teams/teams.module.ts +++ b/apps/api/src/teams/teams.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' import { TeamsService } from './teams.service' import { TeamsController } from './teams.controller' +import { TeamMembersModule } from './members/team-members.module' @Module({ + imports: [TeamMembersModule], controllers: [TeamsController], providers: [TeamsService], })