Skip to content

Commit de195a1

Browse files
feat: add email filtering to team memberships endpoint (calcom#23923)
* Controller Layer updates. * adding email filtering and pagination to team memberships endpoint.. * Minor enhancements. * Improve addressed. * refactor: update team memberships input to handle comma-separated emails - Replace array format with comma-separated string handling - Add proper email validation with BadRequestException - Remove ArrayMaxSize constraint for better flexibility - Update API documentation and examples - Align with codebase patterns from get-managed-users.input.ts * Morgan suggestions addressed. * More improvements......
1 parent d7d2487 commit de195a1

6 files changed

Lines changed: 199 additions & 7 deletions

File tree

apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,131 @@ describe("Teams Memberships Endpoints", () => {
328328
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
329329
});
330330

331+
it("should filter memberships by single email", async () => {
332+
return request(app.getHttpServer())
333+
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail}`)
334+
.expect(200)
335+
.then((response) => {
336+
const responseBody: GetTeamMembershipsOutput = response.body;
337+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
338+
expect(responseBody.data.length).toEqual(1);
339+
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
340+
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
341+
expect(responseBody.data[0].role).toEqual("ADMIN");
342+
});
343+
});
344+
345+
it("should filter memberships by multiple emails", async () => {
346+
return request(app.getHttpServer())
347+
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}`)
348+
.expect(200)
349+
.then((response) => {
350+
const responseBody: GetTeamMembershipsOutput = response.body;
351+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
352+
expect(responseBody.data.length).toEqual(2);
353+
354+
const emails = responseBody.data.map((membership) => membership.user.email);
355+
expect(emails).toContain(teamAdminEmail);
356+
expect(emails).toContain(teamMemberEmail);
357+
358+
const adminMembership = responseBody.data.find((m) => m.user.email === teamAdminEmail);
359+
const memberMembership = responseBody.data.find((m) => m.user.email === teamMemberEmail);
360+
361+
expect(adminMembership).toBeDefined();
362+
expect(memberMembership).toBeDefined();
363+
expect(adminMembership?.role).toEqual("ADMIN");
364+
expect(memberMembership?.role).toEqual("MEMBER");
365+
});
366+
});
367+
368+
it("should return empty array when filtering by non-existent email", async () => {
369+
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
370+
return request(app.getHttpServer())
371+
.get(`/v2/teams/${team.id}/memberships?emails=${nonExistentEmail}`)
372+
.expect(200)
373+
.then((response) => {
374+
const responseBody: GetTeamMembershipsOutput = response.body;
375+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
376+
expect(responseBody.data.length).toEqual(0);
377+
});
378+
});
379+
380+
it("should return partial results when filtering by mix of existing and non-existent emails", async () => {
381+
const nonExistentEmail = `nonexistent-${randomString()}@test.com`;
382+
return request(app.getHttpServer())
383+
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${nonExistentEmail}`)
384+
.expect(200)
385+
.then((response) => {
386+
const responseBody: GetTeamMembershipsOutput = response.body;
387+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
388+
expect(responseBody.data.length).toEqual(1);
389+
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
390+
});
391+
});
392+
393+
it("should work with pagination and email filtering combined", async () => {
394+
return request(app.getHttpServer())
395+
.get(`/v2/teams/${team.id}/memberships?emails=${teamAdminEmail},${teamMemberEmail}&skip=1&take=1`)
396+
.expect(200)
397+
.then((response) => {
398+
const responseBody: GetTeamMembershipsOutput = response.body;
399+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
400+
expect(responseBody.data.length).toEqual(1);
401+
const returnedEmail = responseBody.data[0].user.email;
402+
expect([teamAdminEmail, teamMemberEmail]).toContain(returnedEmail);
403+
});
404+
});
405+
406+
it("should handle empty emails array gracefully", async () => {
407+
return request(app.getHttpServer())
408+
.get(`/v2/teams/${team.id}/memberships?emails=`)
409+
.expect(200)
410+
.then((response) => {
411+
const responseBody: GetTeamMembershipsOutput = response.body;
412+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
413+
expect(responseBody.data.length).toEqual(2);
414+
});
415+
});
416+
417+
it("should handle URL encoded email addresses in filter", async () => {
418+
const encodedEmail = encodeURIComponent(teamAdminEmail);
419+
return request(app.getHttpServer())
420+
.get(`/v2/teams/${team.id}/memberships?emails=${encodedEmail}`)
421+
.expect(200)
422+
.then((response) => {
423+
const responseBody: GetTeamMembershipsOutput = response.body;
424+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
425+
expect(responseBody.data.length).toEqual(1);
426+
expect(responseBody.data[0].user.email).toEqual(teamAdminEmail);
427+
});
428+
});
429+
430+
it("should filter by email and maintain all user properties", async () => {
431+
return request(app.getHttpServer())
432+
.get(`/v2/teams/${team.id}/memberships?emails=${teamMemberEmail}`)
433+
.expect(200)
434+
.then((response) => {
435+
const responseBody: GetTeamMembershipsOutput = response.body;
436+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
437+
expect(responseBody.data.length).toEqual(1);
438+
const membership = responseBody.data[0];
439+
expect(membership.user.email).toEqual(teamMemberEmail);
440+
expect(membership.user.bio).toEqual(teamMember.bio);
441+
expect(membership.user.metadata).toEqual(teamMember.metadata);
442+
expect(membership.user.username).toEqual(teamMember.username);
443+
expect(membership.teamId).toEqual(team.id);
444+
expect(membership.userId).toEqual(teamMember.id);
445+
expect(membership.role).toEqual("MEMBER");
446+
});
447+
});
448+
449+
it("should validate email array size limits", async () => {
450+
const tooManyEmails = Array.from({ length: 21 }, (_, i) => `test${i}@example.com`).join(",");
451+
return request(app.getHttpServer())
452+
.get(`/v2/teams/${team.id}/memberships?emails=${tooManyEmails}`)
453+
.expect(400);
454+
});
455+
331456
afterAll(async () => {
332457
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
333458
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);

apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
44
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
55
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
66
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
7+
import { GetTeamMembershipsInput } from "@/modules/teams/memberships/inputs/get-team-memberships.input";
78
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
89
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
910
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
@@ -32,7 +33,6 @@ import { plainToClass } from "class-transformer";
3233

3334
import { SUCCESS_STATUS } from "@calcom/platform-constants";
3435
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
35-
import { SkipTakePagination } from "@calcom/platform-types";
3636

3737
@Controller({
3838
path: "/v2/teams/:teamId/memberships",
@@ -85,16 +85,20 @@ export class TeamsMembershipsController {
8585
}
8686

8787
@Get("/")
88-
@ApiOperation({ summary: "Get all memberships" })
88+
@ApiOperation({
89+
summary: "Get all memberships",
90+
description: "Retrieve team memberships with optional filtering by email addresses. Supports pagination.",
91+
})
8992
@Roles("TEAM_ADMIN")
9093
@HttpCode(HttpStatus.OK)
9194
async getTeamMemberships(
9295
@Param("teamId", ParseIntPipe) teamId: number,
93-
@Query() queryParams: SkipTakePagination
96+
@Query() queryParams: GetTeamMembershipsInput
9497
): Promise<GetTeamMembershipsOutput> {
95-
const { skip, take } = queryParams;
98+
const { skip, take, emails } = queryParams;
9699
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
97100
teamId,
101+
emails,
98102
skip ?? 0,
99103
take ?? 250
100104
);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
2+
import { ApiPropertyOptional } from "@nestjs/swagger";
3+
import { Transform } from "class-transformer";
4+
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
5+
6+
export class GetTeamMembershipsInput extends GetUsersInput {
7+
@IsOptional()
8+
@Transform(({ value }) => {
9+
if (value == null) return undefined;
10+
const rawValues = (Array.isArray(value) ? value : [value]).flatMap((entry) =>
11+
typeof entry === "string" ? entry.split(",") : []
12+
);
13+
const normalized = rawValues
14+
.map((email) => email.trim())
15+
.filter((email) => email.length > 0)
16+
.map((email) => email.toLowerCase());
17+
const deduplicated = [...new Set(normalized)];
18+
return deduplicated.length > 0 ? deduplicated : undefined;
19+
})
20+
@ArrayNotEmpty({ message: "emails cannot be empty." })
21+
@ArrayMaxSize(20, {
22+
message: "emails array cannot contain more than 20 email addresses for team membership filtering",
23+
})
24+
@IsEmail({}, { each: true, message: "Each email must be a valid email address" })
25+
@ApiPropertyOptional({
26+
type: [String],
27+
description:
28+
"Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).",
29+
example: "?emails=user1@example.com,user2@example.com",
30+
})
31+
emails?: string[];
32+
}

apps/api/v2/src/modules/teams/memberships/services/teams-memberships.service.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ export class TeamsMembershipsService {
2323
return teamMembership;
2424
}
2525

26-
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
27-
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
26+
async getPaginatedTeamMemberships(teamId: number, emails?: string[], skip = 0, take = 250) {
27+
const emailArray = !emails ? [] : emails;
28+
29+
return await this.teamsMembershipsRepository.findTeamMembershipsPaginatedWithFilters(
2830
teamId,
31+
{ emails: emailArray },
2932
skip,
3033
take
3134
);
32-
return teamMemberships;
3335
}
3436

3537
async getTeamMembership(teamId: number, membershipId: number) {

apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { Injectable } from "@nestjs/common";
66

77
import type { Prisma } from "@calcom/prisma/client";
88

9+
export interface TeamMembershipFilters {
10+
emails?: string[];
11+
}
12+
913
export const MembershipUserSelect: Prisma.UserSelect = {
1014
username: true,
1115
email: true,
@@ -41,6 +45,30 @@ export class TeamsMembershipsRepository {
4145
});
4246
}
4347

48+
async findTeamMembershipsPaginatedWithFilters(
49+
teamId: number,
50+
filters: TeamMembershipFilters,
51+
skip: number,
52+
take: number
53+
) {
54+
const whereClause: Prisma.MembershipWhereInput = {
55+
teamId: teamId,
56+
};
57+
58+
if (filters.emails && filters.emails.length > 0) {
59+
whereClause.user = {
60+
email: { in: filters.emails },
61+
};
62+
}
63+
64+
return await this.dbRead.prisma.membership.findMany({
65+
where: whereClause,
66+
include: { user: { select: MembershipUserSelect } },
67+
skip,
68+
take,
69+
});
70+
}
71+
4472
async findTeamMembership(teamId: number, membershipId: number) {
4573
return this.dbRead.prisma.membership.findUnique({
4674
where: {

apps/api/v2/src/modules/users/inputs/get-users.input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class GetUsersInput {
2828
@ApiPropertyOptional({
2929
type: [String],
3030
description: "The email address or an array of email addresses to filter by",
31+
example: ["user1@example.com", "user2@example.com"],
3132
})
3233
emails?: string[];
3334
}

0 commit comments

Comments
 (0)