diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input.ts new file mode 100644 index 0000000000..5dd7706992 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input.ts @@ -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[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index 3c56506430..2c990c2b0c 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -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 () => { diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index 21f766f82e..493193ceba 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -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 { 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)), }; } diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index 907b3a98fe..17397abfbb 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -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); diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 365bbf39e3..54b06dfd79 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -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); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 76a605dfd0..8cf105c165 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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": { diff --git a/apps/api/v2/test/utils/randomString.ts b/apps/api/v2/test/utils/randomString.ts index 4ecd6ec28e..6b376fb80e 100644 --- a/apps/api/v2/test/utils/randomString.ts +++ b/apps/api/v2/test/utils/randomString.ts @@ -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; } diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 76a605dfd0..8cf105c165 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -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": { diff --git a/packages/platform/types/api.ts b/packages/platform/types/api.ts index bf1a826251..c50dccd49e 100644 --- a/packages/platform/types/api.ts +++ b/packages/platform/types/api.ts @@ -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 extends undefined export type ApiResponseMaybeRedirect = ApiResponse | 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 {