Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice tests!

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");
});
});
Comment on lines +331 to +366
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Tests clearly specify comma-separated and multi-email behavior.

These will pass once the emails transform splits and normalizes values as suggested in GetUsersInput.

Run to confirm:


🏁 Script executed:

#!/bin/bash
rg -n "Transform\\(\\(\\{ value" apps/api/v2/src/modules/users/inputs/get-users.input.ts -n -C3

Length of output: 811


Update GetUsersInput email Transform to split and normalize comma-separated emails

Current @Transform in apps/api/v2/src/modules/users/inputs/get-users.input.ts only wraps a single string into an array; replace it with a transform that, for string values, splits on ',', trims entries, filters out empties, and normalizes (e.g., toLowerCase()) so comma-separated email queries in tests pass.

🤖 Prompt for AI Agents
In
apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts
around lines 331 to 366, tests expect comma-separated emails to be handled as
multiple normalized emails; update the @Transform in
apps/api/v2/src/modules/users/inputs/get-users.input.ts so that when the
incoming value is a string it is split on ',', each piece is trimmed, empty
strings are filtered out, and each email is normalized (e.g., toLowerCase()),
while still returning the value unchanged if it is already an array or
converting non-string values to an empty array; ensure the transformed result is
an array of strings compatible with existing validation decorators.


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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now extracting emails also for the filtering.

const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
teamId,
emails,
skip ?? 0,
take ?? 250
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
};
}
Comment on lines +58 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Prisma relation filter is incomplete; use is with normalized, deduped emails.

whereClause.user = { email: { in: ... } } is not a valid relation filter shape; should be { user: { is: { email: { in: ... }}}}. Also normalize to lowercase and dedupe to match DB canonicalization and avoid redundant predicates.

Apply this diff:

-    if (filters.emails && filters.emails.length > 0) {
-      whereClause.user = {
-        email: { in: filters.emails },
-      };
-    }
+    if (filters.emails && filters.emails.length > 0) {
+      const emails = Array.from(new Set(filters.emails.map((e) => e.toLowerCase())));
+      whereClause.user = {
+        is: { email: { in: emails } },
+      };
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (filters.emails && filters.emails.length > 0) {
whereClause.user = {
email: { in: filters.emails },
};
}
if (filters.emails && filters.emails.length > 0) {
const emails = Array.from(new Set(filters.emails.map((e) => e.toLowerCase())));
whereClause.user = {
is: { email: { in: emails } },
};
}
🤖 Prompt for AI Agents
In apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts
around lines 58-62, the relation filter is currently using an invalid shape
(user: { email: { in: ... } }) — change it to the proper Prisma relation filter
form using is (user: { is: { email: { in: [...] }}}). Before building the
filter, normalize the incoming emails to lowercase and remove duplicates (e.g.,
map toLowerCase and use a Set) so the predicate matches DB canonicalization and
avoids redundant values; then assign that deduped array into the in: clause.


return await this.dbRead.prisma.membership.findMany({
where: whereClause,
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
Comment on lines +64 to +69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use select (not include) per project guideline; add stable ordering.

Follow “select only what you need; never use include”. Also add orderBy for deterministic pagination.

Apply this diff:

-    return await this.dbRead.prisma.membership.findMany({
-      where: whereClause,
-      include: { user: { select: MembershipUserSelect } },
-      skip,
-      take,
-    });
+    return await this.dbRead.prisma.membership.findMany({
+      where: whereClause,
+      select: {
+        id: true,
+        teamId: true,
+        userId: true,
+        role: true,
+        user: { select: MembershipUserSelect },
+      },
+      skip,
+      take,
+      orderBy: { id: "asc" },
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return await this.dbRead.prisma.membership.findMany({
where: whereClause,
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
return await this.dbRead.prisma.membership.findMany({
where: whereClause,
select: {
id: true,
teamId: true,
userId: true,
role: true,
user: { select: MembershipUserSelect },
},
skip,
take,
orderBy: { id: "asc" },
});
🤖 Prompt for AI Agents
In apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts
around lines 64–69, change the Prisma call to use select instead of include (per
project guideline "select only what you need; never use include") so that the
user sub-object is returned via select: { user: { select: MembershipUserSelect }
} and add a deterministic orderBy (e.g. orderBy: { id: 'asc' } or orderBy: {
createdAt: 'asc' } if createdAt exists) to guarantee stable pagination; keep
skip and take as-is and return the awaited result.

}

async findTeamMembership(teamId: number, membershipId: number) {
return this.dbRead.prisma.membership.findUnique({
where: {
Expand Down
1 change: 1 addition & 0 deletions apps/api/v2/src/modules/users/inputs/get-users.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Loading