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:
1 parent
b1bcbe9dfb
commit
b7c5cfcf02
9 files changed
+283
-21
No files matched your search
+29
@@ -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[];
|
||||
}
|
||||
+152
-6
@@ -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 () => {
|
||||
|
||||
+4
-9
@@ -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);
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user