feat: v2 get managed users by email (#19526)

* feat: v2 get managed users by email

* docs

* fix: offset query param not showing up in docs

* finish merge main

* fix: check if each email in emails query param is email
This commit is contained in:
Lauris Skraucis authored and GitHub committed 2025-03-11 15:01:43 +00:00
1 parent b1bcbe9dfb
commit b7c5cfcf02
9 files changed
+283 -21

No files matched your search

@@ -0,0 +1,29 @@
import { BadRequestException } from "@nestjs/common";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayNotEmpty, isEmail, IsOptional } from "class-validator";
import { Pagination } from "@calcom/platform-types";
export class GetManagedUsersInput extends Pagination {
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((email: string) => {
const trimmed = email.trim();
if (!isEmail(trimmed)) {
throw new BadRequestException(`Invalid email ${trimmed}`);
}
return trimmed;
});
}
return value;
})
@ArrayNotEmpty({ message: "emails cannot be empty." })
@ApiPropertyOptional({
description:
"Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.",
example: "?emails=email1@example.com,email2@example.com",
})
emails?: string[];
}
@@ -90,8 +90,11 @@ describe("OAuth Client Users Endpoints", () => {
const platformAdminEmail = `oauth-client-users-admin-${randomString()}@api.com`;
let platformAdmin: User;
const userEmail = `oauth-client-users-user-${randomString()}@api.com`;
const userEmail = `oauth-client-users-user-${randomString(5)}@api.com`;
const userTimeZone = "Europe/Rome";
const userEmailTwo = `oauth-client-users-user-2-${randomString(5)}@api.com`;
const userTimeZoneTwo = "Europe/Rome";
let postResponseDataTwo: CreateManagedUserOutput["data"];
const userEmail2 = `oauth-client-users-user2-${randomString()}@api.com`;
const userTimeZone2 = "America/New_York";
@@ -225,7 +228,46 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email);
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1);
await userHasDefaultEventTypes(responseBody.data.user.id);
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
await userHasOnlyOneSchedule(responseBody.data.user.id);
});
it(`/POST with default event types`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmailTwo,
timeZone: userTimeZoneTwo,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name: "Bob Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
postResponseDataTwo = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.name).toEqual(requestBody.name);
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2);
await userHasDefaultEventTypes(responseBody.data.user.id);
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
await userHasOnlyOneSchedule(responseBody.data.user.id);
@@ -266,17 +308,17 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email);
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1);
await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id);
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
await userHasOnlyOneSchedule(responseBody.data.user.id);
});
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string) {
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string, usersCount: number) {
const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClientId);
const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail);
expect(oAuthUsers?.length).toEqual(1);
expect(oAuthUsers?.length).toEqual(usersCount);
expect(newOAuthUser?.email).toEqual(userEmail);
}
@@ -359,6 +401,110 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data?.length).toBeGreaterThan(0);
expect(responseBody.data[0].email).toEqual(postResponseData.user.email);
expect(responseBody.data[0].name).toEqual(postResponseData.user.name);
expect(responseBody.data?.length).toEqual(2);
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
});
it(`/GET: managed user by original email`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${userEmail}`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
const responseBody: GetManagedUsersOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
});
it(`/GET: managed users by original emails`, async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${userEmail},${userEmailTwo}`
)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
const responseBody: GetManagedUsersOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
});
it(`/GET: managed user by oAuth email`, async () => {
const response = await request(app.getHttpServer())
// note(Lauris): we use encodeURIComponent because email stored on our side includes "+" which without encoding becomes an empty space.
.get(
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${encodeURIComponent(
postResponseData.user.email
)}`
)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
const responseBody: GetManagedUsersOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
});
it(`should error /GET if managed user email is invalid`, async () => {
const invalidEmail = "invalid-email";
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${invalidEmail}`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(400);
expect(response.body?.error?.message).toEqual(`Invalid email ${invalidEmail}`);
});
it(`/GET: managed users by oAuth emails`, async () => {
const response = await request(app.getHttpServer())
// note(Lauris): we use encodeURIComponent because email stored on our side includes "+" which without encoding becomes an empty space.
.get(
`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0&emails=${encodeURIComponent(
postResponseData.user.email
)},${encodeURIComponent(postResponseDataTwo.user.email)}`
)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
const responseBody: GetManagedUsersOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(2);
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
const userTwo = responseBody.data.find((user) => user.email === postResponseDataTwo.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
});
it(`/GET/:id`, async () => {
@@ -452,7 +598,7 @@ describe("OAuth Client Users Endpoints", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let postResponseData: CreateManagedUserOutput["data"];
const userEmail = `oauth-client-users-user-${randomString()}@api.com`;
const userEmail = `oauth-client-users-user-${randomString(5)}@api.com`;
const userTimeZone = "Europe/Rome";
beforeAll(async () => {
@@ -3,6 +3,7 @@ import { Locales } from "@/lib/enums/locales";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -57,20 +58,14 @@ export class OAuthClientUsersController {
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
async getManagedUsers(
@Param("clientId") oAuthClientId: string,
@Query() queryParams: Pagination
@Query() queryParams: GetManagedUsersInput
): Promise<GetManagedUsersOutput> {
this.logger.log(`getting managed users with data for OAuth Client with ID ${oAuthClientId}`);
const { offset, limit } = queryParams;
const existingUsers = await this.userRepository.findManagedUsersByOAuthClientId(
oAuthClientId,
offset ?? 0,
limit ?? 50
);
const managedUsers = await this.oAuthClientUsersService.getManagedUsers(oAuthClientId, queryParams);
return {
status: SUCCESS_STATUS,
data: existingUsers.map((user) => this.getResponseUser(user)),
data: managedUsers.map((user) => this.getResponseUser(user)),
};
}
@@ -1,5 +1,6 @@
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -101,6 +102,23 @@ export class OAuthClientUsersService {
return await this.userRepository.findByEmail(oAuthEmail);
}
async getManagedUsers(oAuthClientId: string, queryParams: GetManagedUsersInput) {
const { offset, limit, emails } = queryParams;
const oAuthEmails = emails?.map((email) =>
email.includes(oAuthClientId) ? email : this.getOAuthUserEmail(oAuthClientId, email)
);
const managedUsers = await this.userRepository.findManagedUsersByOAuthClientIdAndEmails(
oAuthClientId,
offset ?? 0,
limit ?? 50,
oAuthEmails
);
return managedUsers;
}
async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) {
if (body.email) {
const emailWithOAuthId = this.getOAuthUserEmail(oAuthClientId, body.email);
@@ -177,6 +177,33 @@ export class UsersRepository {
});
}
async findManagedUsersByOAuthClientIdAndEmails(
oauthClientId: string,
cursor: number,
limit: number,
oAuthEmails?: string[]
) {
return this.dbRead.prisma.user.findMany({
where: {
platformOAuthClients: {
some: {
id: oauthClientId,
},
},
isPlatformManaged: true,
...(oAuthEmails && oAuthEmails.length > 0
? {
email: {
in: oAuthEmails,
},
}
: {}),
},
take: limit,
skip: cursor,
});
}
async update(userId: number, updateData: UpdateManagedUserInput) {
this.formatInput(updateData);
+23
View File
@@ -85,6 +85,29 @@
"schema": {
"type": "number"
}
},
{
"name": "offset",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "emails",
"required": false,
"in": "query",
"description": "Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.",
"example": "?emails=email1@example.com,email2@example.com",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
+3 -2
View File
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid";
export function randomString() {
return uuidv4().replace(/-/g, "");
export function randomString(length?: number) {
const string = uuidv4().replace(/-/g, "");
return length ? string.substring(0, length) : string;
}
+23
View File
@@ -85,6 +85,29 @@
"schema": {
"type": "number"
}
},
{
"name": "offset",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "emails",
"required": false,
"in": "query",
"description": "Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.",
"example": "?emails=email1@example.com,email2@example.com",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
+4 -4
View File
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
import { IsEnum } from "class-validator";
@@ -39,7 +39,7 @@ export type ApiResponse<T = undefined> = T extends undefined
export type ApiResponseMaybeRedirect<T = undefined> = ApiResponse<T> | ApiRedirectResponseType;
export class Pagination {
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@ApiPropertyOptional({ description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@@ -47,13 +47,13 @@ export class Pagination {
@IsOptional()
limit?: number;
@ApiPropertyOptional({ description: "The number of items to skip", example: 0 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
@IsNumber()
@Min(0)
@Max(250)
@IsOptional()
offset?: number | null;
offset?: number;
}
export class SkipTakePagination {