diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts index 5b17acbc7a..847f5b93e9 100644 --- a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts @@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => { return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404); }); + it("should filter memberships by single email", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].user.email).toEqual(teamAdminEmail); + expect(responseBody.data[0].userId).toEqual(teamAdmin.id); + expect(responseBody.data[0].role).toEqual("ADMIN"); + }); + }); + + it("should filter memberships by multiple emails", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(2); + + const emails = responseBody.data.map((membership) => membership.user.email); + expect(emails).toContain(teamAdminEmail); + expect(emails).toContain(teamMemberEmail); + + const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail); + const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail); + + expect(adminMembership).toBeDefined(); + expect(memberMembership).toBeDefined(); + expect(adminMembership?.role).toEqual("ADMIN"); + expect(memberMembership?.role).toEqual("MEMBER"); + }); + }); + + it("should return empty array when filtering by non-existent email", async () => { + const nonExistentEmail = `nonexistent-${randomString()}@test.com`; + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(0); + }); + }); + + it("should return partial results when filtering by mix of existing and non-existent emails", async () => { + const nonExistentEmail = `nonexistent-${randomString()}@test.com`; + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].user.email).toEqual(teamAdminEmail); + }); + }); + + it("should work with pagination and email filtering combined", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(1); + const returnedEmail = responseBody.data[0].user.email; + expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail); + }); + }); + + it("should handle empty emails array gracefully", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(2); + }); + }); + + it("should handle URL encoded email addresses in filter", async () => { + const encodedEmail = encodeURIComponent(teamAdminEmail); + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].user.email).toEqual(teamAdminEmail); + }); + }); + + it("should filter by email and maintain all user properties", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`) + .expect(200) + .then((response) => { + const responseBody: GetTeamMembershipsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.length).toEqual(1); + const membership = responseBody.data[0]; + expect(membership.user.email).toEqual(teamMemberEmail); + expect(membership.user.bio).toEqual(teamMember.bio); + expect(membership.user.metadata).toEqual(teamMember.metadata); + expect(membership.user.username).toEqual(teamMember.username); + expect(membership.teamId).toEqual(team.id); + expect(membership.userId).toEqual(teamMember.id); + expect(membership.role).toEqual("MEMBER"); + }); + }); + + it("should validate email array size limits", async () => { + const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(","); + return request(app.getHttpServer()) + .get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`) + .expect(400); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(teamAdmin.email); await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email); diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts index ab3fd1b61d..56b60adb66 100644 --- a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts @@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; +import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input"; import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output"; import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output"; @@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types"; -import { SkipTakePagination } from "@calcom/platform-types"; @Controller({ path: "/v2/teams/:teamId/memberships", @@ -85,16 +85,20 @@ export class TeamsMembershipsController { } @Get("/") - @ApiOperation({ summary: "Get all memberships" }) + @ApiOperation({ + summary: "Get all memberships", + description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.", + }) @Roles("TEAM_ADMIN") @HttpCode(HttpStatus.OK) async getTeamMemberships( @Param("teamId", ParseIntPipe) teamId: number, - @Query() queryParams: SkipTakePagination + @Query() queryParams: GetTeamMembershipsInput ): Promise { - const { skip, take } = queryParams; + const { skip, take, emails } = queryParams; const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships( teamId, + emails, skip ?? 0, take ?? 250 ); diff --git a/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts b/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts new file mode 100644 index 0000000000..2b2c08cb30 --- /dev/null +++ b/apps/api/v2/src/modules/teams/memberships/inputs/get-team-memberships.input.ts @@ -0,0 +1,32 @@ +import { GetUsersInput } from "@/modules/users/inputs/get-users.input"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator"; + +export class GetTeamMembershipsInput extends GetUsersInput { + @IsOptional() + @Transform(({ value }) => { + if (value == null) return undefined; + const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) => + typeof entry === "string" ? entry.split(",") : [] + ); + const normalized = rawValues + .map((email) => email.trim()) + .filter((email) => email.length > 0) + .map((email) => email.toLowerCase()); + const deduplicated = [...new Set(normalized)]; + return deduplicated.length > 0 ? deduplicated : undefined; + }) + @ArrayNotEmpty({ message: "emails cannot be empty." }) + @ArrayMaxSize(20, { + message: "emails array cannot contain more than 20 email addresses for team membership filtering", + }) + @IsEmail({}, { each: true, message: "Each email must be a valid email address" }) + @ApiPropertyOptional({ + type: [String], + description: + "Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).", + example: "?emails=user1@example.com,user2@example.com", + }) + emails?: string[]; +} diff --git a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts index dfb952d92e..7635773d58 100644 --- a/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts +++ b/apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts @@ -23,13 +23,15 @@ export class TeamsMembershipsService { return teamMembership; } - async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) { - const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated( + async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) { + const emailArray = !emails ? [] : emails; + + return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters( teamId, + { emails: emailArray }, skip, take ); - return teamMemberships; } async getTeamMembership(teamId: number, membershipId: number) { diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts index d7d301223c..d298ebd767 100644 --- a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts @@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common"; import type { Prisma } from "@calcom/prisma/client"; +export interface TeamMembershipFilters { + emails?: string[]; +} + export const MembershipUserSelect: Prisma.UserSelect = { username: true, email: true, @@ -41,6 +45,30 @@ export class TeamsMembershipsRepository { }); } + async findTeamMembershipsPaginatedWithFilters( + teamId: number, + filters: TeamMembershipFilters, + skip: number, + take: number + ) { + const whereClause: Prisma.MembershipWhereInput = { + teamId: teamId, + }; + + if (filters.emails && filters.emails.length > 0) { + whereClause.user = { + email: { in: filters.emails }, + }; + } + + return await this.dbRead.prisma.membership.findMany({ + where: whereClause, + include: { user: { select: MembershipUserSelect } }, + skip, + take, + }); + } + async findTeamMembership(teamId: number, membershipId: number) { return this.dbRead.prisma.membership.findUnique({ where: { diff --git a/apps/api/v2/src/modules/users/inputs/get-users.input.ts b/apps/api/v2/src/modules/users/inputs/get-users.input.ts index 5d7908bb3c..ad962afbec 100644 --- a/apps/api/v2/src/modules/users/inputs/get-users.input.ts +++ b/apps/api/v2/src/modules/users/inputs/get-users.input.ts @@ -28,6 +28,7 @@ export class GetUsersInput { @ApiPropertyOptional({ type: [String], description: "The email address or an array of email addresses to filter by", + example: ["user1@example.com", "user2@example.com"], }) emails?: string[]; }