diff --git a/api-collection/Workspace Controller/Get all invitations of user.bru b/api-collection/Workspace Controller/Get all invitations of user.bru new file mode 100644 index 00000000..7912da15 --- /dev/null +++ b/api-collection/Workspace Controller/Get all invitations of user.bru @@ -0,0 +1,21 @@ +meta { + name: Get all invitations of user to workspaces + type: http + seq: 3 +} + +get { + url: {{BASE_URL}}/api/workspace/invitations?page=0&limit=10 + body: none + auth: bearer +} + +auth:bearer { + token: {{JWT}} +} + +docs { + ## Description + + Fetches all the workspaces where the user is invited to. +} diff --git a/apps/api/src/environment/dto/create.environment/create.environment.ts b/apps/api/src/environment/dto/create.environment/create.environment.ts index ffa62e3f..ae2b7db8 100644 --- a/apps/api/src/environment/dto/create.environment/create.environment.ts +++ b/apps/api/src/environment/dto/create.environment/create.environment.ts @@ -3,7 +3,6 @@ import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator' export class CreateEnvironment { @IsString() @IsNotEmpty() - @Matches(/^[a-zA-Z0-9-_]{1,64}$/) name: string @IsString() diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index ad949f1c..607705ef 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -28,6 +28,7 @@ import { UserModule } from '@/user/user.module' import { UserService } from '@/user/service/user.service' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { fetchEvents } from '@/common/event' +import { ValidationPipe } from '@nestjs/common' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -65,7 +66,7 @@ describe('Environment Controller Tests', () => { environmentService = moduleRef.get(EnvironmentService) userService = moduleRef.get(UserService) - app.useGlobalPipes(new QueryTransformPipe()) + app.useGlobalPipes(new ValidationPipe(), new QueryTransformPipe()) await app.init() await app.getHttpAdapter().getInstance().ready() @@ -184,7 +185,7 @@ describe('Environment Controller Tests', () => { 'x-e2e-user-email': user1.email } }) - + expect(response.statusCode).toBe(400) expect(response.json().message).toContain('name should not be empty') }) diff --git a/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql b/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql new file mode 100644 index 00000000..ded8dff4 --- /dev/null +++ b/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WorkspaceMember" ADD COLUMN "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 5aaa434c..d64780ef 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -339,6 +339,7 @@ model WorkspaceMember { workspaceId String invitationAccepted Boolean @default(false) roles WorkspaceMemberRoleAssociation[] + createdOn DateTime @default(now()) @@unique([workspaceId, userId]) } diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.ts index d10032c5..6bcf5d89 100644 --- a/apps/api/src/workspace-membership/service/workspace-membership.service.ts +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.ts @@ -879,11 +879,14 @@ export class WorkspaceMembershipService { roleSet.add(role) } + const invitedOn = new Date() + // Create the workspace membership const createMembership = this.prisma.workspaceMember.create({ data: { workspaceId: workspace.id, userId, + createdOn: invitedOn, roles: { create: Array.from(roleSet).map((role) => ({ role: { @@ -904,7 +907,7 @@ export class WorkspaceMembershipService { workspace.name, `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.slug}/join`, currentUser.name, - new Date().toISOString(), + invitedOn.toISOString(), true ) diff --git a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts index a606a17d..9c1222c6 100644 --- a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts +++ b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts @@ -425,7 +425,8 @@ describe('Workspace Membership Controller Tests', () => { id: expect.any(String), userId: user2.id, workspaceId: workspace1.id, - invitationAccepted: false + invitationAccepted: false, + createdOn: expect.any(Date) }) }) @@ -909,7 +910,8 @@ describe('Workspace Membership Controller Tests', () => { id: expect.any(String), userId: user2.id, workspaceId: workspace1.id, - invitationAccepted: true + invitationAccepted: true, + createdOn: expect.any(Date) }) }) diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index ac11b504..5356caa7 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -44,6 +44,26 @@ export class WorkspaceController { return this.workspaceService.deleteWorkspace(user, workspaceSlug) } + @Get('invitations') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async getAllInvitationsOfUser( + @CurrentUser() user: User, + @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.getAllWorkspaceInvitations( + user, + page, + limit, + sort, + order, + search + ) + } + @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getWorkspace( diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 2a7ec59d..b965a7d3 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -385,6 +385,111 @@ export class WorkspaceService { return { projects, environments, secrets, variables } } + /** + * Gets all the invitations a user has to various workspaces, paginated. + * @param user The user to get the workspaces for + * @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 workspace invitations of the user, paginated, with metadata + */ + async getAllWorkspaceInvitations( + user: User, + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + // fetch all workspaces of user where they are not admin + const items = await this.prisma.workspaceMember.findMany({ + skip: page * limit, + take: limitMaxItemsPerPage(Number(limit)), + orderBy: { + workspace: { + [sort]: order + } + }, + where: { + userId: user.id, + invitationAccepted: false, + workspace: { + name: { + contains: search + } + }, + roles: { + none: { + role: { + hasAdminAuthority: true + } + } + } + }, + select: { + workspace: { + select: { + id: true, + name: true, + slug: true, + icon: true + } + }, + roles: { + select: { + role: { + select: { + name: true, + colorCode: true + } + } + } + }, + createdOn: true + } + }) + + // get total count of workspaces of the user + const totalCount = await this.prisma.workspaceMember.count({ + where: { + userId: user.id, + invitationAccepted: false, + workspace: { + name: { + contains: search + } + }, + roles: { + none: { + role: { + hasAdminAuthority: true + } + } + } + } + }) + + //calculate metadata for pagination + const metadata = paginate(totalCount, `/workspace/invitations`, { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + }) + + return { + items: items.map((item) => ({ + ...item, + invitedOn: item.createdOn, + createdOn: undefined + })), + metadata + } + } + /** * Gets a list of project IDs that the user has access to READ. * The user has access to a project if the project is global or if the user has the READ_PROJECT authority. diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 5eba8c47..30960866 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -35,6 +35,8 @@ import { SecretService } from '@/secret/service/secret.service' import { VariableService } from '@/variable/service/variable.service' import { WorkspaceRoleService } from '@/workspace-role/service/workspace-role.service' import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' +import { WorkspaceMembershipService } from '@/workspace-membership/service/workspace-membership.service' +import { WorkspaceMembershipModule } from '@/workspace-membership/workspace-membership.module' import { fetchEvents } from '@/common/event' const createMembership = async ( @@ -71,6 +73,7 @@ describe('Workspace Controller Tests', () => { let secretService: SecretService let variableService: VariableService let workspaceRoleService: WorkspaceRoleService + let workspaceMembershipService: WorkspaceMembershipService let user1: User, user2: User let workspace1: Workspace, workspace2: Workspace @@ -87,7 +90,8 @@ describe('Workspace Controller Tests', () => { EnvironmentModule, SecretModule, VariableModule, - WorkspaceRoleModule + WorkspaceRoleModule, + WorkspaceMembershipModule ] }) .overrideProvider(MAIL_SERVICE) @@ -106,6 +110,7 @@ describe('Workspace Controller Tests', () => { secretService = moduleRef.get(SecretService) variableService = moduleRef.get(VariableService) workspaceRoleService = moduleRef.get(WorkspaceRoleService) + workspaceMembershipService = moduleRef.get(WorkspaceMembershipService) app.useGlobalPipes(new QueryTransformPipe()) @@ -179,6 +184,7 @@ describe('Workspace Controller Tests', () => { expect(secretService).toBeDefined() expect(variableService).toBeDefined() expect(workspaceRoleService).toBeDefined() + expect(workspaceMembershipService).toBeDefined() }) describe('Create Workspace Tests', () => { @@ -305,7 +311,8 @@ describe('Workspace Controller Tests', () => { id: expect.any(String), userId: user1.id, workspaceId: workspace1.id, - invitationAccepted: true + invitationAccepted: true, + createdOn: expect.any(Date) }) }) }) @@ -481,6 +488,107 @@ describe('Workspace Controller Tests', () => { }) }) + describe('Get All Workspace Invitations Tests', () => { + it('should be able to fetch all the non accepted invitations of the user', async () => { + //invite user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + + expect(body.items).toHaveLength(1) + expect(body.items[0].workspace.slug).not.toBe(workspace2.slug) + expect(body.items[0]).toEqual({ + invitedOn: expect.any(String), + workspace: { + icon: workspace1.icon, + id: workspace1.id, + name: workspace1.name, + slug: workspace1.slug + }, + roles: [ + { + role: { + name: memberRole.name, + colorCode: memberRole.colorCode + } + } + ] + }) + expect(body.metadata.totalCount).toBe(1) + expect(body.metadata.links.self).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(body.metadata.links.first).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(body.metadata.links.previous).toBeNull() + expect(body.metadata.links.next).toBeNull() + expect(body.metadata.links.last).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch empty list of workspace invitations for the user once all invitations are accepted', async () => { + //invite user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + // accept the invitation for user2 to workspace1 + await workspaceMembershipService.acceptInvitation(user2, workspace1.slug) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + expect(body.items).toHaveLength(0) + expect(body.metadata).toEqual({}) + }) + + it('should be able to fetch empty list of workspace invitations for the user if ownership is transferred', async () => { + //create a new workspace for user 1 + const workspace3 = await workspaceService.createWorkspace(user1, { + name: 'Workspace 3' + }) + + //invite user2 to workspace3 + await createMembership(memberRole.id, user2.id, workspace3.id, prisma) + + //accept the invitation for user2 to workspace3 + await workspaceMembershipService.acceptInvitation(user2, workspace3.slug) + + //transfer ownership of workspace1 to user2 + await workspaceMembershipService.transferOwnership( + user1, + workspace3.slug, + user2.email + ) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + expect(body.items).toHaveLength(0) + expect(body.metadata).toEqual({}) + }) + }) + describe('Export Data Tests', () => { it('should not be able to export data of a non-existing workspace', async () => { const response = await app.inject({