diff --git a/src/modules/teams/controller/invitations.controller.ts b/src/modules/teams/controller/invitations.controller.ts index c1adc0c..a4df155 100644 --- a/src/modules/teams/controller/invitations.controller.ts +++ b/src/modules/teams/controller/invitations.controller.ts @@ -1,25 +1,44 @@ import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; import { TeamInvitationsService } from '../services'; -import { AcceptInviteSwagger, InviteMemberSwagger } from './teams.swagger'; +import { + AcceptInviteSwagger, + DeleteTeamInvitationSwagger, + GetTeamInvitationSwagger, + GetTeamInvitationsSwagger, + InviteMemberSwagger, + UpdateTeamInvitationSwagger, +} from './teams.swagger'; import type { JwtPayload } from '@shared/types'; -import { ApiOperation } from '@nestjs/swagger'; +import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; @ApiBaseController('teams/:slug/invitations', 'Teams Invitations', true) export class TeamsInvitationsController { constructor(private readonly facade: TeamInvitationsService) {} @Get() - @ApiOperation({ deprecated: true }) - async getAll() {} + @GetTeamInvitationsSwagger() + async getAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.getInvitations(slug, userId); + } - @Get(':invitationId') - @ApiOperation({ deprecated: true }) - async getOne() {} + @Get(':code') + @GetTeamInvitationSwagger() + async getOne( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + ) { + return this.facade.getInvitation(slug, code, userId); + } @Post() @InviteMemberSwagger() - async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) { + async invite( + @Param('slug') slug: string, + @GetUserId() inviterId: string, + @Body() dto: InviteMemberDto, + ) { return this.facade.invite(slug, inviterId, dto); } @@ -29,11 +48,24 @@ export class TeamsInvitationsController { return this.facade.acceptInvite(code, user.sub, user.email); } - @Patch(':invitationId') - @ApiOperation({ deprecated: true }) - async update() {} + @Patch(':code') + @UpdateTeamInvitationSwagger() + async update( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + @Body() dto: UpdateInvitationDto, + ) { + return this.facade.updateInvitation(slug, code, userId, dto); + } - @Delete(':invitationId') - @ApiOperation({ deprecated: true }) - async decline() {} + @Delete(':code') + @DeleteTeamInvitationSwagger() + async decline( + @Param('slug') slug: string, + @Param('code') code: string, + @GetUserId() userId: string, + ) { + return this.facade.declineInvitation(slug, code, userId); + } } diff --git a/src/modules/teams/controller/teams.swagger.ts b/src/modules/teams/controller/teams.swagger.ts index 5ea9a1e..c770e62 100644 --- a/src/modules/teams/controller/teams.swagger.ts +++ b/src/modules/teams/controller/teams.swagger.ts @@ -12,12 +12,14 @@ import { import { CreateTeamDto, InviteMemberDto, + TeamInvitationResponse, SyncTagsDto, UpdateTeamDto, TagResponse, TeamMemberResponse, CheckSlugResponse, UpdateMemberDto, + UpdateInvitationDto, UserTeamResponse, UserInviteResponse, } from '../dtos'; @@ -306,3 +308,79 @@ export const AcceptInviteSwagger = () => ApiConflict('Пользователь уже является участником этой команды'), ApiUnauthorized(), ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: [TeamInvitationResponse.Output], + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: TeamInvitationResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'slug', description: 'Слаг команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + ); diff --git a/src/modules/teams/dtos/index.ts b/src/modules/teams/dtos/index.ts index fcd13e2..f87edb5 100644 --- a/src/modules/teams/dtos/index.ts +++ b/src/modules/teams/dtos/index.ts @@ -4,6 +4,7 @@ export { TeamMemberResponse, UserInviteResponse, } from './member.dto'; +export { UpdateInvitationDto, TeamInvitationResponse } from './invitation.dto'; export { CreateTeamDto, UpdateTeamDto, diff --git a/src/modules/teams/dtos/invitation.dto.ts b/src/modules/teams/dtos/invitation.dto.ts new file mode 100644 index 0000000..d3908a9 --- /dev/null +++ b/src/modules/teams/dtos/invitation.dto.ts @@ -0,0 +1,38 @@ +import { z } from 'zod/v4'; +import { createZodDto } from 'nestjs-zod'; +import { roleEnum, TeamRole } from '../entities/enums'; + +export const UpdateInvitationSchema = z.object({ + role: z + .enum(roleEnum.enumValues) + .describe('Новая роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class UpdateInvitationDto extends createZodDto(UpdateInvitationSchema) {} + +export const TeamInvitationSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamId: z.string().describe('ID команды'), + teamName: z.string().describe('Название команды'), + teamAvatar: z.string().nullable().describe('Аватар команды'), + email: z.string().email().describe('Email приглашённого пользователя'), + role: z.string().describe('Роль, которая будет назначена после принятия инвайта'), + inviterId: z.string().describe('ID пользователя, отправившего приглашение'), + inviterName: z.string().describe('Имя пригласившего'), + createdAt: z.string().datetime().describe('Дата создания инвайта (ISO 8601)'), + expiresAt: z.string().datetime().describe('Дата истечения инвайта (ISO 8601)'), +}); + +export class TeamInvitationResponse extends createZodDto(TeamInvitationSchema) {} + +export interface TeamInvite { + teamId: string; + teamName: string; + teamAvatar: string | null; + email: string; + role: TeamRole; + inviterId: string; + inviterName: string; + createdAt: string; + expiresAt: string; +} diff --git a/src/modules/teams/dtos/member.dto.ts b/src/modules/teams/dtos/member.dto.ts index fb740dc..ac48ccd 100644 --- a/src/modules/teams/dtos/member.dto.ts +++ b/src/modules/teams/dtos/member.dto.ts @@ -1,10 +1,11 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; +import { roleEnum } from '../entities'; export const InviteMemberSchema = z.object({ email: z.string().email().describe('Email пользователя, которого нужно пригласить'), role: z - .string() + .enum(roleEnum.enumValues) .default('member') .describe('Роль, которая будет назначена пользователю после принятия инвайта'), }); diff --git a/src/modules/teams/entities/enums.ts b/src/modules/teams/entities/enums.ts index b3d2b79..2dba2b2 100644 --- a/src/modules/teams/entities/enums.ts +++ b/src/modules/teams/entities/enums.ts @@ -8,6 +8,8 @@ export const roleEnum = baseSchema.enum('team_role', [ 'member', // обычный работяга 'viewer', // просто смотрит ]); +export type TeamRole = (typeof roleEnum.enumValues)[number]; + export const statusEnum = baseSchema.enum('member_status', [ 'active', // Полноценный участник 'banned', // Заблокирован не может вернуться по инвайту diff --git a/src/modules/teams/services/invitations.service.ts b/src/modules/teams/services/invitations.service.ts index 9a3b0fd..75bf93a 100644 --- a/src/modules/teams/services/invitations.service.ts +++ b/src/modules/teams/services/invitations.service.ts @@ -7,12 +7,47 @@ import { InjectQueue } from '@nestjs/bullmq'; import { MailJobs, Queues } from '@shared/workers'; import { Queue } from 'bullmq'; import { TeamInvitationEvent } from '@shared/workers/events'; -import type { InviteMemberDto } from '../dtos'; +import { InviteMemberDto, UpdateInvitationDto } from '../dtos'; import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; +import { TeamInvite } from '@core/modules/teams/dtos/invitation.dto'; @Injectable() export class TeamInvitationsService { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + private assertCanManageInvites = async (teamId: string, userId: string) => { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав управлять приглашениями в этой команде', + }, + HttpStatus.FORBIDDEN, + ); + } + return member; + }; + + private parseInvite = (raw: string, code?: string) => { + try { + const invite = JSON.parse(raw) as TeamInvite; + return code ? { code, ...invite } : invite; + } catch { + throw new BaseException( + { + code: 'INVITE_DATA_CORRUPTED', + message: 'Данные приглашения повреждены', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + }; + constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, @@ -23,6 +58,203 @@ export class TeamInvitationsService { private readonly cfg: ConfigService, ) {} + public getInvitations = async (slug: string, userId?: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + if (userId) { + await this.assertCanManageInvites(team.id, userId); + } + + const codes = await this.redis.smembers(this.TEAM_INVITES_KEY(team.id)); + if (!codes.length) return []; + + const keys = codes.map((c) => this.INVITES_KEY(c)); + const invitesRaw = await this.redis.mget(...keys); + + return invitesRaw + .map((raw, idx) => { + if (!raw) return null; + return this.parseInvite(raw, codes[idx]); + }) + .filter((v): v is TeamInvite => Boolean(v)); + }; + + public getInvitation = async (slug: string, code: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw, code); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + return invite; + }; + + public updateInvitation = async ( + slug: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const key = this.INVITES_KEY(code); + const raw = await this.redis.get(key); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + const ttl = await this.redis.ttl(key); + if (ttl === -2) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + invite.role = dto.role; + + if (ttl > 0) { + await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + } else { + await this.redis.set(key, JSON.stringify(invite)); + } + + return { code, ...invite }; + }; + + public declineInvitation = async (slug: string, code: string, userId: string) => { + const team = await this.teamsRepo.findBySlug(slug); + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.assertCanManageInvites(team.id, userId); + + const raw = await this.redis.get(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'Срок действия приглашения истек или код неверен', + }, + HttpStatus.NOT_FOUND, + ); + } + + const invite = this.parseInvite(raw); + if (invite.teamId !== team.id) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND', + message: 'Приглашение не найдено', + }, + HttpStatus.NOT_FOUND, + ); + } + + await this.removeInvitation(team.id, code, invite.email); + + return { + success: true, + message: 'Приглашение удалено', + }; + }; + + public removeInvitation = async (teamId: string, code: string, email: string) => { + try { + const multi = this.redis.multi(); + multi.del(this.INVITES_KEY(code)); + multi.srem(this.TEAM_INVITES_KEY(teamId), code); + multi.srem(this.USER_INVITES_KEY(email.toLowerCase()), code); + await multi.exec(); + } catch { + throw new BaseException( + { + code: 'REDIS_TRANSACTION_FAILED', + message: 'Не удалось удалить приглашение из системы', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { success: true }; + }; + public invite = async (slug: string, inviterId: string, dto: InviteMemberDto) => { const team = await this.teamsRepo.findBySlug(slug); if (!team) { @@ -48,11 +280,10 @@ export class TeamInvitationsService { const code = generateSecret({ length: 8 }); - const INVITE_TTL = 86400; const now = new Date(); - const expiresAt = new Date(now.getTime() + INVITE_TTL * 1000); + const expiresAt = new Date(now.getTime() + this.INVITE_TTL * 1000); - const inviteData = { + const inviteData: TeamInvite = { teamId: team.id, teamName: team.name, teamAvatar: team.avatarUrl, @@ -66,9 +297,9 @@ export class TeamInvitationsService { try { const multi = this.redis.multi(); - multi.set(`inv:code:${code}`, JSON.stringify(inviteData), 'EX', INVITE_TTL); - multi.sadd(`team:invites:${team.id}`, code); - multi.sadd(`user:invites:${dto.email.toLowerCase()}`, code); + multi.set(this.INVITES_KEY(code), JSON.stringify(inviteData), 'EX', this.INVITE_TTL); + multi.sadd(this.TEAM_INVITES_KEY(team.id), code); + multi.sadd(this.USER_INVITES_KEY(dto.email.toLowerCase()), code); await multi.exec(); } catch (error) { throw new BaseException( @@ -83,12 +314,6 @@ export class TeamInvitationsService { const origins = this.cfg.get('CORS_ALLOWED_ORIGINS'); const FRONTEND_URL = origins[0]; - /** - * Человек кликает: ttopen.ru/invites/accept?code=... - * Фронт видит, что токена нет -> Редирект на /signup?inviteCode=... - * Юзер регистрируется. - * После успешного входа фронт видит inviteCode в URL или стейте и автоматом завершает процесс вступления. - */ const event = new TeamInvitationEvent( dto.email, team.name, @@ -110,7 +335,7 @@ export class TeamInvitationsService { }; public acceptInvite = async (code: string, userId: string, email: string) => { - const inviteRaw = await this.redis.get(`inv:code:${code}`); + const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); if (!inviteRaw) { throw new BaseException( { @@ -121,7 +346,7 @@ export class TeamInvitationsService { ); } - const invite = JSON.parse(inviteRaw); + const invite = this.parseInvite(inviteRaw); if (invite.email.toLowerCase() !== email.toLowerCase()) { throw new BaseException( @@ -167,11 +392,7 @@ export class TeamInvitationsService { joinedAt: new Date(), }); - const multi = this.redis.multi(); - multi.del(`inv:code:${code}`); - multi.srem(`team:invites:${invite.teamId}`, code); - multi.srem(`user:invites:${email.toLowerCase()}`, code); - await multi.exec(); + await this.removeInvitation(invite.teamId, code, email); return { success: true,