Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions apps/api-v2/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions apps/api-v2/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +25,7 @@ import { UtilityModule } from './sections/utility/utility.module';
AuthModule,
ClaimsModule,
ConfigModule.forRoot({ isGlobal: true, cache: true }),
MembersModule,
QueueModule,
SocialsModule,
StatusModule,
Expand Down
5 changes: 5 additions & 0 deletions apps/api-v2/src/common/queue/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand All @@ -31,6 +33,8 @@ export enum BuildTeamWebhookEvent {
ClaimCreate = 'CLAIM_CREATE',
ClaimUpdate = 'CLAIM_UPDATE',
ClaimDelete = 'CLAIM_DELETE',
MemberAdd = 'MEMBER_ADD',
MemberRemove = 'MEMBER_REMOVE',
}

/**
Expand All @@ -54,5 +58,6 @@ export interface WorkerJobPayloads {
discordIds?: string[];
content: unknown;
};
[WorkerJob.SyncDiscordRoles]: { discordId: string; isBuilder: boolean };
[WorkerJob.RevalidateWebsite]: { paths?: string[]; tags?: string[] };
}
41 changes: 41 additions & 0 deletions apps/api-v2/src/sections/members/dto/member-ref.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 64 additions & 0 deletions apps/api-v2/src/sections/members/dto/member.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
207 changes: 207 additions & 0 deletions apps/api-v2/src/sections/members/members.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading