diff --git a/apps/api-v2/roadmap.md b/apps/api-v2/roadmap.md index da25a4a6..e4db495a 100644 --- a/apps/api-v2/roadmap.md +++ b/apps/api-v2/roadmap.md @@ -164,15 +164,15 @@ del /showcases/[showId] ## Members -get /members \ -post /members \ -get /members/[userId] \ -del /members/[userId] \ -put /members/[userId] \ - -get /members/[userId]/permissions \ -put /members/[userId]/permissions -> also needs to support adding new ones (upsert) \ -del /members/[userId]/permissions/[permId] +✅ get /members \ +✅ post /members \ +✅ get /members/[userId] \ +✅ del /members/[userId] \ +✅ put /members/[userId] \ + +✅ get /members/[userId]/permissions \ +✅ put /members/[userId]/permissions -> also needs to support adding new ones (upsert) \ +✅ del /members/[userId]/permissions/[permId] # Network API routes diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 72f4282d..bf5032d4 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -9,6 +9,7 @@ import { ApplicationsModule } from './sections/applications/applications.module' import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module'; import { AuthModule } from './sections/auth/auth.module'; import { ClaimsModule } from './sections/claims/claims.module'; +import { MembersModule } from './sections/members/members.module'; import { SocialsModule } from './sections/socials/socials.module'; import { StatusModule } from './sections/status/status.module'; import { UtilityModule } from './sections/utility/utility.module'; @@ -24,6 +25,7 @@ import { UtilityModule } from './sections/utility/utility.module'; AuthModule, ClaimsModule, ConfigModule.forRoot({ isGlobal: true, cache: true }), + MembersModule, QueueModule, SocialsModule, StatusModule, diff --git a/apps/api-v2/src/common/queue/jobs.ts b/apps/api-v2/src/common/queue/jobs.ts index de561ede..28140684 100644 --- a/apps/api-v2/src/common/queue/jobs.ts +++ b/apps/api-v2/src/common/queue/jobs.ts @@ -18,6 +18,8 @@ export enum WorkerJob { SendDiscordLog = 'SEND_DISCORD_LOG', /** Sends a Discord DM to one or more users. */ SendDiscordDm = 'SEND_DISCORD_DM', + /** Adds or removes the Discord builder role for a user. */ + SyncDiscordRoles = 'SYNC_DISCORD_ROLES', /** Asks the frontend to revalidate cached pages. */ RevalidateWebsite = 'REVALIDATE_WEBSITE', } @@ -31,6 +33,8 @@ export enum BuildTeamWebhookEvent { ClaimCreate = 'CLAIM_CREATE', ClaimUpdate = 'CLAIM_UPDATE', ClaimDelete = 'CLAIM_DELETE', + MemberAdd = 'MEMBER_ADD', + MemberRemove = 'MEMBER_REMOVE', } /** @@ -54,5 +58,6 @@ export interface WorkerJobPayloads { discordIds?: string[]; content: unknown; }; + [WorkerJob.SyncDiscordRoles]: { discordId: string; isBuilder: boolean }; [WorkerJob.RevalidateWebsite]: { paths?: string[]; tags?: string[] }; } diff --git a/apps/api-v2/src/sections/members/dto/member-ref.dto.ts b/apps/api-v2/src/sections/members/dto/member-ref.dto.ts new file mode 100644 index 00000000..fc5c0788 --- /dev/null +++ b/apps/api-v2/src/sections/members/dto/member-ref.dto.ts @@ -0,0 +1,41 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * How a user is named when adding them to a team. + * + * A build team usually knows a builder by their Minecraft name or their Discord + * account rather than by a BuildTheEarth ID, so all four are accepted. The + * fields are listed explicitly rather than passed through as a filter, so a + * caller cannot turn this into an arbitrary query over the user table. + */ +export class MemberRefDto { + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The BuildTheEarth user ID.', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The Keycloak ID of the user.', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + ssoId?: string; + + @ApiPropertyOptional({ example: '123456789012345678', description: 'The Discord ID of the user.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + discordId?: string; + + @ApiPropertyOptional({ example: 'Notch', description: 'The Minecraft name of the user.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + minecraft?: string; +} diff --git a/apps/api-v2/src/sections/members/dto/member.dto.ts b/apps/api-v2/src/sections/members/dto/member.dto.ts new file mode 100644 index 00000000..4268cc77 --- /dev/null +++ b/apps/api-v2/src/sections/members/dto/member.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class MemberDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the user.' }) + id: string; + + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The Keycloak ID of the user.' }) + ssoId: string; + + @ApiPropertyOptional({ example: '123456789012345678', description: 'The Discord ID of the user.' }) + discordId: string | null; + + @ApiPropertyOptional({ example: 'Notch', description: 'The Minecraft name of the user.' }) + minecraft: string | null; + + @ApiPropertyOptional({ example: 'notch', description: 'The username of the user.' }) + username: string | null; + + @ApiPropertyOptional({ example: 'https://example.com/avatar.png', description: 'The avatar of the user.' }) + avatar: string | null; +} + +export class PermissionDto { + @ApiProperty({ example: 'team.claim.list', description: 'The key of the permission.' }) + id: string; + + @ApiProperty({ example: 'May list the claims of a team.', description: 'What the permission allows.' }) + description: string; + + @ApiProperty({ example: false, description: 'Whether every user has this permission by default.' }) + defaultValue: boolean; + + @ApiProperty({ + example: false, + description: 'Whether the permission applies across the whole site rather than to a single team.', + }) + global: boolean; +} + +export class MemberPermissionDto { + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of this grant. Use it to revoke the permission again.', + }) + id: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the user it was granted to.', + }) + userId: string; + + @ApiProperty({ example: 'team.claim.list', description: 'The key of the granted permission.' }) + permissionId: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the build team the grant applies to.', + }) + buildTeamId: string | null; + + @ApiPropertyOptional({ type: PermissionDto, description: 'What the granted permission allows.' }) + permission?: PermissionDto; +} diff --git a/apps/api-v2/src/sections/members/dto/upsert.member-permission.dto.ts b/apps/api-v2/src/sections/members/dto/upsert.member-permission.dto.ts new file mode 100644 index 00000000..19ef98be --- /dev/null +++ b/apps/api-v2/src/sections/members/dto/upsert.member-permission.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * A single entry of a bulk permission grant. Grants the member already has are + * left as they are, so sending the same payload twice changes nothing. + */ +export class UpsertMemberPermissionDto { + @ApiProperty({ + example: 'team.claim.list', + description: 'The key of the permission to grant. Global permissions cannot be granted by a team.', + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + permissionId: string; +} diff --git a/apps/api-v2/src/sections/members/members.controller.ts b/apps/api-v2/src/sections/members/members.controller.ts new file mode 100644 index 00000000..4346fe15 --- /dev/null +++ b/apps/api-v2/src/sections/members/members.controller.ts @@ -0,0 +1,207 @@ +import { Body, Controller, Delete, Get, Param, ParseArrayPipe, Post, Put } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { TeamScope } from 'src/common/decorators/team-scope.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { MemberRefDto } from './dto/member-ref.dto'; +import { MemberDto, MemberPermissionDto } from './dto/member.dto'; +import { UpsertMemberPermissionDto } from './dto/upsert.member-permission.dto'; +import { MAX_BULK_PERMISSIONS, MembersService } from './members.service'; + +/** + * Every route is registered twice: once bare, and once behind a `:teamId` + * prefix, so a caller that already carries the team id in its URLs can keep it + * there. The controller therefore has no prefix of its own, since a Nest + * controller prefix cannot be made optional. + * + * Nothing here is public. A member list is a list of people, with their Discord + * and Minecraft accounts attached, so every route is scoped to the team the + * token belongs to. See TeamScope. + */ +@Controller() +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + /** + * Returns the members of the currently authenticated team. + */ + @Get(['members', ':teamId/members']) + @ApiBearerAuth() + @Paginated() + @Sortable({ + defaultSortBy: 'username', + allowedFields: ['username', 'minecraft', 'discordId', 'id'], + defaultOrder: 'asc', + }) + @ApiOperation({ + summary: 'Get Members', + description: 'Returns the members of the currently authenticated team.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @Filtered({ + fields: [ + { name: 'username', required: false, type: String }, + { name: 'minecraft', required: false, type: String }, + { name: 'discordId', required: false, type: String }, + ], + }) + @ApiPaginatedResponseDto(MemberDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async getMembers( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @TeamScope() buildTeamId: string, + ): PaginatedControllerResponse { + return await this.membersService.findAll(buildTeamId, pagination, sorting.sortBy, sorting.order, filter.filter); + } + + /** + * Adds a user to the currently authenticated team. + */ + @Post(['members', ':teamId/members']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Add Member', + description: + 'Adds a user to the currently authenticated team. The user can be named by BuildTheEarth ID, Keycloak ID, Discord ID or Minecraft name.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberDto, { status: 201, description: 'Member added successfully.' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'User not found' }) + async addMember(@Body() memberRefDto: MemberRefDto, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.membersService.create(memberRefDto, buildTeamId); + } + + /** + * Returns a single member of the currently authenticated team. + */ + @Get(['members/:userId', ':teamId/members/:userId']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get Member', + description: 'Returns the member with the given user ID, if they belong to the currently authenticated team.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Member not found' }) + async getMember(@Param('userId') userId: string, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.membersService.findOne(userId, buildTeamId); + } + + /** + * Makes sure the user with the given ID is a member of the currently + * authenticated team. + */ + @Put(['members/:userId', ':teamId/members/:userId']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Add Member by ID', + description: + 'Makes sure the user with the given ID is a member of the currently authenticated team, and answers the member either way. A user row is shared by every team, so there is nothing about it a single team may edit; this route puts the membership itself, which is idempotent and safe for a tool that syncs its roster repeatedly.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'User not found' }) + async putMember(@Param('userId') userId: string, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.membersService.add(userId, buildTeamId); + } + + /** + * Removes a member from the currently authenticated team. + */ + @Delete(['members/:userId', ':teamId/members/:userId']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Remove Member', + description: + 'Removes the member from the currently authenticated team, along with the permissions this team gave them. The user account itself is untouched.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberDto, { description: 'Member removed successfully.' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Member not found' }) + async deleteMember(@Param('userId') userId: string, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.membersService.delete(userId, buildTeamId); + } + + /** + * Returns the permissions a member holds for the currently authenticated team. + */ + @Get(['members/:userId/permissions', ':teamId/members/:userId/permissions']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get Member Permissions', + description: + 'Returns the permissions the member holds for the currently authenticated team. Permissions they hold globally, or through another team, are not included.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberPermissionDto, { isArray: true, description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Member not found' }) + async getMemberPermissions(@Param('userId') userId: string, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.membersService.findAllPermissions(userId, buildTeamId); + } + + /** + * Grants permissions to a member of the currently authenticated team. + */ + @Put(['members/:userId/permissions', ':teamId/members/:userId/permissions']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Grant Member Permissions', + description: `Grants the given permissions to the member, for the currently authenticated team only. Permissions the member already holds are left as they are, and ones that are not part of the payload are left alone, so sending the same payload twice changes nothing. At most ${MAX_BULK_PERMISSIONS} permissions can be sent at once, and a team cannot grant a global permission.`, + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiBody({ type: [UpsertMemberPermissionDto] }) + @ApiDefaultResponse(MemberPermissionDto, { isArray: true, description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 403, description: 'Global permissions cannot be granted by a team' }) + @ApiErrorResponse({ status: 404, description: 'Member or permission not found' }) + async upsertMemberPermissions( + @Param('userId') userId: string, + @Body(new ParseArrayPipe({ items: UpsertMemberPermissionDto, whitelist: true, forbidNonWhitelisted: true })) + upsertMemberPermissionDtos: UpsertMemberPermissionDto[], + @TeamScope() buildTeamId: string, + ): ControllerResponse { + return await this.membersService.upsertPermissions(userId, upsertMemberPermissionDtos, buildTeamId); + } + + /** + * Revokes a single permission from a member of the currently authenticated team. + */ + @Delete(['members/:userId/permissions/:permissionId', ':teamId/members/:userId/permissions/:permissionId']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Revoke Member Permission', + description: + 'Revokes one permission grant from the member. The ID is the one the permissions listing returns for the grant, not the key of the permission, so a team can only ever revoke what it granted itself.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(MemberPermissionDto, { description: 'Permission revoked successfully.' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Member or permission not found' }) + async deleteMemberPermission( + @Param('userId') userId: string, + @Param('permissionId') permissionId: string, + @TeamScope() buildTeamId: string, + ): ControllerResponse { + return await this.membersService.deletePermission(userId, permissionId, buildTeamId); + } +} diff --git a/apps/api-v2/src/sections/members/members.module.ts b/apps/api-v2/src/sections/members/members.module.ts new file mode 100644 index 00000000..7db5c9ab --- /dev/null +++ b/apps/api-v2/src/sections/members/members.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; + +@Module({ + controllers: [MembersController], + providers: [MembersService, PrismaService], +}) +export class MembersModule {} diff --git a/apps/api-v2/src/sections/members/members.service.ts b/apps/api-v2/src/sections/members/members.service.ts new file mode 100644 index 00000000..21e7cec7 --- /dev/null +++ b/apps/api-v2/src/sections/members/members.service.ts @@ -0,0 +1,376 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { BuildTeamWebhookEvent, WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { MemberRefDto } from './dto/member-ref.dto'; +import { UpsertMemberPermissionDto } from './dto/upsert.member-permission.dto'; + +/** Upper bound for a single bulk grant, so one request cannot hold the table. */ +export const MAX_BULK_PERMISSIONS = 100; + +/** + * The user columns a member is described by. A user row also carries what other + * teams know about them, so the selection is explicit rather than the whole row. + */ +const MEMBER_SELECT = { + id: true, + ssoId: true, + discordId: true, + minecraft: true, + username: true, + avatar: true, +} as const; + +/** + * The frontend pages that list a team's members. Kept in step with the team + * pages BuildTeams revalidates. + */ +const MEMBER_PAGES = ['/teams/[team]', '/teams/[team]/manage/members']; + +@Injectable() +export class MembersService { + constructor( + private readonly prisma: PrismaService, + private readonly queue: QueueService, + ) {} + + /** + * Finds the members of a team based on pagination, sorting and filtering + * parameters. + * @param buildTeamId ID of the team whose members to list. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @param filter Filter parameters. + * @returns A paginated response containing the members and metadata. + */ + async findAll( + buildTeamId: string, + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + ) { + const where = { + ...filter, + joinedBuildTeams: { some: { id: buildTeamId } }, + }; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const [members, count] = await Promise.all([ + this.prisma.user.findMany({ + where, + orderBy: { [sortBy || 'username']: order === 'desc' ? 'desc' : 'asc' }, + skip, + take, + select: MEMBER_SELECT, + }), + this.prisma.user.count({ where }), + ]); + + return { + data: members, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Finds a single member of the given team. + * @param userId ID of the user. + * @param buildTeamId ID of the team they have to be a member of. + * @returns The member. + * @throws NotFoundException if the user is not a member of the team. + */ + async findOne(userId: string, buildTeamId: string) { + const member = await this.prisma.user.findFirst({ + where: { id: userId, joinedBuildTeams: { some: { id: buildTeamId } } }, + select: MEMBER_SELECT, + }); + + if (!member) { + throw new NotFoundException('Member not found'); + } + + return member; + } + + /** + * Adds the user a caller named to the team. + * @param ref How the user is named. + * @param buildTeamId ID of the team to add them to. + * @returns The member. + * @throws BadRequestException if the reference names no field. + * @throws NotFoundException if no matching user exists. + */ + async create(ref: MemberRefDto, buildTeamId: string) { + const user = await this.resolveUser(ref); + + return await this.add(user.id, buildTeamId); + } + + /** + * Adds the user with the given ID to the team. + * + * Idempotent: a user who is already a member stays one and the response is the + * same, which is what makes this safe for a tool that syncs its roster + * repeatedly. + * @param userId ID of the user to add. + * @param buildTeamId ID of the team to add them to. + * @returns The member. + * @throws NotFoundException if no user with that ID exists. + */ + async add(userId: string, buildTeamId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, discordId: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const member = await this.prisma.user.update({ + where: { id: user.id }, + data: { joinedBuildTeams: { connect: { id: buildTeamId } } }, + select: MEMBER_SELECT, + }); + + // Being in any build team is what makes someone a builder on Discord, and + // they are certainly in one now. + await this.announce(BuildTeamWebhookEvent.MemberAdd, member, true, buildTeamId); + + return member; + } + + /** + * Removes a member from the team, along with the permissions they only had + * there. + * @param userId ID of the user to remove. + * @param buildTeamId ID of the team to remove them from. + * @returns The removed member. + * @throws NotFoundException if the user is not a member of the team. + */ + async delete(userId: string, buildTeamId: string) { + const member = await this.findOne(userId, buildTeamId); + + await this.prisma.$transaction([ + this.prisma.userPermission.deleteMany({ where: { userId: member.id, buildTeamId } }), + this.prisma.user.update({ + where: { id: member.id }, + data: { joinedBuildTeams: { disconnect: { id: buildTeamId } } }, + }), + ]); + + // Their Discord builder role only goes away if this was the last team they + // were in; leaving one team does not stop them building in another. + const remaining = await this.prisma.buildTeam.count({ where: { members: { some: { id: member.id } } } }); + + await this.announce(BuildTeamWebhookEvent.MemberRemove, member, remaining > 0, buildTeamId); + + return member; + } + + /** + * Lists the permissions a member holds for the given team. + * + * Only the grants that belong to this team: a user's global permissions, and + * the ones another team gave them, are none of this team's business. + * @param userId ID of the member. + * @param buildTeamId ID of the team the grants belong to. + * @returns The member's permissions for this team. + * @throws NotFoundException if the user is not a member of the team. + */ + async findAllPermissions(userId: string, buildTeamId: string) { + const member = await this.findOne(userId, buildTeamId); + + return await this.prisma.userPermission.findMany({ + where: { userId: member.id, buildTeamId }, + include: { permission: true }, + }); + } + + /** + * Grants the given permissions to a member of the team. + * + * Grants the member already has are left alone, and grants that are not part of + * the payload are left alone too, so this only ever adds. Revoking is what + * `DELETE /members/:userId/permissions/:permissionId` is for. + * @param userId ID of the member. + * @param dtos The permissions to grant. + * @param buildTeamId ID of the team granting them. + * @returns Every permission the member holds for this team afterwards. + * @throws BadRequestException if more than MAX_BULK_PERMISSIONS are sent at once. + * @throws NotFoundException if the user is not a member, or a permission does not exist. + * @throws ForbiddenException if a permission is global. + */ + async upsertPermissions(userId: string, dtos: UpsertMemberPermissionDto[], buildTeamId: string) { + if (dtos.length > MAX_BULK_PERMISSIONS) { + throw new BadRequestException(`Cannot grant more than ${MAX_BULK_PERMISSIONS} permissions at once`); + } + + const member = await this.findOne(userId, buildTeamId); + const permissionIds = [...new Set(dtos.map((dto) => dto.permissionId))]; + + const permissions = await this.prisma.permisision.findMany({ + where: { id: { in: permissionIds } }, + select: { id: true, global: true }, + }); + + const missing = permissionIds.filter((id) => !permissions.some((permission) => permission.id === id)); + + if (missing.length > 0) { + throw new NotFoundException(`Unknown permission: ${missing.join(', ')}`); + } + + // A team may only hand out permissions that are scoped to a team. A global + // one would apply everywhere, which is how a team could grant itself rights + // over the whole site. + const global = permissions.filter((permission) => permission.global); + + if (global.length > 0) { + throw new ForbiddenException( + `A build team cannot grant global permissions: ${global.map((permission) => permission.id).join(', ')}`, + ); + } + + const existing = await this.prisma.userPermission.findMany({ + where: { userId: member.id, buildTeamId, permissionId: { in: permissionIds } }, + select: { permissionId: true }, + }); + + const held = new Set(existing.map((grant) => grant.permissionId)); + const toGrant = permissionIds.filter((id) => !held.has(id)); + + if (toGrant.length > 0) { + await this.prisma.userPermission.createMany({ + data: toGrant.map((permissionId) => ({ userId: member.id, buildTeamId, permissionId })), + }); + } + + return await this.prisma.userPermission.findMany({ + where: { userId: member.id, buildTeamId }, + include: { permission: true }, + }); + } + + /** + * Revokes a single permission grant. + * @param userId ID of the member. + * @param grantId ID of the grant, as returned by the permissions listing. + * @param buildTeamId ID of the team the grant has to belong to. + * @returns The revoked grant. + * @throws NotFoundException if the user is not a member, or the grant does not + * exist or belongs to another team. + */ + async deletePermission(userId: string, grantId: string, buildTeamId: string) { + const member = await this.findOne(userId, buildTeamId); + + // Matching on the team as well as on the grant is what stops a team revoking + // a grant another team, or the site itself, gave this user. + const grant = await this.prisma.userPermission.findFirst({ + where: { id: grantId, userId: member.id, buildTeamId }, + include: { permission: true }, + }); + + if (!grant) { + throw new NotFoundException('Permission not found'); + } + + await this.prisma.userPermission.delete({ where: { id: grant.id } }); + + return grant; + } + + /** + * Tells the outside world that a team's roster changed, without making the + * request wait for any of it: the team's own webhook, the builder role on + * Discord, and the team pages that list members. + * @param event Which side of the change this is. + * @param member The member it happened to. + * @param isBuilder Whether they are still in at least one build team. + * @param buildTeamId ID of the team whose roster and pages changed. + */ + private async announce( + event: BuildTeamWebhookEvent, + member: { + id: string; + username: string | null; + discordId: string | null; + minecraft: string | null; + avatar: string | null; + }, + isBuilder: boolean, + buildTeamId: string, + ) { + const team = await this.prisma.buildTeam.findUnique({ + where: { id: buildTeamId }, + select: { slug: true }, + }); + + await Promise.all([ + this.queue.dispatch(WorkerJob.BuildTeamWebhook, { + type: event, + // Listed field by field rather than spread: the member was selected with + // ssoId on it, which is the Keycloak account behind the person and has + // no business leaving this service. + data: { + id: member.id, + username: member.username, + discordId: member.discordId, + minecraft: member.minecraft, + avatar: member.avatar, + buildTeamId, + }, + destination: [{ id: buildTeamId }], + }), + ...(member.discordId + ? [this.queue.dispatch(WorkerJob.SyncDiscordRoles, { discordId: member.discordId, isBuilder })] + : []), + ...(team?.slug + ? [ + this.queue.dispatch(WorkerJob.RevalidateWebsite, { + paths: MEMBER_PAGES.map((page) => page.replace('[team]', team.slug)), + }), + ] + : []), + ]); + } + + /** + * Resolves the user a caller named by ID, Keycloak ID, Discord ID or Minecraft + * name. + * @throws BadRequestException if the reference names no field at all. + * @throws NotFoundException if no matching user exists. + */ + private async resolveUser(ref: MemberRefDto) { + const where: Prisma.UserWhereInput = {}; + + if (ref.id) where.id = ref.id; + if (ref.ssoId) where.ssoId = ref.ssoId; + if (ref.discordId) where.discordId = ref.discordId; + if (ref.minecraft) where.minecraft = ref.minecraft; + + if (Object.keys(where).length === 0) { + throw new BadRequestException('A user has to be named by id, ssoId, discordId or minecraft'); + } + + const user = await this.prisma.user.findFirst({ where, select: { id: true } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user; + } +} diff --git a/apps/api-v2/test/sections/members/members.controller.spec.ts b/apps/api-v2/test/sections/members/members.controller.spec.ts new file mode 100644 index 00000000..6551211b --- /dev/null +++ b/apps/api-v2/test/sections/members/members.controller.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MembersController } from 'src/sections/members/members.controller'; +import { MembersService } from 'src/sections/members/members.service'; + +describe('MembersController', () => { + let membersController: MembersController; + let membersService: { + findAll: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + add: jest.Mock; + delete: jest.Mock; + findAllPermissions: jest.Mock; + upsertPermissions: jest.Mock; + deletePermission: jest.Mock; + }; + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'username', order: 'asc' }; + + beforeEach(async () => { + membersService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + findAllPermissions: jest.fn(), + upsertPermissions: jest.fn(), + deletePermission: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [MembersController], + providers: [{ provide: MembersService, useValue: membersService }], + }).compile(); + + membersController = module.get(MembersController); + }); + + it('should list the members of the authenticated team', async () => { + membersService.findAll.mockResolvedValue({ data: [], meta: {} }); + + await membersController.getMembers( + pagination as never, + sorting as never, + { filter: { minecraft: 'Notch' } } as never, + 'team-123', + ); + + expect(membersService.findAll).toHaveBeenCalledWith('team-123', pagination, 'username', 'asc', { + minecraft: 'Notch', + }); + }); + + it('should add a member by reference', async () => { + membersService.create.mockResolvedValue({ id: 'user-1' }); + + const result = await membersController.addMember({ minecraft: 'Notch' }, 'team-123'); + + expect(membersService.create).toHaveBeenCalledWith({ minecraft: 'Notch' }, 'team-123'); + expect(result).toEqual({ id: 'user-1' }); + }); + + it('should return a single member', async () => { + membersService.findOne.mockResolvedValue({ id: 'user-1' }); + + await membersController.getMember('user-1', 'team-123'); + + expect(membersService.findOne).toHaveBeenCalledWith('user-1', 'team-123'); + }); + + it('should put a membership by user id', async () => { + membersService.add.mockResolvedValue({ id: 'user-1' }); + + await membersController.putMember('user-1', 'team-123'); + + expect(membersService.add).toHaveBeenCalledWith('user-1', 'team-123'); + }); + + it('should remove a member', async () => { + membersService.delete.mockResolvedValue({ id: 'user-1' }); + + await membersController.deleteMember('user-1', 'team-123'); + + expect(membersService.delete).toHaveBeenCalledWith('user-1', 'team-123'); + }); + + it('should list a member permissions', async () => { + membersService.findAllPermissions.mockResolvedValue([]); + + await membersController.getMemberPermissions('user-1', 'team-123'); + + expect(membersService.findAllPermissions).toHaveBeenCalledWith('user-1', 'team-123'); + }); + + it('should grant permissions to a member', async () => { + membersService.upsertPermissions.mockResolvedValue([]); + + const dtos = [{ permissionId: 'team.claim.list' }]; + await membersController.upsertMemberPermissions('user-1', dtos, 'team-123'); + + expect(membersService.upsertPermissions).toHaveBeenCalledWith('user-1', dtos, 'team-123'); + }); + + it('should revoke a single grant', async () => { + membersService.deletePermission.mockResolvedValue({ id: 'grant-1' }); + + await membersController.deleteMemberPermission('user-1', 'grant-1', 'team-123'); + + expect(membersService.deletePermission).toHaveBeenCalledWith('user-1', 'grant-1', 'team-123'); + }); +}); diff --git a/apps/api-v2/test/sections/members/members.routes.spec.ts b/apps/api-v2/test/sections/members/members.routes.spec.ts new file mode 100644 index 00000000..04dcf542 --- /dev/null +++ b/apps/api-v2/test/sections/members/members.routes.spec.ts @@ -0,0 +1,284 @@ +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +/** + * Nothing about a member is public, and a team may only ever reach its own + * roster and the permissions it granted itself. Both of those only hold once the + * guard, the team scope and the real router are all involved, so they are + * checked end to end here. + */ +describe('member routes', () => { + let app: INestApplication; + let token: string; + let prismaService: { + $connect: jest.Mock; + $transaction: jest.Mock; + user: { findMany: jest.Mock; findFirst: jest.Mock; findUnique: jest.Mock; count: jest.Mock; update: jest.Mock }; + buildTeam: { findUnique: jest.Mock; count: jest.Mock }; + userPermission: { + findMany: jest.Mock; + findFirst: jest.Mock; + createMany: jest.Mock; + delete: jest.Mock; + deleteMany: jest.Mock; + }; + permisision: { findMany: jest.Mock }; + }; + + const member = { + id: 'user-1', + ssoId: 'sso-1', + discordId: '123', + minecraft: 'Notch', + username: 'notch', + avatar: null, + }; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + prismaService = { + $connect: jest.fn(), + $transaction: jest.fn().mockResolvedValue([]), + user: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }, + buildTeam: { findUnique: jest.fn(), count: jest.fn() }, + userPermission: { + findMany: jest.fn(), + findFirst: jest.fn(), + createMany: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + permisision: { findMany: jest.fn() }, + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + .useValue(prismaService) + .compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '2' }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); + await app.init(); + + token = await app.get(JwtService).signAsync({ sub: 'team-123', id: 'team-123' }); + }); + + afterAll(async () => { + await app.close(); + delete process.env.JWT_SECRET; + }); + + beforeEach(() => { + jest.clearAllMocks(); + prismaService.user.findMany.mockResolvedValue([member]); + prismaService.user.count.mockResolvedValue(1); + prismaService.user.findFirst.mockResolvedValue(member); + prismaService.user.findUnique.mockResolvedValue({ id: 'user-1', discordId: '123' }); + prismaService.user.update.mockResolvedValue(member); + prismaService.buildTeam.findUnique.mockResolvedValue({ slug: 'my-team' }); + prismaService.buildTeam.count.mockResolvedValue(0); + prismaService.userPermission.findMany.mockResolvedValue([]); + }); + + it.each([ + ['get', '/v2/members'], + ['get', '/v2/members/user-1'], + ['get', '/v2/members/user-1/permissions'], + ['post', '/v2/members'], + ['put', '/v2/members/user-1'], + ['delete', '/v2/members/user-1'], + ])('rejects %s %s without a token', async (method, path) => { + await request(app.getHttpServer())[method as 'get'](path).expect(401); + + expect(prismaService.user.findMany).not.toHaveBeenCalled(); + expect(prismaService.user.update).not.toHaveBeenCalled(); + }); + + it('lists only the members of the authenticated team', async () => { + const response = await request(app.getHttpServer()) + .get('/v2/members') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { joinedBuildTeams: { some: { id: 'team-123' } } } }), + ); + expect(response.body).toEqual({ + status: 200, + message: 'Success', + data: [member], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('rejects an unlisted sortBy', async () => { + await request(app.getHttpServer()) + .get('/v2/members?sortBy=ssoId') + .set('Authorization', `Bearer ${token}`) + .expect(400); + }); + + it('refuses a prefix naming a team the token does not belong to', async () => { + await request(app.getHttpServer()) + .get('/v2/someone-else/members') + .set('Authorization', `Bearer ${token}`) + .expect(404); + + expect(prismaService.user.findMany).not.toHaveBeenCalled(); + }); + + it('adds a member named by Minecraft name', async () => { + const response = await request(app.getHttpServer()) + .post('/v2/team-123/members') + .set('Authorization', `Bearer ${token}`) + .send({ minecraft: 'Notch' }) + .expect(201); + + expect(prismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { joinedBuildTeams: { connect: { id: 'team-123' } } } }), + ); + expect(response.body.data).toEqual(member); + }); + + it('rejects an add that names no user', async () => { + await request(app.getHttpServer()).post('/v2/members').set('Authorization', `Bearer ${token}`).send({}).expect(400); + + expect(prismaService.user.update).not.toHaveBeenCalled(); + }); + + it('rejects an add with an unknown field', async () => { + await request(app.getHttpServer()) + .post('/v2/members') + .set('Authorization', `Bearer ${token}`) + .send({ minecraft: 'Notch', permissions: ['admin.everything'] }) + .expect(400); + }); + + it('puts a membership by user id', async () => { + await request(app.getHttpServer()).put('/v2/members/user-1').set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { joinedBuildTeams: { connect: { id: 'team-123' } } } }), + ); + }); + + it('removes a member', async () => { + await request(app.getHttpServer()).delete('/v2/members/user-1').set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService.userPermission.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', buildTeamId: 'team-123' }, + }); + }); + + it('answers 404 for someone who is not a member', async () => { + prismaService.user.findFirst.mockResolvedValue(null); + + await request(app.getHttpServer()).get('/v2/members/stranger').set('Authorization', `Bearer ${token}`).expect(404); + }); + + describe('permissions', () => { + it('lists only the grants that belong to this team', async () => { + prismaService.userPermission.findMany.mockResolvedValue([{ id: 'grant-1' }]); + + const response = await request(app.getHttpServer()) + .get('/v2/members/user-1/permissions') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.userPermission.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', buildTeamId: 'team-123' }, + include: { permission: true }, + }); + expect(response.body.data).toEqual([{ id: 'grant-1' }]); + }); + + it('grants a permission scoped to this team', async () => { + prismaService.permisision.findMany.mockResolvedValue([{ id: 'team.claim.list', global: false }]); + + await request(app.getHttpServer()) + .put('/v2/members/user-1/permissions') + .set('Authorization', `Bearer ${token}`) + .send([{ permissionId: 'team.claim.list' }]) + .expect(200); + + expect(prismaService.userPermission.createMany).toHaveBeenCalledWith({ + data: [{ userId: 'user-1', buildTeamId: 'team-123', permissionId: 'team.claim.list' }], + }); + }); + + it('refuses to let a team grant a global permission', async () => { + prismaService.permisision.findMany.mockResolvedValue([{ id: 'admin.everything', global: true }]); + + await request(app.getHttpServer()) + .put('/v2/members/user-1/permissions') + .set('Authorization', `Bearer ${token}`) + .send([{ permissionId: 'admin.everything' }]) + .expect(403); + + expect(prismaService.userPermission.createMany).not.toHaveBeenCalled(); + }); + + it('rejects a grant payload that is not an array', async () => { + await request(app.getHttpServer()) + .put('/v2/members/user-1/permissions') + .set('Authorization', `Bearer ${token}`) + .send({ permissionId: 'team.claim.list' }) + .expect(400); + }); + + it('revokes a grant of this team', async () => { + prismaService.userPermission.findFirst.mockResolvedValue({ id: 'grant-1' }); + + await request(app.getHttpServer()) + .delete('/v2/members/user-1/permissions/grant-1') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.userPermission.delete).toHaveBeenCalledWith({ where: { id: 'grant-1' } }); + }); + + it('answers 404 for a grant that belongs to another team', async () => { + prismaService.userPermission.findFirst.mockResolvedValue(null); + + await request(app.getHttpServer()) + .delete('/v2/members/user-1/permissions/someone-elses-grant') + .set('Authorization', `Bearer ${token}`) + .expect(404); + + expect(prismaService.userPermission.delete).not.toHaveBeenCalled(); + }); + + it('routes the permissions path to the permissions handler, not to a member id', async () => { + await request(app.getHttpServer()) + .get('/v2/team-123/members/user-1/permissions') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.userPermission.findMany).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api-v2/test/sections/members/members.service.spec.ts b/apps/api-v2/test/sections/members/members.service.spec.ts new file mode 100644 index 00000000..dcbce084 --- /dev/null +++ b/apps/api-v2/test/sections/members/members.service.spec.ts @@ -0,0 +1,361 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { BuildTeamWebhookEvent, WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { MAX_BULK_PERMISSIONS, MembersService } from 'src/sections/members/members.service'; + +describe('MembersService', () => { + let membersService: MembersService; + let queueService: { dispatch: jest.Mock; dispatchAll: jest.Mock }; + let prismaService: { + $transaction: jest.Mock; + user: { findMany: jest.Mock; findFirst: jest.Mock; findUnique: jest.Mock; count: jest.Mock; update: jest.Mock }; + buildTeam: { findUnique: jest.Mock; count: jest.Mock }; + userPermission: { + findMany: jest.Mock; + findFirst: jest.Mock; + createMany: jest.Mock; + delete: jest.Mock; + deleteMany: jest.Mock; + }; + permisision: { findMany: jest.Mock }; + }; + + const member = { + id: 'user-1', + ssoId: 'sso-1', + discordId: '123', + minecraft: 'Notch', + username: 'notch', + avatar: null, + }; + + beforeEach(() => { + prismaService = { + $transaction: jest.fn().mockResolvedValue([]), + user: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }, + buildTeam: { findUnique: jest.fn().mockResolvedValue({ slug: 'my-team' }), count: jest.fn() }, + userPermission: { + findMany: jest.fn(), + findFirst: jest.fn(), + createMany: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + permisision: { findMany: jest.fn() }, + }; + queueService = { dispatch: jest.fn().mockResolvedValue(true), dispatchAll: jest.fn().mockResolvedValue(0) }; + + membersService = new MembersService( + prismaService as unknown as PrismaService, + queueService as unknown as QueueService, + ); + }); + + describe('findAll', () => { + it('should only list users who joined the given team', async () => { + prismaService.user.findMany.mockResolvedValue([member]); + prismaService.user.count.mockResolvedValue(1); + + const result = await membersService.findAll('team-123', { page: 1, limit: 20 }); + + expect(prismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { joinedBuildTeams: { some: { id: 'team-123' } } }, + orderBy: { username: 'asc' }, + }), + ); + expect(result.meta).toEqual({ page: 1, perPage: 20, totalItems: 1, totalPages: 1 }); + }); + + it('should keep the team constraint even when a filter is given', async () => { + prismaService.user.findMany.mockResolvedValue([]); + prismaService.user.count.mockResolvedValue(0); + + await membersService.findAll('team-123', { page: 1, limit: 20 }, 'minecraft', 'desc', { minecraft: 'Notch' }); + + expect(prismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { minecraft: 'Notch', joinedBuildTeams: { some: { id: 'team-123' } } }, + orderBy: { minecraft: 'desc' }, + }), + ); + }); + }); + + describe('findOne', () => { + it('should look the member up within the team', async () => { + prismaService.user.findFirst.mockResolvedValue(member); + + await membersService.findOne('user-1', 'team-123'); + + expect(prismaService.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'user-1', joinedBuildTeams: { some: { id: 'team-123' } } }, + }), + ); + }); + + it('should throw when the user is not a member of the team', async () => { + prismaService.user.findFirst.mockResolvedValue(null); + + await expect(membersService.findOne('user-1', 'team-123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('add', () => { + beforeEach(() => { + prismaService.user.findUnique.mockResolvedValue({ id: 'user-1', discordId: '123' }); + prismaService.user.update.mockResolvedValue(member); + }); + + it('should connect the user to the team', async () => { + await membersService.add('user-1', 'team-123'); + + expect(prismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'user-1' }, + data: { joinedBuildTeams: { connect: { id: 'team-123' } } }, + }), + ); + }); + + it('should give the member the Discord builder role', async () => { + await membersService.add('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncDiscordRoles, { + discordId: '123', + isBuilder: true, + }); + }); + + it('should revalidate the pages that list members', async () => { + await membersService.add('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.RevalidateWebsite, { + paths: ['/teams/my-team', '/teams/my-team/manage/members'], + }); + }); + + it('should deliver a MEMBER_ADD event to the team webhook', async () => { + await membersService.add('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.BuildTeamWebhook, { + type: BuildTeamWebhookEvent.MemberAdd, + data: expect.objectContaining({ id: 'user-1', minecraft: 'Notch', buildTeamId: 'team-123' }), + destination: [{ id: 'team-123' }], + }); + }); + + it('should not put the member’s Keycloak account in the webhook payload', async () => { + await membersService.add('user-1', 'team-123'); + + const [, payload] = queueService.dispatch.mock.calls.find(([job]) => job === WorkerJob.BuildTeamWebhook) as [ + string, + { data: Record }, + ]; + expect(payload.data).not.toHaveProperty('ssoId'); + }); + + it('should skip the Discord sync for a member with no linked account', async () => { + prismaService.user.update.mockResolvedValue({ ...member, discordId: null }); + + await membersService.add('user-1', 'team-123'); + + expect(queueService.dispatch).not.toHaveBeenCalledWith(WorkerJob.SyncDiscordRoles, expect.anything()); + }); + + it('should throw when the user does not exist', async () => { + prismaService.user.findUnique.mockResolvedValue(null); + + await expect(membersService.add('nobody', 'team-123')).rejects.toThrow(NotFoundException); + expect(prismaService.user.update).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('should resolve the user by Minecraft name before adding them', async () => { + prismaService.user.findFirst.mockResolvedValue({ id: 'user-1' }); + prismaService.user.findUnique.mockResolvedValue({ id: 'user-1', discordId: '123' }); + prismaService.user.update.mockResolvedValue(member); + + await membersService.create({ minecraft: 'Notch' }, 'team-123'); + + expect(prismaService.user.findFirst).toHaveBeenCalledWith({ + where: { minecraft: 'Notch' }, + select: { id: true }, + }); + expect(prismaService.user.update).toHaveBeenCalled(); + }); + + it('should refuse a reference that names no field', async () => { + await expect(membersService.create({}, 'team-123')).rejects.toThrow(BadRequestException); + }); + + it('should throw when no user matches', async () => { + prismaService.user.findFirst.mockResolvedValue(null); + + await expect(membersService.create({ minecraft: 'Nobody' }, 'team-123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + beforeEach(() => { + prismaService.user.findFirst.mockResolvedValue(member); + }); + + it('should disconnect the member and drop the permissions this team gave them', async () => { + prismaService.buildTeam.count.mockResolvedValue(0); + + await membersService.delete('user-1', 'team-123'); + + expect(prismaService.userPermission.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', buildTeamId: 'team-123' }, + }); + expect(prismaService.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { joinedBuildTeams: { disconnect: { id: 'team-123' } } } }), + ); + expect(prismaService.$transaction).toHaveBeenCalled(); + }); + + it('should deliver a MEMBER_REMOVE event to the team webhook', async () => { + prismaService.buildTeam.count.mockResolvedValue(0); + + await membersService.delete('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith( + WorkerJob.BuildTeamWebhook, + expect.objectContaining({ type: BuildTeamWebhookEvent.MemberRemove }), + ); + }); + + it('should take the Discord builder role away when that was their last team', async () => { + prismaService.buildTeam.count.mockResolvedValue(0); + + await membersService.delete('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncDiscordRoles, { + discordId: '123', + isBuilder: false, + }); + }); + + it('should leave the Discord builder role alone while they are still in another team', async () => { + prismaService.buildTeam.count.mockResolvedValue(1); + + await membersService.delete('user-1', 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncDiscordRoles, { + discordId: '123', + isBuilder: true, + }); + }); + + it('should throw when the user is not a member of the team', async () => { + prismaService.user.findFirst.mockResolvedValue(null); + + await expect(membersService.delete('user-1', 'team-123')).rejects.toThrow(NotFoundException); + expect(prismaService.$transaction).not.toHaveBeenCalled(); + }); + }); + + describe('permissions', () => { + beforeEach(() => { + prismaService.user.findFirst.mockResolvedValue(member); + prismaService.userPermission.findMany.mockResolvedValue([]); + }); + + it('should only list the grants that belong to this team', async () => { + await membersService.findAllPermissions('user-1', 'team-123'); + + expect(prismaService.userPermission.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', buildTeamId: 'team-123' }, + include: { permission: true }, + }); + }); + + it('should grant the permissions the member does not have yet', async () => { + prismaService.permisision.findMany.mockResolvedValue([ + { id: 'team.claim.list', global: false }, + { id: 'team.members.edit', global: false }, + ]); + prismaService.userPermission.findMany + .mockResolvedValueOnce([{ permissionId: 'team.claim.list' }]) + .mockResolvedValueOnce([{ id: 'grant-1' }]); + + await membersService.upsertPermissions( + 'user-1', + [{ permissionId: 'team.claim.list' }, { permissionId: 'team.members.edit' }], + 'team-123', + ); + + expect(prismaService.userPermission.createMany).toHaveBeenCalledWith({ + data: [{ userId: 'user-1', buildTeamId: 'team-123', permissionId: 'team.members.edit' }], + }); + }); + + it('should write nothing when the member already holds everything asked for', async () => { + prismaService.permisision.findMany.mockResolvedValue([{ id: 'team.claim.list', global: false }]); + prismaService.userPermission.findMany.mockResolvedValue([{ permissionId: 'team.claim.list' }]); + + await membersService.upsertPermissions('user-1', [{ permissionId: 'team.claim.list' }], 'team-123'); + + expect(prismaService.userPermission.createMany).not.toHaveBeenCalled(); + }); + + it('should refuse to let a team hand out a global permission', async () => { + prismaService.permisision.findMany.mockResolvedValue([{ id: 'admin.everything', global: true }]); + + await expect( + membersService.upsertPermissions('user-1', [{ permissionId: 'admin.everything' }], 'team-123'), + ).rejects.toThrow(ForbiddenException); + expect(prismaService.userPermission.createMany).not.toHaveBeenCalled(); + }); + + it('should throw when a permission key does not exist', async () => { + prismaService.permisision.findMany.mockResolvedValue([]); + + await expect( + membersService.upsertPermissions('user-1', [{ permissionId: 'made.up' }], 'team-123'), + ).rejects.toThrow(NotFoundException); + }); + + it('should reject a payload above the bulk limit before touching the database', async () => { + const tooMany = Array.from({ length: MAX_BULK_PERMISSIONS + 1 }, (_, index) => ({ + permissionId: `permission.${index}`, + })); + + await expect(membersService.upsertPermissions('user-1', tooMany, 'team-123')).rejects.toThrow( + BadRequestException, + ); + expect(prismaService.permisision.findMany).not.toHaveBeenCalled(); + }); + + it('should revoke a grant of this team', async () => { + prismaService.userPermission.findFirst.mockResolvedValue({ id: 'grant-1' }); + + const result = await membersService.deletePermission('user-1', 'grant-1', 'team-123'); + + expect(prismaService.userPermission.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'grant-1', userId: 'user-1', buildTeamId: 'team-123' }, + }), + ); + expect(prismaService.userPermission.delete).toHaveBeenCalledWith({ where: { id: 'grant-1' } }); + expect(result).toEqual({ id: 'grant-1' }); + }); + + it('should refuse to revoke a grant that belongs to another team or to the site', async () => { + prismaService.userPermission.findFirst.mockResolvedValue(null); + + await expect(membersService.deletePermission('user-1', 'grant-1', 'team-123')).rejects.toThrow(NotFoundException); + expect(prismaService.userPermission.delete).not.toHaveBeenCalled(); + }); + }); +});