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:
+125
@@ -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);
|
||||
|
||||
+8
-4
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user