Skip to content
Merged
12 changes: 12 additions & 0 deletions apps/api/src/teams/members/dto/change-role.dto.ts
Original file line number Diff line number Diff line change
@@ -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'
}
33 changes: 33 additions & 0 deletions apps/api/src/teams/members/dto/member-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
74 changes: 74 additions & 0 deletions apps/api/src/teams/members/team-members.controller.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions apps/api/src/teams/members/team-members.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
142 changes: 142 additions & 0 deletions apps/api/src/teams/members/team-members.service.ts
Original file line number Diff line number Diff line change
@@ -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<TeamRole, number> = {
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,
}
}
}
2 changes: 2 additions & 0 deletions apps/api/src/teams/teams.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/teams/index.ts
Original file line number Diff line number Diff line change
@@ -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'
10 changes: 10 additions & 0 deletions packages/types/src/teams/schemas/change-role.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof changeRoleSchema>
Loading