feat: add email filtering to team memberships endpoint (#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......
This commit is contained in:
Devanshu Sharma
2025-10-03 18:09:47 +05:30
committed by GitHub
parent d7d2487f6d
commit de195a1c0f
6 changed files with 199 additions and 7 deletions
@@ -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);
@@ -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<GetTeamMembershipsOutput> {
const { skip, take } = queryParams;
const { skip, take, emails } = queryParams;
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
teamId,
emails,
skip ?? 0,
take ?? 250
);
@@ -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[];
}
@@ -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) {
@@ -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: {
@@ -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[];
}