From 181bd78951a201f36f886c722650e78895e90133 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 4 Mar 2024 17:48:01 +0000 Subject: [PATCH] feat: workspace invitation --- .../controllers/workspace.controller.ts | 40 ++++++ .../src/core/workspace/dto/invitation.dto.ts | 23 ++++ .../workspace-invitation.repository.ts | 10 ++ .../services/workspace-invitation.service.ts | 121 ++++++++++++++++++ .../services/workspace-user.service.ts | 30 +++-- .../src/core/workspace/workspace.module.ts | 4 + 6 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/core/workspace/dto/invitation.dto.ts create mode 100644 apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts create mode 100644 apps/server/src/core/workspace/services/workspace-invitation.service.ts diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 2da846a2..2e999b70 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -19,6 +19,13 @@ import { CurrentWorkspace } from '../../../decorators/current-workspace.decorato import { Workspace } from '../entities/workspace.entity'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { WorkspaceUserService } from '../services/workspace-user.service'; +import { WorkspaceInvitationService } from '../services/workspace-invitation.service'; +import { Public } from '../../../decorators/public.decorator'; +import { + AcceptInviteDto, + InviteUserDto, + RevokeInviteDto, +} from '../dto/invitation.dto'; @UseGuards(JwtGuard) @Controller('workspaces') @@ -26,6 +33,7 @@ export class WorkspaceController { constructor( private readonly workspaceService: WorkspaceService, private readonly workspaceUserService: WorkspaceUserService, + private readonly workspaceInvitationService: WorkspaceInvitationService, ) {} @HttpCode(HttpStatus.OK) @@ -115,4 +123,36 @@ export class WorkspaceController { workspace.id, ); } + + @HttpCode(HttpStatus.OK) + @Post('invite') + async inviteUser( + @Body() inviteUserDto: InviteUserDto, + @AuthUser() authUser: User, + @CurrentWorkspace() workspace: Workspace, + ) { + return this.workspaceInvitationService.createInvitation( + authUser, + workspace.id, + inviteUserDto, + ); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('invite/accept') + async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) { + return this.workspaceInvitationService.acceptInvitation( + acceptInviteDto.invitationId, + ); + } + + // TODO: authorize permission with guards + @HttpCode(HttpStatus.OK) + @Post('invite/revoke') + async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) { + return this.workspaceInvitationService.revokeInvitation( + revokeInviteDto.invitationId, + ); + } } diff --git a/apps/server/src/core/workspace/dto/invitation.dto.ts b/apps/server/src/core/workspace/dto/invitation.dto.ts new file mode 100644 index 00000000..58870e7d --- /dev/null +++ b/apps/server/src/core/workspace/dto/invitation.dto.ts @@ -0,0 +1,23 @@ +import { IsEmail, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { WorkspaceUserRole } from '../entities/workspace-user.entity'; + +export class InviteUserDto { + @IsString() + @IsOptional() + name: string; + + @IsEmail() + email: string; + + @IsEnum(WorkspaceUserRole) + role: string; +} + +export class InvitationIdDto { + @IsUUID() + invitationId: string; +} + +export class AcceptInviteDto extends InvitationIdDto {} + +export class RevokeInviteDto extends InvitationIdDto {} diff --git a/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts b/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts new file mode 100644 index 00000000..bca570ae --- /dev/null +++ b/apps/server/src/core/workspace/repositories/workspace-invitation.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; + +@Injectable() +export class WorkspaceInvitationRepository extends Repository { + constructor(private dataSource: DataSource) { + super(WorkspaceInvitation, dataSource.createEntityManager()); + } +} diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts new file mode 100644 index 00000000..e2f38f49 --- /dev/null +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -0,0 +1,121 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { WorkspaceInvitationRepository } from '../repositories/workspace-invitation.repository'; +import { WorkspaceInvitation } from '../entities/workspace-invitation.entity'; +import { User } from '../../user/entities/user.entity'; +import { WorkspaceService } from './workspace.service'; +import { WorkspaceUserService } from './workspace-user.service'; +import { WorkspaceUserRole } from '../entities/workspace-user.entity'; +import { UserService } from '../../user/user.service'; +import { InviteUserDto } from '../dto/invitation.dto'; + +@Injectable() +export class WorkspaceInvitationService { + constructor( + private workspaceInvitationRepository: WorkspaceInvitationRepository, + private workspaceService: WorkspaceService, + private workspaceUserService: WorkspaceUserService, + private userService: UserService, + ) {} + + async findInvitedUserByEmail( + email, + workspaceId, + ): Promise { + return this.workspaceInvitationRepository.findOneBy({ + email: email, + workspaceId: workspaceId, + }); + } + + async createInvitation( + authUser: User, + workspaceId: string, + inviteUserDto: InviteUserDto, + ): Promise { + const authUserMembership = + await this.workspaceUserService.findWorkspaceUser( + authUser.id, + workspaceId, + ); + + if (!authUserMembership) { + throw new BadRequestException('Inviting user must be a workspace member'); + } + + if (authUserMembership.role != WorkspaceUserRole.OWNER) { + throw new BadRequestException( + 'Only workspace owners can invite new members', + ); + } + + const invitedUser = await this.userService.findByEmail(inviteUserDto.email); + + // check if invited user is already a workspace member + if (invitedUser) { + const invitedUserMembership = + await this.workspaceUserService.findWorkspaceUser( + invitedUser.id, + workspaceId, + ); + + if (invitedUserMembership) { + throw new BadRequestException( + 'This user already a member of this workspace', + ); + } + } + + // check if user was already invited + const existingInvitation = await this.findInvitedUserByEmail( + inviteUserDto.email, + workspaceId, + ); + + if (existingInvitation) { + throw new BadRequestException('This user has already been invited'); + } + + const invitation = new WorkspaceInvitation(); + invitation.workspaceId = workspaceId; + invitation.email = inviteUserDto.email; + invitation.role = inviteUserDto.role; + invitation.invitedById = authUser.id; + + // TODO: send invitation email + + return await this.workspaceInvitationRepository.save(invitation); + } + + async acceptInvitation(invitationId: string) { + const invitation = await this.workspaceInvitationRepository.findOneBy({ + id: invitationId, + }); + + if (!invitation) { + throw new BadRequestException('Invalid or expired invitation code'); + } + + // TODO: to be completed + + // check if user is in the system already + const invitedUser = await this.userService.findByEmail(invitation.email); + + if (invitedUser) { + // fetch the workspace + // add the user to the workspace + } + return invitation; + } + + async revokeInvitation(invitationId: string): Promise { + const invitation = await this.workspaceInvitationRepository.findOneBy({ + id: invitationId, + }); + + if (!invitation) { + throw new BadRequestException('Invitation not found'); + } + + await this.workspaceInvitationRepository.delete(invitationId); + } +} diff --git a/apps/server/src/core/workspace/services/workspace-user.service.ts b/apps/server/src/core/workspace/services/workspace-user.service.ts index 34e7249e..975996ad 100644 --- a/apps/server/src/core/workspace/services/workspace-user.service.ts +++ b/apps/server/src/core/workspace/services/workspace-user.service.ts @@ -10,7 +10,6 @@ import { } from '../entities/workspace-user.entity'; import { Workspace } from '../entities/workspace.entity'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; -import { SpaceService } from '../../space/space.service'; import { PaginationOptions } from '../../../helpers/pagination/pagination-options'; import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto'; import { PaginatedResult } from '../../../helpers/pagination/paginated-result'; @@ -22,7 +21,6 @@ import { transactionWrapper } from '../../../helpers/db.helper'; export class WorkspaceUserService { constructor( private workspaceUserRepository: WorkspaceUserRepository, - private spaceService: SpaceService, private dataSource: DataSource, ) {} @@ -72,7 +70,7 @@ export class WorkspaceUserService { workspaceUserRoleDto: UpdateWorkspaceUserRoleDto, workspaceId: string, ) { - const workspaceUser = await this.getWorkspaceUser( + const workspaceUser = await this.findAndValidateWorkspaceUser( workspaceUserRoleDto.userId, workspaceId, ); @@ -105,9 +103,10 @@ export class WorkspaceUserService { userId: string, workspaceId: string, ): Promise { - await this.getWorkspaceUser(userId, workspaceId); - - const workspaceUser = await this.getWorkspaceUser(userId, workspaceId); + const workspaceUser = await this.findAndValidateWorkspaceUser( + userId, + workspaceId, + ); const workspaceOwnerCount = await this.workspaceUserRepository.count({ where: { @@ -183,17 +182,28 @@ export class WorkspaceUserService { userId: string, workspaceId: string, ): Promise { - const workspaceUser = await this.getWorkspaceUser(userId, workspaceId); + const workspaceUser = await this.findAndValidateWorkspaceUser( + userId, + workspaceId, + ); return workspaceUser.role ? workspaceUser.role : null; } - async getWorkspaceUser( + async findWorkspaceUser( userId: string, workspaceId: string, ): Promise { - const workspaceUser = await this.workspaceUserRepository.findOne({ - where: { userId, workspaceId }, + return await this.workspaceUserRepository.findOneBy({ + userId, + workspaceId, }); + } + + async findAndValidateWorkspaceUser( + userId: string, + workspaceId: string, + ): Promise { + const workspaceUser = await this.findWorkspaceUser(userId, workspaceId); if (!workspaceUser) { throw new BadRequestException('Workspace member not found'); diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index 7507afd5..89790f15 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -10,6 +10,8 @@ import { WorkspaceUserRepository } from './repositories/workspace-user.repositor import { AuthModule } from '../auth/auth.module'; import { SpaceModule } from '../space/space.module'; import { WorkspaceUserService } from './services/workspace-user.service'; +import { WorkspaceInvitationService } from './services/workspace-invitation.service'; +import { WorkspaceInvitationRepository } from './repositories/workspace-invitation.repository'; @Module({ imports: [ @@ -21,8 +23,10 @@ import { WorkspaceUserService } from './services/workspace-user.service'; providers: [ WorkspaceService, WorkspaceUserService, + WorkspaceInvitationService, WorkspaceRepository, WorkspaceUserRepository, + WorkspaceInvitationRepository, ], exports: [WorkspaceService, WorkspaceRepository, WorkspaceUserRepository], })