From 574170f39d0a56d6087d8518ca02bbe6b9fd9740 Mon Sep 17 00:00:00 2001 From: Udit Namdev <35065474+unamdev0@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:32:17 +0530 Subject: [PATCH] refactor(API): Refactor workspace-membership into a separate module (#421) Co-authored-by: rajdip-b --- apps/api/src/project/project.e2e.spec.ts | 10 +- apps/api/src/secret/secret.e2e.spec.ts | 1 - .../workspace-membership.controller.spec.ts | 38 + .../workspace-membership.controller.ts | 163 +++ .../create.workspace-membership.spec.ts | 7 + .../create.workspace-membership.ts | 12 + .../workspace-membership.service.spec.ts | 34 + .../service/workspace-membership.service.ts | 1011 +++++++++++++++ .../workspace-membership.e2e.spec.ts | 1075 ++++++++++++++++ .../workspace-membership.module.ts | 9 + .../controller/workspace.controller.ts | 142 +- .../dto/create.workspace/create.workspace.ts | 7 +- .../workspace/service/workspace.service.ts | 1139 ++--------------- apps/api/src/workspace/workspace.e2e.spec.ts | 857 ------------- 14 files changed, 2442 insertions(+), 2063 deletions(-) create mode 100644 apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts create mode 100644 apps/api/src/workspace-membership/controller/workspace-membership.controller.ts create mode 100644 apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.spec.ts create mode 100644 apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.ts create mode 100644 apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts create mode 100644 apps/api/src/workspace-membership/service/workspace-membership.service.ts create mode 100644 apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts create mode 100644 apps/api/src/workspace-membership/workspace-membership.module.ts diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index a25fb9a3..88b81f49 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -26,8 +26,10 @@ import { EventService } from '@/event/service/event.service' import { EventModule } from '@/event/event.module' import { ProjectService } from './service/project.service' import { WorkspaceService } from '@/workspace/service/workspace.service' +import { WorkspaceMembershipService } from '@/workspace-membership/service/workspace-membership.service' import { UserService } from '@/user/service/user.service' import { WorkspaceModule } from '@/workspace/workspace.module' +import { WorkspaceMembershipModule } from '@/workspace-membership/workspace-membership.module' import { UserModule } from '@/user/user.module' import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' import { WorkspaceRoleService } from '@/workspace-role/service/workspace-role.service' @@ -46,6 +48,7 @@ describe('Project Controller Tests', () => { let eventService: EventService let projectService: ProjectService let workspaceService: WorkspaceService + let workspaceMembershipService: WorkspaceMembershipService let userService: UserService let workspaceRoleService: WorkspaceRoleService let environmentService: EnvironmentService @@ -63,6 +66,7 @@ describe('Project Controller Tests', () => { ProjectModule, EventModule, WorkspaceModule, + WorkspaceMembershipModule, UserModule, WorkspaceRoleModule, EnvironmentModule, @@ -81,6 +85,7 @@ describe('Project Controller Tests', () => { eventService = moduleRef.get(EventService) projectService = moduleRef.get(ProjectService) workspaceService = moduleRef.get(WorkspaceService) + workspaceMembershipService = moduleRef.get(WorkspaceMembershipService) userService = moduleRef.get(UserService) workspaceRoleService = moduleRef.get(WorkspaceRoleService) environmentService = moduleRef.get(EnvironmentService) @@ -150,6 +155,7 @@ describe('Project Controller Tests', () => { expect(eventService).toBeDefined() expect(projectService).toBeDefined() expect(workspaceService).toBeDefined() + expect(workspaceMembershipService).toBeDefined() expect(userService).toBeDefined() expect(workspaceRoleService).toBeDefined() expect(environmentService).toBeDefined() @@ -846,7 +852,7 @@ describe('Project Controller Tests', () => { ) // Add user to workspace as a member - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + await workspaceMembershipService.inviteUsersToWorkspace(user1, workspace1.slug, [ { email: johnny.email, roleSlugs: [role.slug] @@ -854,7 +860,7 @@ describe('Project Controller Tests', () => { ]) // Accept the invitation on behalf of the user - await workspaceService.acceptInvitation(johnny, workspace1.slug) + await workspaceMembershipService.acceptInvitation(johnny, workspace1.slug) // Update the access level of the project const response = await app.inject({ diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index b0f31988..54f5d085 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -545,7 +545,6 @@ describe('Secret Controller Tests', () => { } }) - expect(response.statusCode).toBe(200) expect(response.json().count).toEqual(2) versions = await prisma.secretVersion.findMany({ diff --git a/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts b/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts new file mode 100644 index 00000000..8a0e9ad9 --- /dev/null +++ b/apps/api/src/workspace-membership/controller/workspace-membership.controller.spec.ts @@ -0,0 +1,38 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { WorkspaceMembershipController } from './workspace-membership.controller' +import { WorkspaceMembershipService } from '../service/workspace-membership.service' +import { PrismaService } from '@/prisma/prisma.service' +import { MAIL_SERVICE } from '@/mail/services/interface.service' +import { MockMailService } from '@/mail/services/mock.service' +import { JwtService } from '@nestjs/jwt' +import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { CommonModule } from '@/common/common.module' + +describe('WorkspaceMembershipController', () => { + let controller: WorkspaceMembershipController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [CommonModule], + providers: [ + WorkspaceMembershipService, + PrismaService, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + JwtService, + AuthorityCheckerService + ], + controllers: [WorkspaceMembershipController] + }).compile() + + controller = module.get( + WorkspaceMembershipController + ) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts b/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts new file mode 100644 index 00000000..2e561ebb --- /dev/null +++ b/apps/api/src/workspace-membership/controller/workspace-membership.controller.ts @@ -0,0 +1,163 @@ +import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' +import { CurrentUser } from '@/decorators/user.decorator' +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query +} from '@nestjs/common' +import { Authority, User, Workspace, WorkspaceRole } from '@prisma/client' +import { CreateWorkspaceMember } from '../dto/create.workspace/create.workspace-membership' +import { WorkspaceMembershipService } from '../service/workspace-membership.service' + +@Controller('workspace-membership') +export class WorkspaceMembershipController { + constructor( + private readonly workspaceMembershipService: WorkspaceMembershipService + ) {} + + @Put(':workspaceSlug/transfer-ownership/:userEmail') + @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) + async transferOwnership( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] + ) { + return this.workspaceMembershipService.transferOwnership( + user, + workspaceSlug, + userEmail + ) + } + + @Post(':workspaceSlug/invite-users') + @RequiredApiKeyAuthorities(Authority.ADD_USER, Authority.READ_WORKSPACE) + async addUsers( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Body() members: CreateWorkspaceMember[] + ) { + return this.workspaceMembershipService.inviteUsersToWorkspace( + user, + workspaceSlug, + members + ) + } + + @Delete(':workspaceSlug/remove-users') + @RequiredApiKeyAuthorities(Authority.REMOVE_USER, Authority.READ_WORKSPACE) + async removeUsers( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Body() userEmails: User['email'][] + ) { + return this.workspaceMembershipService.removeUsersFromWorkspace( + user, + workspaceSlug, + userEmails + ) + } + + @Put(':workspaceSlug/update-member-role/:userEmail') + @RequiredApiKeyAuthorities( + Authority.UPDATE_USER_ROLE, + Authority.READ_WORKSPACE + ) + async updateMemberRoles( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'], + @Body() roleSlugs: WorkspaceRole['slug'][] + ) { + return this.workspaceMembershipService.updateMemberRoles( + user, + workspaceSlug, + userEmail, + roleSlugs + ) + } + + @Post(':workspaceSlug/accept-invitation') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async acceptInvitation( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] + ) { + return this.workspaceMembershipService.acceptInvitation(user, workspaceSlug) + } + + @Delete(':workspaceSlug/decline-invitation') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async declineInvitation( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] + ) { + return this.workspaceMembershipService.declineInvitation( + user, + workspaceSlug + ) + } + + @Delete(':workspaceSlug/cancel-invitation/:userEmail') + @RequiredApiKeyAuthorities(Authority.REMOVE_USER) + async cancelInvitation( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] + ) { + return this.workspaceMembershipService.cancelInvitation( + user, + workspaceSlug, + userEmail + ) + } + + @Delete(':workspaceSlug/leave') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async leave( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] + ) { + return this.workspaceMembershipService.leaveWorkspace(user, workspaceSlug) + } + + @Get(':workspaceSlug/is-member/:userEmail') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async isMember( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] + ) { + return this.workspaceMembershipService.isUserMemberOfWorkspace( + user, + workspaceSlug, + userEmail + ) + } + + @Get(':workspaceSlug/members') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async getMembers( + @CurrentUser() user: User, + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Query('page') page: number = 0, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return this.workspaceMembershipService.getAllMembersOfWorkspace( + user, + workspaceSlug, + page, + limit, + sort, + order, + search + ) + } +} diff --git a/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.spec.ts b/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.spec.ts new file mode 100644 index 00000000..e9e0287e --- /dev/null +++ b/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.spec.ts @@ -0,0 +1,7 @@ +import { CreateWorkspaceMember } from './create.workspace-membership' + +describe('CreateWorkspaceMember', () => { + it('should be defined', () => { + expect(new CreateWorkspaceMember()).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.ts b/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.ts new file mode 100644 index 00000000..66618ac7 --- /dev/null +++ b/apps/api/src/workspace-membership/dto/create.workspace/create.workspace-membership.ts @@ -0,0 +1,12 @@ +import { WorkspaceRole } from '@prisma/client' +import { IsArray, IsNotEmpty, IsString } from 'class-validator' + +export class CreateWorkspaceMember { + @IsString() + @IsNotEmpty() + email: string + + @IsArray() + @IsString() + roleSlugs: WorkspaceRole['slug'][] +} diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts new file mode 100644 index 00000000..7c6b0bc5 --- /dev/null +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { WorkspaceMembershipService } from './workspace-membership.service' +import { PrismaService } from '@/prisma/prisma.service' +import { MAIL_SERVICE } from '@/mail/services/interface.service' +import { MockMailService } from '@/mail/services/mock.service' +import { JwtService } from '@nestjs/jwt' +import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { CommonModule } from '@/common/common.module' + +describe('WorkspaceMembershipService', () => { + let service: WorkspaceMembershipService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [CommonModule], + providers: [ + WorkspaceMembershipService, + PrismaService, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + JwtService, + AuthorityCheckerService + ] + }).compile() + + service = module.get(WorkspaceMembershipService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.ts new file mode 100644 index 00000000..2d574b75 --- /dev/null +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.ts @@ -0,0 +1,1011 @@ +import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { paginate } from '@/common/paginate' +import { getUserByEmail } from '@/common/user' +import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' +import { PrismaService } from '@/prisma/prisma.service' +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException +} from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { + Authority, + EventSource, + EventType, + User, + Workspace, + WorkspaceMember, + WorkspaceRole +} from '@prisma/client' +import { v4 } from 'uuid' +import { CreateWorkspaceMember } from '../dto/create.workspace/create.workspace-membership' + +import { createEvent } from '@/common/event' +import { limitMaxItemsPerPage } from '@/common/util' + +@Injectable() +export class WorkspaceMembershipService { + private readonly log = new Logger(WorkspaceMembershipService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + @Inject(MAIL_SERVICE) private readonly mailService: IMailService, + private readonly authorityCheckerService: AuthorityCheckerService + ) {} + + /** + * Transfers ownership of a workspace to another user. + * @param user The user transferring the ownership + * @param workspaceSlug The slug of the workspace to transfer + * @param otherUserEmail The email of the user to transfer the ownership to + * @throws BadRequestException if the user is already the owner of the workspace, + * or if the workspace is the default workspace + * @throws NotFoundException if the other user is not a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + */ + async transferOwnership( + user: User, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] + ): Promise { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.WORKSPACE_ADMIN], + + prisma: this.prisma + }) + + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + + if (otherUser.id === user.id) { + throw new BadRequestException( + `You are already the owner of the workspace ${workspace.name} (${workspace.slug})` + ) + } + + // We don't want the users to be able to transfer + // ownership if the workspace is the default workspace + if (workspace.isDefault) { + throw new BadRequestException( + `You cannot transfer ownership of default workspace ${workspace.name} (${workspace.slug})` + ) + } + + const workspaceMembership = await this.getWorkspaceMembership( + workspace.id, + otherUser.id + ) + + // Check if the user is a member of the workspace + if (!workspaceMembership) { + throw new NotFoundException( + `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` + ) + } + + const currentUserMembership = await this.getWorkspaceMembership( + workspace.id, + user.id + ) + + // Get the admin ownership role + const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspace.id, + hasAdminAuthority: true + } + }) + + // Remove this role from the current owner + const removeRole = this.prisma.workspaceMemberRoleAssociation.delete({ + where: { + roleId_workspaceMemberId: { + roleId: adminOwnershipRole.id, + workspaceMemberId: currentUserMembership.id + } + } + }) + + // Assign this role to the new owner + const assignRole = this.prisma.workspaceMemberRoleAssociation.create({ + data: { + role: { + connect: { + id: adminOwnershipRole.id + } + }, + workspaceMember: { + connect: { + id: workspaceMembership.id + } + } + } + }) + + // Update the owner of the workspace + const updateWorkspace = this.prisma.workspace.update({ + where: { + id: workspace.id + }, + data: { + ownerId: otherUser.id, + lastUpdatedBy: { + connect: { + id: user.id + } + } + } + }) + + try { + await this.prisma.$transaction([removeRole, assignRole, updateWorkspace]) + } catch (e) { + this.log.error('Error in transaction', e) + throw new InternalServerErrorException('Error in transaction') + } + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.WORKSPACE_UPDATED, + source: EventSource.WORKSPACE, + title: `Workspace transferred`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + newOwnerId: otherUser.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${otherUser.email} (${otherUser.id})` + ) + } + + /** + * Invites users to a workspace. + * @param user The user to invite the users for + * @param workspaceSlug The slug of the workspace to invite users to + * @param members The members to invite + * @throws BadRequestException if the user does not have the authority to add users to the workspace + * @throws NotFoundException if the workspace or any of the users to invite do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async inviteUsersToWorkspace( + user: User, + workspaceSlug: Workspace['slug'], + members: CreateWorkspaceMember[] + ): Promise { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.ADD_USER], + prisma: this.prisma + }) + + // Add users to the workspace if any + if (members && members.length > 0) { + await this.addMembersToWorkspace(workspace, user, members) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.INVITED_TO_WORKSPACE, + source: EventSource.WORKSPACE, + title: `Invited users to workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + members: members.map((m) => m.email) + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Added users to workspace ${workspace.name} (${workspace.id})` + ) + + return + } + + this.log.warn( + `No users to add to workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Removes users from a workspace. + * @param user The user to remove users from the workspace for + * @param workspaceSlug The slug of the workspace to remove users from + * @param userEmails The emails of the users to remove from the workspace + * @throws BadRequestException if the user is trying to remove themselves from the workspace, + * or if the user is not a member of the workspace + * @throws NotFoundException if the workspace or any of the users to remove do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async removeUsersFromWorkspace( + user: User, + workspaceSlug: Workspace['slug'], + userEmails: User['email'][] + ): Promise { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.REMOVE_USER], + prisma: this.prisma + }) + + const userIds = await this.prisma.user + .findMany({ + where: { + email: { + in: userEmails + } + }, + select: { + id: true + } + }) + .then((users) => users.map((u) => u.id)) + + // Remove users from the workspace if any + if (userIds && userIds.length > 0) { + if (userIds.find((id) => id === user.id)) { + throw new BadRequestException( + `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` + ) + } + + // Delete the membership + await this.prisma.workspaceMember.deleteMany({ + where: { + workspaceId: workspace.id, + userId: { + in: userIds + } + } + }) + } + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.REMOVED_FROM_WORKSPACE, + source: EventSource.WORKSPACE, + title: `Removed users from workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + members: userIds + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Removed users from workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Updates the roles of a user in a workspace. + * + * @throws NotFoundException if the user is not a member of the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @param user The user to update the roles for + * @param workspaceSlug The slug of the workspace to update the roles in + * @param otherUserEmail The email of the user to update the roles for + * @param roleSlugs The slugs of the roles to assign to the user + */ + async updateMemberRoles( + user: User, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'], + roleSlugs: WorkspaceRole['slug'][] + ): Promise { + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.UPDATE_USER_ROLE], + prisma: this.prisma + }) + + if (!roleSlugs || roleSlugs.length === 0) { + this.log.warn( + `No roles to update for user ${otherUserEmail} in workspace ${workspace.name} (${workspace.id})` + ) + } + + // Check if the member in concern is a part of the workspace or not + if (!(await this.memberExistsInWorkspace(workspace.id, otherUser.id))) + throw new NotFoundException( + `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` + ) + + const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) + + // Check if the admin role is tried to be assigned to the user + if (roleSlugs.includes(workspaceAdminRole.slug)) { + throw new BadRequestException(`Admin role cannot be assigned to the user`) + } + + // Update the role of the user + const membership = await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: otherUser.id + } + } + }) + + // Clear out the existing roles + const deleteExistingAssociations = + this.prisma.workspaceMemberRoleAssociation.deleteMany({ + where: { + workspaceMemberId: membership.id + } + }) + + const roleSet = new Set() + + for (const slug of roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException(`Role ${slug} not found`) + } + + roleSet.add(role) + } + + // Create new associations + const createNewAssociations = + this.prisma.workspaceMemberRoleAssociation.createMany({ + data: Array.from(roleSet).map((role) => ({ + roleId: role.id, + workspaceMemberId: membership.id + })) + }) + + await this.prisma.$transaction([ + deleteExistingAssociations, + createNewAssociations + ]) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.WORKSPACE_MEMBERSHIP_UPDATED, + source: EventSource.WORKSPACE, + title: `Updated role of user in workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + userId: otherUser.id, + roleIds: roleSlugs + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `Updated role of user ${otherUser.id} in workspace ${workspace.name} (${workspace.id})` + ) + } + + /** + * Gets all members of a workspace, paginated. + * @param user The user to get the members for + * @param workspaceSlug The slug of the workspace to get the members from + * @param page The page number to get + * @param limit The number of items per page to get + * @param sort The field to sort by + * @param order The order to sort in + * @param search The search string to filter by + * @returns The members of the workspace, paginated, with metadata + */ + async getAllMembersOfWorkspace( + user: User, + workspaceSlug: Workspace['slug'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS], + prisma: this.prisma + }) + //get all members of workspace for page with limit + const items = await this.prisma.workspaceMember.findMany({ + skip: page * limit, + take: limit, + orderBy: { + workspace: { + [sort]: order + } + }, + where: { + workspaceId: workspace.id, + user: { + OR: [ + { + name: { + contains: search + } + }, + { + email: { + contains: search + } + } + ] + } + }, + select: { + id: true, + user: true, + roles: { + select: { + id: true, + role: { + select: { + id: true, + name: true, + description: true, + colorCode: true, + authorities: true, + projects: { + select: { + id: true + } + } + } + } + } + } + } + }) + + //calculate metadata for pagination + const totalCount = await this.prisma.workspaceMember.count({ + where: { + workspaceId: workspace.id, + user: { + OR: [ + { + name: { + contains: search + } + }, + { + email: { + contains: search + } + } + ] + } + } + }) + + const metadata = paginate( + totalCount, + `/workspace-membership/${workspace.slug}/members`, + { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + } + ) + + return { items, metadata } + } + + /** + * Accepts an invitation to a workspace. + * @param user The user to accept the invitation for + * @param workspaceSlug The slug of the workspace to accept the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async acceptInvitation( + user: User, + workspaceSlug: Workspace['slug'] + ): Promise { + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, user) + + const workspace = await this.prisma.workspace.findUnique({ + where: { + slug: workspaceSlug + } + }) + + // Update the membership + await this.prisma.workspaceMember.update({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: user.id + } + }, + data: { + invitationAccepted: true + } + }) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.ACCEPTED_INVITATION, + source: EventSource.WORKSPACE, + title: `${user.name} accepted invitation to workspace ${workspace.name}`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) accepted invitation to workspace ${workspace.id}` + ) + } + + /** + * Cancels an invitation to a workspace. + * @param user The user cancelling the invitation + * @param workspaceSlug The slug of the workspace to cancel the invitation for + * @param inviteeEmail The email of the user to cancel the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace or the user to cancel the invitation for do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async cancelInvitation( + user: User, + workspaceSlug: Workspace['slug'], + inviteeEmail: User['email'] + ): Promise { + const inviteeUser = await getUserByEmail(inviteeEmail, this.prisma) + + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.REMOVE_USER], + prisma: this.prisma + }) + + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, inviteeUser) + + // Delete the membership + await this.deleteMembership(workspace.id, inviteeUser.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.CANCELLED_INVITATION, + source: EventSource.WORKSPACE, + title: `Cancelled invitation to workspace`, + metadata: { + workspaceId: workspace.id, + inviteeId: inviteeUser.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspace.id}` + ) + } + + /** + * Declines an invitation to a workspace. + * @param user The user declining the invitation + * @param workspaceSlug The slug of the workspace to decline the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ + async declineInvitation( + user: User, + workspaceSlug: Workspace['slug'] + ): Promise { + // Check if the user has a pending invitation to the workspace + await this.checkInvitationPending(workspaceSlug, user) + + const workspace = await this.prisma.workspace.findUnique({ + where: { + slug: workspaceSlug + } + }) + + // Delete the membership + await this.deleteMembership(workspace.id, user.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.DECLINED_INVITATION, + source: EventSource.WORKSPACE, + title: `${user.name} declined invitation to workspace ${workspace.name}`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) declined invitation to workspace ${workspace.id}` + ) + } + + /** + * Leaves a workspace. + * @throws BadRequestException if the user is the owner of the workspace + * @param user The user to leave the workspace for + * @param workspaceSlug The slug of the workspace to leave + */ + async leaveWorkspace( + user: User, + workspaceSlug: Workspace['slug'] + ): Promise { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE], + prisma: this.prisma + }) + + const workspaceOwnerId = await this.prisma.workspace + .findUnique({ + where: { + id: workspace.id + }, + select: { + ownerId: true + } + }) + .then((workspace) => workspace.ownerId) + + // Check if the user is the owner of the workspace + if (workspaceOwnerId === user.id) + throw new BadRequestException( + `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` + ) + + // Delete the membership + await this.deleteMembership(workspace.id, user.id) + + await createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.LEFT_WORKSPACE, + source: EventSource.WORKSPACE, + title: `User left workspace`, + metadata: { + workspaceId: workspace.id + }, + workspaceId: workspace.id + }, + this.prisma + ) + + this.log.debug( + `User ${user.name} (${user.id}) left workspace ${workspace.id}` + ) + } + + /** + * Checks if a user is a member of a workspace. + * @param user The user to check if the other user is a member of the workspace for + * @param workspaceSlug The slug of the workspace to check if the user is a member of + * @param otherUserEmail The email of the user to check if is a member of the workspace + * @returns True if the user is a member of the workspace, false otherwise + */ + async isUserMemberOfWorkspace( + user: User, + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] + ): Promise { + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS], + prisma: this.prisma + }) + + return await this.memberExistsInWorkspace(workspace.id, otherUser.id) + } + + private async getWorkspaceAdminRole( + workspaceId: Workspace['id'] + ): Promise { + const adminRole = await this.prisma.workspaceRole.findFirst({ + where: { + hasAdminAuthority: true, + workspaceId + } + }) + + if (!adminRole) { + throw new InternalServerErrorException( + `Admin role not found for workspace ${workspaceId}` + ) + } + + return adminRole + } + + /** + * Adds members to a workspace. + * @param workspace The workspace to add members to + * @param currentUser The user performing the action + * @param members The members to add to the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @throws ConflictException if the user is already a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + * @private + */ + private async addMembersToWorkspace( + workspace: Workspace, + currentUser: User, + members: CreateWorkspaceMember[] + ) { + const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) + + for (const member of members) { + // Check if the admin role is tried to be assigned to the user + if (member.roleSlugs.includes(workspaceAdminRole.slug)) { + throw new BadRequestException( + `Admin role cannot be assigned to the user` + ) + } + + const memberUser: User | null = await this.prisma.user.findUnique({ + where: { + email: member.email + } + }) + + const userId = memberUser?.id ?? v4() + + // Check if the user is already a member of the workspace + if ( + memberUser && + (await this.memberExistsInWorkspace(workspace.id, userId)) + ) { + this.log.warn( + `User ${ + memberUser.name ?? 'NO_NAME_YET' + } (${userId}) is already a member of workspace ${workspace.name} (${ + workspace.slug + }). Skipping.` + ) + throw new ConflictException( + `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.slug})` + ) + } + + const roleSet = new Set() + + for (const slug of member.roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException(`Workspace role ${slug} does not exist`) + } + + roleSet.add(role) + } + + // Create the workspace membership + const createMembership = this.prisma.workspaceMember.create({ + data: { + workspaceId: workspace.id, + userId, + roles: { + create: Array.from(roleSet).map((role) => ({ + role: { + connect: { + id: role.id + } + } + })) + } + } + }) + + if (memberUser) { + await this.prisma.$transaction([createMembership]) + + this.mailService.workspaceInvitationMailForUsers( + member.email, + workspace.name, + `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.id}/join`, + currentUser.name, + true + ) + + this.log.debug( + `Sent workspace invitation mail to registered user ${memberUser}` + ) + } else { + const createMember = this.prisma.user.create({ + data: { + id: userId, + email: member.email, + isOnboardingFinished: false + } + }) + + await this.prisma.$transaction([createMember, createMembership]) + + this.log.debug(`Created non-registered user ${memberUser}`) + + this.mailService.workspaceInvitationMailForUsers( + member.email, + workspace.name, + `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${ + workspace.id + }/join?token=${await this.jwt.signAsync({ + id: userId + })}`, + currentUser.name, + false + ) + + this.log.debug( + `Sent workspace invitation mail to non-registered user ${memberUser}` + ) + } + + this.log.debug(`Added user ${memberUser} to workspace ${workspace.name}.`) + } + } + + /** + * Checks if a user is a member of a workspace. + * @param workspaceId The ID of the workspace to check + * @param userId The ID of the user to check + * @returns True if the user is a member of the workspace, false otherwise + * @private + */ + private async memberExistsInWorkspace( + workspaceId: string, + userId: string + ): Promise { + return ( + (await this.prisma.workspaceMember.count({ + where: { + workspaceId, + userId + } + })) > 0 + ) + } + + /** + * Gets the workspace membership of a user in a workspace. + * @param workspaceId The ID of the workspace to get the membership for + * @param userId The ID of the user to get the membership for + * @returns The workspace membership of the user in the workspace + * @private + */ + private async getWorkspaceMembership( + workspaceId: Workspace['id'], + userId: User['id'] + ): Promise { + return await this.prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId, + userId + } + } + }) + } + + /** + * Deletes the membership of a user in a workspace. + * @param workspaceId The ID of the workspace to delete the membership from + * @param userId The ID of the user to delete the membership for + * @returns A promise that resolves when the membership is deleted + * @private + */ + private async deleteMembership( + workspaceId: Workspace['id'], + userId: User['id'] + ): Promise { + await this.prisma.workspaceMember.delete({ + where: { + workspaceId_userId: { + workspaceId, + userId + } + } + }) + } + + /** + * Checks if a user has a pending invitation to a workspace. + * @throws BadRequestException if the user is not invited to the workspace + * @param workspaceSlug The slug of the workspace to check if the user is invited to + * @param user The user to check if the user is invited to the workspace + */ + private async checkInvitationPending( + workspaceSlug: Workspace['slug'], + user: User + ): Promise { + const membershipExists = await this.prisma.workspaceMember + .count({ + where: { + workspace: { + slug: workspaceSlug + }, + userId: user.id, + invitationAccepted: false + } + }) + .then((count) => count > 0) + + if (!membershipExists) + throw new BadRequestException( + `${user.email} is not invited to workspace ${workspaceSlug}` + ) + } +} diff --git a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts new file mode 100644 index 00000000..deecaca3 --- /dev/null +++ b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts @@ -0,0 +1,1075 @@ +import { AppModule } from '@/app/app.module' +import { fetchEvents } from '@/common/event' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { EnvironmentModule } from '@/environment/environment.module' +import { EnvironmentService } from '@/environment/service/environment.service' +import { EventModule } from '@/event/event.module' +import { EventService } from '@/event/service/event.service' +import { MAIL_SERVICE } from '@/mail/services/interface.service' +import { MockMailService } from '@/mail/services/mock.service' +import { PrismaService } from '@/prisma/prisma.service' +import { ProjectModule } from '@/project/project.module' +import { ProjectService } from '@/project/service/project.service' +import { SecretModule } from '@/secret/secret.module' +import { SecretService } from '@/secret/service/secret.service' +import { UserService } from '@/user/service/user.service' +import { UserModule } from '@/user/user.module' +import { VariableService } from '@/variable/service/variable.service' +import { VariableModule } from '@/variable/variable.module' +import { WorkspaceRoleService } from '@/workspace-role/service/workspace-role.service' +import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' +import { WorkspaceService } from '@/workspace/service/workspace.service' +import { WorkspaceModule } from '@/workspace/workspace.module' +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { Test } from '@nestjs/testing' +import { + Authority, + EventSeverity, + EventSource, + EventTriggerer, + EventType, + User, + Workspace, + WorkspaceRole +} from '@prisma/client' +import { WorkspaceMembershipService } from './service/workspace-membership.service' +import { WorkspaceMembershipModule } from './workspace-membership.module' + +const createMembership = async ( + roleId: string, + userId: string, + workspaceId: string, + prisma: PrismaService +) => { + await prisma.workspaceMember.create({ + data: { + workspaceId: workspaceId, + userId: userId, + roles: { + create: { + role: { + connect: { + id: roleId + } + } + } + } + } + }) +} + +describe('Workspace Membership Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let eventService: EventService + let userService: UserService + let workspaceService: WorkspaceService + let workspaceMembershipService: WorkspaceMembershipService + let projectService: ProjectService + let environmentService: EnvironmentService + let secretService: SecretService + let variableService: VariableService + let workspaceRoleService: WorkspaceRoleService + + let user1: User, user2: User, user3: User + let workspace1: Workspace + let adminRole: WorkspaceRole, memberRole: WorkspaceRole + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AppModule, + WorkspaceMembershipModule, + WorkspaceModule, + EventModule, + UserModule, + ProjectModule, + EnvironmentModule, + SecretModule, + VariableModule, + WorkspaceRoleModule + ] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + eventService = moduleRef.get(EventService) + userService = moduleRef.get(UserService) + workspaceMembershipService = moduleRef.get(WorkspaceMembershipService) + workspaceService = moduleRef.get(WorkspaceService) + projectService = moduleRef.get(ProjectService) + environmentService = moduleRef.get(EnvironmentService) + secretService = moduleRef.get(SecretService) + variableService = moduleRef.get(VariableService) + workspaceRoleService = moduleRef.get(WorkspaceRoleService) + + app.useGlobalPipes(new QueryTransformPipe()) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + }) + + beforeEach(async () => { + const createUser1 = await userService.createUser({ + email: 'john@keyshade.xyz', + name: 'John Doe', + isOnboardingFinished: true + }) + + const createUser2 = await userService.createUser({ + email: 'jane@keyshade.xyz', + name: 'Jane Doe', + isOnboardingFinished: true + }) + + const createUser3 = await userService.createUser({ + email: 'sadie@keyshade.xyz', + name: 'Sadie', + isOnboardingFinished: true + }) + + workspace1 = createUser1.defaultWorkspace + + delete createUser1.defaultWorkspace + delete createUser2.defaultWorkspace + delete createUser3.defaultWorkspace + + user1 = createUser1 + user2 = createUser2 + user3 = createUser3 + + memberRole = await prisma.workspaceRole.create({ + data: { + name: 'Member', + slug: 'member', + workspaceId: workspace1.id, + authorities: [Authority.READ_WORKSPACE] + } + }) + + adminRole = await prisma.workspaceRole.findUnique({ + where: { + workspaceId_name: { + workspaceId: workspace1.id, + name: 'Admin' + } + } + }) + }) + + afterEach(async () => { + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.workspace.deleteMany() + ]) + }) + + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + expect(eventService).toBeDefined() + expect(userService).toBeDefined() + expect(workspaceMembershipService).toBeDefined() + expect(workspaceService).toBeDefined() + expect(projectService).toBeDefined() + expect(environmentService).toBeDefined() + expect(secretService).toBeDefined() + expect(variableService).toBeDefined() + expect(workspaceRoleService).toBeDefined() + }) + + describe('Change Ownership Tests', () => { + it('should prevent external user from changing ownership of workspace', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/transfer-ownership/${user1.id}` + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to transfer the ownership to self', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/transfer-ownership/${user1.email}` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You are already the owner of the workspace ${workspace1.name} (${workspace1.slug})` + }) + }) + + it('should not be able to transfer ownership to a non member', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${newWorkspace.slug}/transfer-ownership/${user3.email}` + }) + + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `${user3.email} is not a member of workspace ${newWorkspace.name} (${newWorkspace.slug})` + }) + }) + + it('should be able to transfer the ownership of the workspace', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + // Create membership + await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${newWorkspace.slug}/transfer-ownership/${user2.email}` + }) + + expect(response.statusCode).toBe(200) + + const workspace = await prisma.workspace.findUnique({ + where: { + id: newWorkspace.id + } + }) + + expect(workspace.ownerId).toEqual(user2.id) + }) + + it('should not be able to transfer ownership if is not admin', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + // Create membership + await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${newWorkspace.slug}/transfer-ownership/${user3.email}` + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to transfer ownership of default workspace', async () => { + // Invite another user to the workspace + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + ) + + // Accept the invitation + await workspaceMembershipService.acceptInvitation(user2, workspace1.slug) + + // Try transferring ownership + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/transfer-ownership/${user2.email}` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot transfer ownership of default workspace ${workspace1.name} (${workspace1.slug})` + }) + }) + }) + + describe('Invite User Tests', () => { + it('should do nothing if null or empty array is sent for invitation of user', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/invite-users`, + payload: [] + }) + + expect(response.statusCode).toBe(201) + }) + + it('should not allow user to invite another user ', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [adminRole.slug] + } + ] + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `Admin role cannot be assigned to the user` + }) + }) + + it('should allow user to invite another user to the workspace', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + }) + + expect(response.statusCode).toBe(201) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeDefined() + expect(membership).toEqual({ + id: expect.any(String), + userId: user2.id, + workspaceId: workspace1.id, + invitationAccepted: false + }) + }) + + it('should not be able to add an existing user to the workspace', async () => { + // Add user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [] + } + ] + }) + + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: `User ${user2.name} (${user2.id}) is already a member of workspace ${workspace1.name} (${workspace1.slug})` + }) + }) + + it('should have created a INVITED_TO_WORKSPACE event', async () => { + // Invite user2 to workspace1 + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [] + } + ] + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.INVITED_TO_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + + it('should have created a new user if they did not exist while inviting them to the workspace', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/invite-users`, + payload: [ + { + email: 'joy@keyshade.xyz', + roleSlugs: [memberRole.slug] + } + ] + }) + + expect(response.statusCode).toBe(201) + + // Expect the user to have been created + const user = await prisma.user.findUnique({ + where: { + email: 'joy@keyshade.xyz' + } + }) + + expect(user).toBeDefined() + }) + }) + + describe('Remove Users Tests', () => { + it('should be able to remove users from workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/remove-users`, + payload: [user2.id] + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeNull() + }) + + it('should not be able to remove self from workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/remove-users`, + payload: [user1.email] + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` + }) + }) + + it('should have created a REMOVED_FROM_WORKSPACE event', async () => { + // Create membership + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + // Remove user2 from workspace1 + await workspaceMembershipService.removeUsersFromWorkspace( + user1, + workspace1.slug, + [user2.email] + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.REMOVED_FROM_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Update Membership Tests', () => { + it('should not be able to update the membership to admin role', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [adminRole.slug] + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `Admin role cannot be assigned to the user` + }) + }) + + it('should be able to update the role of a member', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [memberRole.slug] + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + }, + select: { + roles: { + select: { + roleId: true + } + } + } + }) + + expect(membership.roles).toEqual([ + { + roleId: memberRole.id + } + ]) + }) + + it('should have created a WORKSPACE_MEMBERSHIP_UPDATED event', async () => { + // Create membership + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + // Update the membership + await workspaceMembershipService.updateMemberRoles( + user1, + workspace1.slug, + user2.email, + [memberRole.slug] + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_MEMBERSHIP_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + + it('should not be able to update the role of a non existing member', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [] + }) + + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `${user2.email} is not a member of workspace ${workspace1.name} (${workspace1.slug})` + }) + }) + }) + + describe('Cancel Invitation Tests', () => { + it('should be able to cancel the invitation', async () => { + // Invite user2 to workspace1 + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [] + } + ] + ) + + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/cancel-invitation/${user2.email}` + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeNull() + }) + + it('should not be able to cancel the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/cancel-invitation/${user2.email}` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) + }) + + it('should have created a CANCELLED_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [] + } + ] + ) + + // Cancel the invitation + await workspaceMembershipService.cancelInvitation( + user1, + workspace1.slug, + user2.email + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.CANCELLED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Decline Invitation Tests', () => { + it('should be able to decline invitation to the workspace', async () => { + // Send an invitation + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + ) + + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/decline-invitation` + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeNull() + }) + + it('should not be able to decline the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/decline-invitation` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) + }) + + it('should have created a DECLINED_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + ) + + // Decline the invitation + await workspaceMembershipService.declineInvitation(user2, workspace1.slug) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.DECLINED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Accept Invitation Tests', () => { + it('should be able to accept the invitation to the workspace', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/accept-invitation` + }) + + expect(response.statusCode).toBe(201) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeDefined() + expect(membership).toEqual({ + id: expect.any(String), + userId: user2.id, + workspaceId: workspace1.id, + invitationAccepted: true + }) + }) + + it('should not be able to accept the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/accept-invitation` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) + }) + + it('should have created a ACCEPT_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + ) + + // Accept the invitation + await workspaceMembershipService.acceptInvitation(user2, workspace1.slug) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.ACCEPTED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Leave Workspace Tests', () => { + it('should be able to leave the workspace', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/leave` + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeNull() + }) + + it('should not be able to leave the workspace if user is workspace owner', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/leave` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` + }) + }) + + it('should not be able to leave the workspace if the user is not a member', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/leave` + }) + + expect(response.statusCode).toBe(401) + }) + + it('should have created a LEFT_WORKSPACE event', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + // Leave the workspace + await workspaceMembershipService.leaveWorkspace(user2, workspace1.slug) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) + + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.LEFT_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + }) + + describe('Check Membership Tests', () => { + it('should be able to check if user is a member of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/is-member/${user2.email}` + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(false) + }) + + it('should not be able to check if user is a member of the workspace if user is not a member', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/is-member/${user1.email}` + }) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('Get All Members Tests', () => { + it('should be able to get all the members of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${workspace1.slug}/members` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items).toBeInstanceOf(Array) + expect(response.json().items).toHaveLength(1) + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/workspace-membership/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace-membership/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace-membership/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should not be able to get all the members of the workspace if user is not a member', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace-membership/${workspace1.slug}/members` + }) + + expect(response.statusCode).toBe(401) + }) + }) +}) diff --git a/apps/api/src/workspace-membership/workspace-membership.module.ts b/apps/api/src/workspace-membership/workspace-membership.module.ts new file mode 100644 index 00000000..851391d0 --- /dev/null +++ b/apps/api/src/workspace-membership/workspace-membership.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { WorkspaceMembershipService } from './service/workspace-membership.service' +import { WorkspaceMembershipController } from './controller/workspace-membership.controller' + +@Module({ + providers: [WorkspaceMembershipService], + controllers: [WorkspaceMembershipController] +}) +export class WorkspaceMembershipModule {} diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index 25e12ca6..ac11b504 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -10,11 +10,8 @@ import { } from '@nestjs/common' import { WorkspaceService } from '../service/workspace.service' import { CurrentUser } from '@/decorators/user.decorator' -import { Authority, User, Workspace, WorkspaceRole } from '@prisma/client' -import { - CreateWorkspace, - WorkspaceMemberDTO -} from '../dto/create.workspace/create.workspace' +import { Authority, User, Workspace } from '@prisma/client' +import { CreateWorkspace } from '../dto/create.workspace/create.workspace' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' @@ -38,20 +35,6 @@ export class WorkspaceController { return this.workspaceService.updateWorkspace(user, workspaceSlug, dto) } - @Put(':workspaceSlug/transfer-ownership/:userEmail') - @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) - async transferOwnership( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Param('userEmail') userEmail: User['email'] - ) { - return this.workspaceService.transferOwnership( - user, - workspaceSlug, - userEmail - ) - } - @Delete(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.DELETE_WORKSPACE) async delete( @@ -61,127 +44,6 @@ export class WorkspaceController { return this.workspaceService.deleteWorkspace(user, workspaceSlug) } - @Post(':workspaceSlug/invite-users') - @RequiredApiKeyAuthorities(Authority.ADD_USER) - async addUsers( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Body() members: WorkspaceMemberDTO[] - ) { - return this.workspaceService.inviteUsersToWorkspace( - user, - workspaceSlug, - members - ) - } - - @Delete(':workspaceSlug/remove-users') - @RequiredApiKeyAuthorities(Authority.REMOVE_USER) - async removeUsers( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Body() userEmails: User['email'][] - ) { - return this.workspaceService.removeUsersFromWorkspace( - user, - workspaceSlug, - userEmails - ) - } - - @Put(':workspaceSlug/update-member-role/:userEmail') - @RequiredApiKeyAuthorities(Authority.UPDATE_USER_ROLE) - async updateMemberRoles( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Param('userEmail') userEmail: User['email'], - @Body() roleSlugs: WorkspaceRole['slug'][] - ) { - return this.workspaceService.updateMemberRoles( - user, - workspaceSlug, - userEmail, - roleSlugs - ) - } - - @Post(':workspaceSlug/accept-invitation') - @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) - async acceptInvitation( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'] - ) { - return this.workspaceService.acceptInvitation(user, workspaceSlug) - } - - @Delete(':workspaceSlug/decline-invitation') - @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) - async declineInvitation( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'] - ) { - return this.workspaceService.declineInvitation(user, workspaceSlug) - } - - @Delete(':workspaceSlug/cancel-invitation/:userEmail') - @RequiredApiKeyAuthorities(Authority.REMOVE_USER) - async cancelInvitation( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Param('userEmail') userEmail: User['email'] - ) { - return this.workspaceService.cancelInvitation( - user, - workspaceSlug, - userEmail - ) - } - - @Delete(':workspaceSlug/leave') - @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) - async leave( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'] - ) { - return this.workspaceService.leaveWorkspace(user, workspaceSlug) - } - - @Get(':workspaceSlug/is-member/:userEmail') - @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) - async isMember( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Param('userEmail') userEmail: User['email'] - ) { - return this.workspaceService.isUserMemberOfWorkspace( - user, - workspaceSlug, - userEmail - ) - } - - @Get(':workspaceSlug/members') - @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) - async getMembers( - @CurrentUser() user: User, - @Param('workspaceSlug') workspaceSlug: Workspace['slug'], - @Query('page') page: number = 0, - @Query('limit') limit: number = 10, - @Query('sort') sort: string = 'name', - @Query('order') order: string = 'asc', - @Query('search') search: string = '' - ) { - return this.workspaceService.getAllMembersOfWorkspace( - user, - workspaceSlug, - page, - limit, - sort, - order, - search - ) - } - @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getWorkspace( diff --git a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts index 83301ca3..032206bb 100644 --- a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts +++ b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts @@ -9,9 +9,4 @@ export class CreateWorkspace { @IsString() @IsOptional() description?: string -} - -export interface WorkspaceMemberDTO { - email: string - roleSlugs: WorkspaceRole['slug'][] -} +} \ No newline at end of file diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 6821139b..21bc24d8 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -1,13 +1,22 @@ +import { AuthorityCheckerService } from '@/common/authority-checker.service' +import { getCollectiveProjectAuthorities } from '@/common/collective-authorities' +import { createEvent } from '@/common/event' +import { paginate } from '@/common/paginate' +import generateEntitySlug from '@/common/slug-generator' +import { limitMaxItemsPerPage } from '@/common/util' +import { + createWorkspace +} from '@/common/workspace' +import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' +import { PrismaService } from '@/prisma/prisma.service' import { BadRequestException, ConflictException, Inject, Injectable, - InternalServerErrorException, - Logger, - NotFoundException + Logger } from '@nestjs/common' -import { PrismaService } from '@/prisma/prisma.service' +import { JwtService } from '@nestjs/jwt' import { Authority, Environment, @@ -18,26 +27,12 @@ import { Secret, User, Variable, - Workspace, - WorkspaceMember, - WorkspaceRole + Workspace } from '@prisma/client' import { CreateWorkspace, - WorkspaceMemberDTO } from '../dto/create.workspace/create.workspace' -import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' -import { JwtService } from '@nestjs/jwt' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' -import { v4 } from 'uuid' -import { AuthorityCheckerService } from '@/common/authority-checker.service' -import { paginate } from '@/common/paginate' -import generateEntitySlug from '@/common/slug-generator' -import { getUserByEmail } from '@/common/user' -import { createWorkspace } from '@/common/workspace' -import { createEvent } from '@/common/event' -import { limitMaxItemsPerPage } from '@/common/util' -import { getCollectiveProjectAuthorities } from '@/common/collective-authorities' @Injectable() export class WorkspaceService { @@ -47,781 +42,100 @@ export class WorkspaceService { private readonly prisma: PrismaService, private readonly jwt: JwtService, @Inject(MAIL_SERVICE) private readonly mailService: IMailService, - private readonly authorityCheckerService: AuthorityCheckerService - ) {} - - /** - * Creates a new workspace for the given user. - * @throws ConflictException if the workspace with the same name already exists - * @param user The user to create the workspace for - * @param dto The data to create the workspace with - * @returns The created workspace - */ - async createWorkspace(user: User, dto: CreateWorkspace) { - if (await this.existsByName(dto.name, user.id)) { - throw new ConflictException('Workspace already exists') - } - - return await createWorkspace(user, dto, this.prisma) - } - - /** - * Updates a workspace - * @throws ConflictException if the workspace with the same name already exists - * @param user The user to update the workspace for - * @param workspaceSlug The slug of the workspace to update - * @param dto The data to update the workspace with - * @returns The updated workspace - */ - async updateWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - dto: UpdateWorkspace - ) { - // Fetch the workspace - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.UPDATE_WORKSPACE], - - prisma: this.prisma - }) - - // Check if a same named workspace already exists - if ( - (dto.name && (await this.existsByName(dto.name, user.id))) || - dto.name === workspace.name - ) { - throw new ConflictException('Workspace already exists') - } - - const updatedWorkspace = await this.prisma.workspace.update({ - where: { - id: workspace.id - }, - data: { - name: dto.name, - slug: dto.name - ? await generateEntitySlug(dto.name, 'WORKSPACE', this.prisma) - : undefined, - description: dto.description, - lastUpdatedBy: { - connect: { - id: user.id - } - } - } - }) - this.log.debug(`Updated workspace ${workspace.name} (${workspace.id})`) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.WORKSPACE_UPDATED, - source: EventSource.WORKSPACE, - title: `Workspace updated`, - metadata: { - workspaceId: workspace.id, - name: workspace.name - }, - workspaceId: workspace.id - }, - this.prisma - ) - - return updatedWorkspace - } - - /** - * Transfers ownership of a workspace to another user. - * @param user The user transferring the ownership - * @param workspaceSlug The slug of the workspace to transfer - * @param otherUserEmail The email of the user to transfer the ownership to - * @throws BadRequestException if the user is already the owner of the workspace, - * or if the workspace is the default workspace - * @throws NotFoundException if the other user is not a member of the workspace - * @throws InternalServerErrorException if there is an error in the transaction - */ - async transferOwnership( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.WORKSPACE_ADMIN], - - prisma: this.prisma - }) - - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) - - if (otherUser.id === user.id) { - throw new BadRequestException( - `You are already the owner of the workspace ${workspace.name} (${workspace.slug})` - ) - } - - // We don't want the users to be able to transfer - // ownership if the workspace is the default workspace - if (workspace.isDefault) { - throw new BadRequestException( - `You cannot transfer ownership of default workspace ${workspace.name} (${workspace.slug})` - ) - } - - const workspaceMembership = await this.getWorkspaceMembership( - workspace.id, - otherUser.id - ) - - // Check if the user is a member of the workspace - if (!workspaceMembership) { - throw new NotFoundException( - `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` - ) - } - - const currentUserMembership = await this.getWorkspaceMembership( - workspace.id, - user.id - ) - - // Get the admin ownership role - const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ - where: { - workspaceId: workspace.id, - hasAdminAuthority: true - } - }) - - // Remove this role from the current owner - const removeRole = this.prisma.workspaceMemberRoleAssociation.delete({ - where: { - roleId_workspaceMemberId: { - roleId: adminOwnershipRole.id, - workspaceMemberId: currentUserMembership.id - } - } - }) - - // Assign this role to the new owner - const assignRole = this.prisma.workspaceMemberRoleAssociation.create({ - data: { - role: { - connect: { - id: adminOwnershipRole.id - } - }, - workspaceMember: { - connect: { - id: workspaceMembership.id - } - } - } - }) - - // Update the owner of the workspace - const updateWorkspace = this.prisma.workspace.update({ - where: { - id: workspace.id - }, - data: { - ownerId: otherUser.id, - lastUpdatedBy: { - connect: { - id: user.id - } - } - } - }) - - try { - await this.prisma.$transaction([removeRole, assignRole, updateWorkspace]) - } catch (e) { - this.log.error('Error in transaction', e) - throw new InternalServerErrorException('Error in transaction') - } - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.WORKSPACE_UPDATED, - source: EventSource.WORKSPACE, - title: `Workspace transferred`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - newOwnerId: otherUser.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${otherUser.email} (${otherUser.id})` - ) - } - - /** - * Deletes a workspace. - * @throws BadRequestException if the workspace is the default workspace - * @param user The user to delete the workspace for - * @param workspaceSlug The slug of the workspace to delete - */ - async deleteWorkspace( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.DELETE_WORKSPACE], - prisma: this.prisma - }) - - // We don't want the users to delete their default workspace - if (workspace.isDefault) { - throw new BadRequestException( - `You cannot delete the default workspace ${workspace.name} (${workspace.slug})` - ) - } - - // Delete the workspace - await this.prisma.workspace.delete({ - where: { - id: workspace.id - } - }) - - this.log.debug(`Deleted workspace ${workspace.name} (${workspace.slug})`) - } - - /** - * Invites users to a workspace. - * @param user The user to invite the users for - * @param workspaceSlug The slug of the workspace to invite users to - * @param members The members to invite - * @throws BadRequestException if the user does not have the authority to add users to the workspace - * @throws NotFoundException if the workspace or any of the users to invite do not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async inviteUsersToWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - members: WorkspaceMemberDTO[] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.ADD_USER], - prisma: this.prisma - }) - - // Add users to the workspace if any - if (members && members.length > 0) { - await this.addMembersToWorkspace(workspace, user, members) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.INVITED_TO_WORKSPACE, - source: EventSource.WORKSPACE, - title: `Invited users to workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - members: members.map((m) => m.email) - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Added users to workspace ${workspace.name} (${workspace.id})` - ) - - return - } - - this.log.warn( - `No users to add to workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Removes users from a workspace. - * @param user The user to remove users from the workspace for - * @param workspaceSlug The slug of the workspace to remove users from - * @param userEmails The emails of the users to remove from the workspace - * @throws BadRequestException if the user is trying to remove themselves from the workspace, - * or if the user is not a member of the workspace - * @throws NotFoundException if the workspace or any of the users to remove do not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async removeUsersFromWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - userEmails: User['email'][] - ): Promise { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.REMOVE_USER], - prisma: this.prisma - }) - - const userIds = await this.prisma.user - .findMany({ - where: { - email: { - in: userEmails - } - }, - select: { - id: true - } - }) - .then((users) => users.map((u) => u.id)) - - // Remove users from the workspace if any - if (userIds && userIds.length > 0) { - if (userIds.find((id) => id === user.id)) { - throw new BadRequestException( - `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` - ) - } - - // Delete the membership - await this.prisma.workspaceMember.deleteMany({ - where: { - workspaceId: workspace.id, - userId: { - in: userIds - } - } - }) - } - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.REMOVED_FROM_WORKSPACE, - source: EventSource.WORKSPACE, - title: `Removed users from workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - members: userIds - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Removed users from workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Updates the roles of a user in a workspace. - * - * @throws NotFoundException if the user is not a member of the workspace - * @throws BadRequestException if the admin role is tried to be assigned to the user - * @param user The user to update the roles for - * @param workspaceSlug The slug of the workspace to update the roles in - * @param otherUserEmail The email of the user to update the roles for - * @param roleSlugs The slugs of the roles to assign to the user - */ - async updateMemberRoles( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'], - roleSlugs: WorkspaceRole['slug'][] - ): Promise { - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.UPDATE_USER_ROLE], - prisma: this.prisma - }) - - if (!roleSlugs || roleSlugs.length === 0) { - this.log.warn( - `No roles to update for user ${otherUserEmail} in workspace ${workspace.name} (${workspace.id})` - ) - } - - // Check if the member in concern is a part of the workspace or not - if (!(await this.memberExistsInWorkspace(workspace.id, otherUser.id))) - throw new NotFoundException( - `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` - ) - - const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) - - // Check if the admin role is tried to be assigned to the user - if (roleSlugs.includes(workspaceAdminRole.slug)) { - throw new BadRequestException(`Admin role cannot be assigned to the user`) - } - - // Update the role of the user - const membership = await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: otherUser.id - } - } - }) - - // Clear out the existing roles - const deleteExistingAssociations = - this.prisma.workspaceMemberRoleAssociation.deleteMany({ - where: { - workspaceMemberId: membership.id - } - }) - - const roleSet = new Set() - - for (const slug of roleSlugs) { - const role = await this.prisma.workspaceRole.findUnique({ - where: { - slug - } - }) - - if (!role) { - throw new NotFoundException(`Role ${slug} not found`) - } - - roleSet.add(role) - } - - // Create new associations - const createNewAssociations = - this.prisma.workspaceMemberRoleAssociation.createMany({ - data: Array.from(roleSet).map((role) => ({ - roleId: role.id, - workspaceMemberId: membership.id - })) - }) - - await this.prisma.$transaction([ - deleteExistingAssociations, - createNewAssociations - ]) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.WORKSPACE_MEMBERSHIP_UPDATED, - source: EventSource.WORKSPACE, - title: `Updated role of user in workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - userId: otherUser.id, - roleIds: roleSlugs - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `Updated role of user ${otherUser.id} in workspace ${workspace.name} (${workspace.id})` - ) - } - - /** - * Gets all members of a workspace, paginated. - * @param user The user to get the members for - * @param workspaceSlug The slug of the workspace to get the members from - * @param page The page number to get - * @param limit The number of items per page to get - * @param sort The field to sort by - * @param order The order to sort in - * @param search The search string to filter by - * @returns The members of the workspace, paginated, with metadata - */ - async getAllMembersOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - page: number, - limit: number, - sort: string, - order: string, - search: string - ) { - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) - //get all members of workspace for page with limit - const items = await this.prisma.workspaceMember.findMany({ - skip: page * limit, - take: limit, - orderBy: { - workspace: { - [sort]: order - } - }, - where: { - workspaceId: workspace.id, - user: { - OR: [ - { - name: { - contains: search - } - }, - { - email: { - contains: search - } - } - ] - } - }, - select: { - id: true, - user: true, - roles: { - select: { - id: true, - role: { - select: { - id: true, - name: true, - description: true, - colorCode: true, - authorities: true, - projects: { - select: { - id: true - } - } - } - } - } - } - } - }) - - //calculate metadata for pagination - const totalCount = await this.prisma.workspaceMember.count({ - where: { - workspaceId: workspace.id, - user: { - OR: [ - { - name: { - contains: search - } - }, - { - email: { - contains: search - } - } - ] - } - } - }) - - const metadata = paginate( - totalCount, - `/workspace/${workspace.slug}/members`, - { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - } - ) - - return { items, metadata } - } + private readonly authorityCheckerService: AuthorityCheckerService + ) {} /** - * Accepts an invitation to a workspace. - * @param user The user to accept the invitation for - * @param workspaceSlug The slug of the workspace to accept the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace does not exist - * @throws InternalServerErrorException if there is an error in the transaction + * Creates a new workspace for the given user. + * @throws ConflictException if the workspace with the same name already exists + * @param user The user to create the workspace for + * @param dto The data to create the workspace with + * @returns The created workspace */ - async acceptInvitation( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, user) - - const workspace = await this.prisma.workspace.findUnique({ - where: { - slug: workspaceSlug - } - }) - - // Update the membership - await this.prisma.workspaceMember.update({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id - } - }, - data: { - invitationAccepted: true - } - }) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.ACCEPTED_INVITATION, - source: EventSource.WORKSPACE, - title: `${user.name} accepted invitation to workspace ${workspace.name}`, - metadata: { - workspaceId: workspace.id - }, - workspaceId: workspace.id - }, - this.prisma - ) + async createWorkspace(user: User, dto: CreateWorkspace) { + if (await this.existsByName(dto.name, user.id)) { + throw new ConflictException('Workspace already exists') + } - this.log.debug( - `User ${user.name} (${user.id}) accepted invitation to workspace ${workspace.id}` - ) + return await createWorkspace(user, dto, this.prisma) } /** - * Cancels an invitation to a workspace. - * @param user The user cancelling the invitation - * @param workspaceSlug The slug of the workspace to cancel the invitation for - * @param inviteeEmail The email of the user to cancel the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace or the user to cancel the invitation for do not exist - * @throws InternalServerErrorException if there is an error in the transaction + * Updates a workspace + * @throws ConflictException if the workspace with the same name already exists + * @param user The user to update the workspace for + * @param workspaceSlug The slug of the workspace to update + * @param dto The data to update the workspace with + * @returns The updated workspace */ - async cancelInvitation( + async updateWorkspace( user: User, workspaceSlug: Workspace['slug'], - inviteeEmail: User['email'] - ): Promise { - const inviteeUser = await getUserByEmail(inviteeEmail, this.prisma) - + dto: UpdateWorkspace + ) { + // Fetch the workspace const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, entity: { slug: workspaceSlug }, - authorities: [Authority.REMOVE_USER], + authorities: [Authority.UPDATE_WORKSPACE], + prisma: this.prisma }) - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, inviteeUser) - - // Delete the membership - await this.deleteMembership(workspace.id, inviteeUser.id) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.CANCELLED_INVITATION, - source: EventSource.WORKSPACE, - title: `Cancelled invitation to workspace`, - metadata: { - workspaceId: workspace.id, - inviteeId: inviteeUser.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspace.id}` - ) - } - - /** - * Declines an invitation to a workspace. - * @param user The user declining the invitation - * @param workspaceSlug The slug of the workspace to decline the invitation for - * @throws BadRequestException if the user does not have a pending invitation to the workspace - * @throws NotFoundException if the workspace does not exist - * @throws InternalServerErrorException if there is an error in the transaction - */ - async declineInvitation( - user: User, - workspaceSlug: Workspace['slug'] - ): Promise { - // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceSlug, user) + // Check if a same named workspace already exists + if ( + (dto.name && (await this.existsByName(dto.name, user.id))) || + dto.name === workspace.name + ) { + throw new ConflictException('Workspace already exists') + } - const workspace = await this.prisma.workspace.findUnique({ + const updatedWorkspace = await this.prisma.workspace.update({ where: { - slug: workspaceSlug + id: workspace.id + }, + data: { + name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'WORKSPACE', this.prisma) + : undefined, + description: dto.description, + lastUpdatedBy: { + connect: { + id: user.id + } + } } }) - - // Delete the membership - await this.deleteMembership(workspace.id, user.id) + this.log.debug(`Updated workspace ${workspace.name} (${workspace.id})`) await createEvent( { triggeredBy: user, entity: workspace, - type: EventType.DECLINED_INVITATION, + type: EventType.WORKSPACE_UPDATED, source: EventSource.WORKSPACE, - title: `${user.name} declined invitation to workspace ${workspace.name}`, + title: `Workspace updated`, metadata: { - workspaceId: workspace.id + workspaceId: workspace.id, + name: workspace.name }, workspaceId: workspace.id }, this.prisma ) - this.log.debug( - `User ${user.name} (${user.id}) declined invitation to workspace ${workspace.id}` - ) + return updatedWorkspace } /** - * Leaves a workspace. - * @throws BadRequestException if the user is the owner of the workspace - * @param user The user to leave the workspace for - * @param workspaceSlug The slug of the workspace to leave + * Deletes a workspace. + * @throws BadRequestException if the workspace is the default workspace + * @param user The user to delete the workspace for + * @param workspaceSlug The slug of the workspace to delete */ - async leaveWorkspace( + async deleteWorkspace( user: User, workspaceSlug: Workspace['slug'] ): Promise { @@ -829,73 +143,25 @@ export class WorkspaceService { await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, entity: { slug: workspaceSlug }, - authorities: [Authority.READ_WORKSPACE], + authorities: [Authority.DELETE_WORKSPACE], prisma: this.prisma }) - const workspaceOwnerId = await this.prisma.workspace - .findUnique({ - where: { - id: workspace.id - }, - select: { - ownerId: true - } - }) - .then((workspace) => workspace.ownerId) - - // Check if the user is the owner of the workspace - if (workspaceOwnerId === user.id) + // We don't want the users to delete their default workspace + if (workspace.isDefault) { throw new BadRequestException( - `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` + `You cannot delete the default workspace ${workspace.name} (${workspace.slug})` ) + } - // Delete the membership - await this.deleteMembership(workspace.id, user.id) - - await createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.LEFT_WORKSPACE, - source: EventSource.WORKSPACE, - title: `User left workspace`, - metadata: { - workspaceId: workspace.id - }, - workspaceId: workspace.id - }, - this.prisma - ) - - this.log.debug( - `User ${user.name} (${user.id}) left workspace ${workspace.id}` - ) - } - - /** - * Checks if a user is a member of a workspace. - * @param user The user to check if the other user is a member of the workspace for - * @param workspaceSlug The slug of the workspace to check if the user is a member of - * @param otherUserEmail The email of the user to check if is a member of the workspace - * @returns True if the user is a member of the workspace, false otherwise - */ - async isUserMemberOfWorkspace( - user: User, - workspaceSlug: Workspace['slug'], - otherUserEmail: User['email'] - ): Promise { - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) - - const workspace = - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) + // Delete the workspace + await this.prisma.workspace.delete({ + where: { + id: workspace.id + } + }) - return await this.memberExistsInWorkspace(workspace.id, otherUser.id) + this.log.debug(`Deleted workspace ${workspace.name} (${workspace.slug})`) } /** @@ -1301,245 +567,4 @@ export class WorkspaceService { })) > 0 ) } - - private async getWorkspaceAdminRole( - workspaceId: Workspace['id'] - ): Promise { - const adminRole = await this.prisma.workspaceRole.findFirst({ - where: { - hasAdminAuthority: true, - workspaceId - } - }) - - if (!adminRole) { - throw new InternalServerErrorException( - `Admin role not found for workspace ${workspaceId}` - ) - } - - return adminRole - } - - /** - * Adds members to a workspace. - * @param workspace The workspace to add members to - * @param currentUser The user performing the action - * @param members The members to add to the workspace - * @throws BadRequestException if the admin role is tried to be assigned to the user - * @throws ConflictException if the user is already a member of the workspace - * @throws InternalServerErrorException if there is an error in the transaction - * @private - */ - private async addMembersToWorkspace( - workspace: Workspace, - currentUser: User, - members: WorkspaceMemberDTO[] - ) { - const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) - - for (const member of members) { - // Check if the admin role is tried to be assigned to the user - if (member.roleSlugs.includes(workspaceAdminRole.slug)) { - throw new BadRequestException( - `Admin role cannot be assigned to the user` - ) - } - - const memberUser: User | null = await this.prisma.user.findUnique({ - where: { - email: member.email - } - }) - - const userId = memberUser?.id ?? v4() - - // Check if the user is already a member of the workspace - if ( - memberUser && - (await this.memberExistsInWorkspace(workspace.id, userId)) - ) { - this.log.warn( - `User ${ - memberUser.name ?? 'NO_NAME_YET' - } (${userId}) is already a member of workspace ${workspace.name} (${ - workspace.slug - }). Skipping.` - ) - throw new ConflictException( - `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.slug})` - ) - } - - const roleSet = new Set() - - for (const slug of member.roleSlugs) { - const role = await this.prisma.workspaceRole.findUnique({ - where: { - slug - } - }) - - if (!role) { - throw new NotFoundException(`Workspace role ${slug} does not exist`) - } - - roleSet.add(role) - } - - // Create the workspace membership - const createMembership = this.prisma.workspaceMember.create({ - data: { - workspaceId: workspace.id, - userId, - roles: { - create: Array.from(roleSet).map((role) => ({ - role: { - connect: { - id: role.id - } - } - })) - } - } - }) - - if (memberUser) { - await this.prisma.$transaction([createMembership]) - - this.mailService.workspaceInvitationMailForUsers( - member.email, - workspace.name, - `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.id}/join`, - currentUser.name, - true - ) - - this.log.debug( - `Sent workspace invitation mail to registered user ${memberUser}` - ) - } else { - const createMember = this.prisma.user.create({ - data: { - id: userId, - email: member.email, - isOnboardingFinished: false - } - }) - - await this.prisma.$transaction([createMember, createMembership]) - - this.log.debug(`Created non-registered user ${memberUser}`) - - this.mailService.workspaceInvitationMailForUsers( - member.email, - workspace.name, - `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${ - workspace.id - }/join?token=${await this.jwt.signAsync({ - id: userId - })}`, - currentUser.name, - false - ) - - this.log.debug( - `Sent workspace invitation mail to non-registered user ${memberUser}` - ) - } - - this.log.debug(`Added user ${memberUser} to workspace ${workspace.name}.`) - } - } - - /** - * Checks if a user is a member of a workspace. - * @param workspaceId The ID of the workspace to check - * @param userId The ID of the user to check - * @returns True if the user is a member of the workspace, false otherwise - * @private - */ - private async memberExistsInWorkspace( - workspaceId: string, - userId: string - ): Promise { - return ( - (await this.prisma.workspaceMember.count({ - where: { - workspaceId, - userId - } - })) > 0 - ) - } - - /** - * Gets the workspace membership of a user in a workspace. - * @param workspaceId The ID of the workspace to get the membership for - * @param userId The ID of the user to get the membership for - * @returns The workspace membership of the user in the workspace - * @private - */ - private async getWorkspaceMembership( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - return await this.prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) - } - - /** - * Deletes the membership of a user in a workspace. - * @param workspaceId The ID of the workspace to delete the membership from - * @param userId The ID of the user to delete the membership for - * @returns A promise that resolves when the membership is deleted - * @private - */ - private async deleteMembership( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - await this.prisma.workspaceMember.delete({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) - } - - /** - * Checks if a user has a pending invitation to a workspace. - * @throws BadRequestException if the user is not invited to the workspace - * @param workspaceSlug The slug of the workspace to check if the user is invited to - * @param user The user to check if the user is invited to the workspace - */ - private async checkInvitationPending( - workspaceSlug: Workspace['slug'], - user: User - ): Promise { - const membershipExists = await this.prisma.workspaceMember - .count({ - where: { - workspace: { - slug: workspaceSlug - }, - userId: user.id, - invitationAccepted: false - } - }) - .then((count) => count > 0) - - if (!membershipExists) - throw new BadRequestException( - `${user.email} is not invited to workspace ${workspaceSlug}` - ) - } } diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 70767ea6..098452cb 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -393,734 +393,6 @@ describe('Workspace Controller Tests', () => { }) }) - describe('Invite User Tests', () => { - it('should do nothing if null or empty array is sent for invitation of user', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/invite-users`, - payload: [] - }) - - expect(response.statusCode).toBe(201) - }) - - it('should not allow user to invite another user ', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/invite-users`, - payload: [ - { - email: user2.email, - roleSlugs: [adminRole.slug] - } - ] - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `Admin role cannot be assigned to the user` - }) - }) - - it('should allow user to invite another user to the workspace', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/invite-users`, - payload: [ - { - email: user2.email, - roleSlugs: [memberRole.slug] - } - ] - }) - - expect(response.statusCode).toBe(201) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeDefined() - expect(membership).toEqual({ - id: expect.any(String), - userId: user2.id, - workspaceId: workspace1.id, - invitationAccepted: false - }) - }) - - it('should not be able to add an existing user to the workspace', async () => { - // Add user2 to workspace1 - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/invite-users`, - payload: [ - { - email: user2.email, - roleSlugs: [] - } - ] - }) - - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: `User ${user2.name} (${user2.id}) is already a member of workspace ${workspace1.name} (${workspace1.slug})` - }) - }) - - it('should have created a INVITED_TO_WORKSPACE event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [] - } - ]) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.INVITED_TO_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - - it('should have created a new user if they did not exist while inviting them to the workspace', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/invite-users`, - payload: [ - { - email: 'joy@keyshade.xyz', - roleSlugs: [memberRole.slug] - } - ] - }) - - expect(response.statusCode).toBe(201) - - // Expect the user to have been created - const user = await prisma.user.findUnique({ - where: { - email: 'joy@keyshade.xyz' - } - }) - - expect(user).toBeDefined() - }) - }) - - describe('Update Membership Tests', () => { - it('should not be able to update the membership to admin role', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, - payload: [adminRole.slug] - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `Admin role cannot be assigned to the user` - }) - }) - - it('should be able to update the role of a member', async () => { - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, - payload: [memberRole.slug] - }) - - expect(response.statusCode).toBe(200) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - }, - select: { - roles: { - select: { - roleId: true - } - } - } - }) - - expect(membership.roles).toEqual([ - { - roleId: memberRole.id - } - ]) - }) - - it('should have created a WORKSPACE_MEMBERSHIP_UPDATED event', async () => { - // Create membership - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - - // Update the membership - await workspaceService.updateMemberRoles( - user1, - workspace1.slug, - user2.email, - [memberRole.slug] - ) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_MEMBERSHIP_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - - it('should not be able to update the role of a non existing member', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, - payload: [] - }) - - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `${user2.email} is not a member of workspace ${workspace1.name} (${workspace1.slug})` - }) - }) - }) - - describe('Cancel Invitation Tests', () => { - it('should be able to cancel the invitation', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [] - } - ]) - - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/cancel-invitation/${user2.email}` - }) - - expect(response.statusCode).toBe(200) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeNull() - }) - - it('should not be able to cancel the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/cancel-invitation/${user2.email}` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `${user2.email} is not invited to workspace ${workspace1.slug}` - }) - }) - - it('should have created a CANCELLED_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [] - } - ]) - - // Cancel the invitation - await workspaceService.cancelInvitation( - user1, - workspace1.slug, - user2.email - ) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.CANCELLED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Decline Invitation Tests', () => { - it('should be able to decline invitation to the workspace', async () => { - // Send an invitation - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [memberRole.slug] - } - ]) - - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/decline-invitation` - }) - - expect(response.statusCode).toBe(200) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeNull() - }) - - it('should not be able to decline the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/decline-invitation` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `${user2.email} is not invited to workspace ${workspace1.slug}` - }) - }) - - it('should have created a DECLINED_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [memberRole.slug] - } - ]) - - // Decline the invitation - await workspaceService.declineInvitation(user2, workspace1.slug) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.DECLINED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Accept Invitation Tests', () => { - it('should be able to accept the invitation to the workspace', async () => { - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/accept-invitation` - }) - - expect(response.statusCode).toBe(201) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeDefined() - expect(membership).toEqual({ - id: expect.any(String), - userId: user2.id, - workspaceId: workspace1.id, - invitationAccepted: true - }) - }) - - it('should not be able to accept the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/accept-invitation` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `${user2.email} is not invited to workspace ${workspace1.slug}` - }) - }) - - it('should have created a ACCEPT_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [memberRole.slug] - } - ]) - - // Accept the invitation - await workspaceService.acceptInvitation(user2, workspace1.slug) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.ACCEPTED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Leave Workspace Tests', () => { - it('should be able to leave the workspace', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/leave` - }) - - expect(response.statusCode).toBe(200) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeNull() - }) - - it('should not be able to leave the workspace if user is workspace owner', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/leave` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` - }) - }) - - it('should not be able to leave the workspace if the user is not a member', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/leave` - }) - - expect(response.statusCode).toBe(401) - }) - - it('should have created a LEFT_WORKSPACE event', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - - // Leave the workspace - await workspaceService.leaveWorkspace(user2, workspace1.slug) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.LEFT_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Remove Users Tests', () => { - it('should be able to remove users from workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/remove-users`, - payload: [user2.id] - }) - - expect(response.statusCode).toBe(200) - - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) - - expect(membership).toBeNull() - }) - - it('should not be able to remove self from workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/remove-users`, - payload: [user1.email] - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` - }) - }) - - it('should have created a REMOVED_FROM_WORKSPACE event', async () => { - // Create membership - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - - // Remove user2 from workspace1 - await workspaceService.removeUsersFromWorkspace(user1, workspace1.slug, [ - user2.email - ]) - - const response = await fetchEvents( - eventService, - user1, - workspace1.slug, - EventSource.WORKSPACE - ) - - const event = response.items[0] - - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.REMOVED_FROM_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - }) - - describe('Check Membership Tests', () => { - it('should be able to check if user is a member of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/is-member/${user2.email}` - }) - - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual(false) - }) - - it('should not be able to check if user is a member of the workspace if user is not a member', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/is-member/${user1.email}` - }) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('Get All Members Tests', () => { - it('should be able to get all the members of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/members` - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items).toBeInstanceOf(Array) - expect(response.json().items).toHaveLength(1) - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` - ) - }) - - it('should not be able to get all the members of the workspace if user is not a member', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/members` - }) - - expect(response.statusCode).toBe(401) - }) - }) - describe('Get Workspace Tests', () => { it('should be able to fetch the workspace by slug', async () => { const response = await app.inject({ @@ -1148,135 +420,6 @@ describe('Workspace Controller Tests', () => { }) }) - describe('Change Ownership Tests', () => { - it('should prevent external user from changing ownership of workspace', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.slug}/transfer-ownership/${user1.id}` - }) - - expect(response.statusCode).toBe(401) - }) - - it('should not be able to transfer the ownership to self', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/transfer-ownership/${user1.email}` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You are already the owner of the workspace ${workspace1.name} (${workspace1.slug})` - }) - }) - - it('should not be able to transfer ownership to a non member', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) - - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user3.email}` - }) - - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `${user3.email} is not a member of workspace ${newWorkspace.name} (${newWorkspace.slug})` - }) - }) - - it('should be able to transfer the ownership of the workspace', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) - - // Create membership - await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) - - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user2.email}` - }) - - expect(response.statusCode).toBe(200) - - const workspace = await prisma.workspace.findUnique({ - where: { - id: newWorkspace.id - } - }) - - expect(workspace.ownerId).toEqual(user2.id) - }) - - it('should not be able to transfer ownership if is not admin', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) - - // Create membership - await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) - - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user3.email}` - }) - - expect(response.statusCode).toBe(401) - }) - - it('should not be able to transfer ownership of default workspace', async () => { - // Invite another user to the workspace - await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: user2.email, - roleSlugs: [memberRole.slug] - } - ]) - - // Accept the invitation - await workspaceService.acceptInvitation(user2, workspace1.slug) - - // Try transferring ownership - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.slug}/transfer-ownership/${user2.email}` - }) - - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot transfer ownership of default workspace ${workspace1.name} (${workspace1.slug})` - }) - }) - }) - describe('Get All Workspace Of User Tests', () => { it('should be able to fetch all the workspaces the user is a member of', async () => { await createMembership(memberRole.id, user2.id, workspace1.id, prisma)