From b0b94b91fec1447062ace4886c2f1ab902952121 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Thu, 30 Oct 2025 15:43:05 +0100 Subject: [PATCH] fix: allow org admin to cancel and reschedule seated bookings (#24640) * refactor: move org admin related logic to org server * fix: update cancellation logic to make sure org admin can cancel seated bookings of a team user * fix: import path * update bookings repository * fix: update reschedule endpoint logic to let org admin reschedule bookings for a user * refactor: make logic more simple * chore: update platform libraries * more refactors * fix: add check to make sure org admin can reschedule booking * chore: remove unused comments * test: add e2e tests for org admin reschedule and cancel seated bookings - Add seated event type creation for testing - Add test for org admin rescheduling a seated booking for a managed user - Add test for org admin canceling a full seated booking for a managed user - Add test for org admin canceling a specific seat in a seated booking These tests verify the functionality added in PR #24640 which allows org admins to reschedule and cancel seated bookings for users in their organization. Co-Authored-By: rajiv@cal.com * chore: add cubic feedback * fix: tests for seated booking management by org admin * chore: implement PR feedback * fixup * chore: update docs * fixup: get optional user from request and then pass it down to getBookingForReschedule * fix: validate seatUid before checking booking cancellation status Move canRescheduleBooking call to happen after input validation (including seatUid validation for seated bookings) but before the actual booking creation. This ensures that when trying to reschedule a seated booking without providing seatUid, users get the proper 'seatUid required' error instead of 'booking has been cancelled' error. Fixes failing e2e test: 'should not be able to reschedule seated booking if seatUid is not provided' Co-Authored-By: rajiv@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../controllers/bookings.controller.ts | 13 +- .../controllers/bookings.controller.ts | 4 +- .../e2e/managed-user-bookings.e2e-spec.ts | 116 ++++++++++++++++++ .../repositories/bookings.repository.ts | 14 +++ .../2024-08-13/services/bookings.service.ts | 69 ++++++++++- .../2024-08-13/services/input.service.ts | 19 +-- .../bookings/lib/handleCancelBooking.ts | 15 ++- .../reschedule/rescheduleSeatedBooking.ts | 14 ++- .../PrismaOrgMembershipRepository.ts | 61 +++++++++ packages/platform/libraries/bookings.ts | 1 + .../viewer/bookings/confirm.handler.ts | 62 +--------- 11 files changed, 313 insertions(+), 75 deletions(-) create mode 100644 packages/lib/server/repository/PrismaOrgMembershipRepository.ts 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 5ebf59c850..d88ecce3d4 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 @@ -13,9 +13,14 @@ import { InstantBookingCreateService } from "@/lib/services/instant-booking-crea import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; import { RegularBookingService } from "@/lib/services/regular-booking.service"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; +import { + AuthOptionalUser, + GetOptionalUser, +} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { BillingService } from "@/modules/billing/services/billing.service"; import { KyselyReadService } from "@/modules/kysely/kysely-read.service"; @@ -172,8 +177,12 @@ export class BookingsController_2024_04_15 { } @Get("/:bookingUid/reschedule") - async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise> { - const booking = await getBookingForReschedule(bookingUid); + @UseGuards(OptionalApiAuthGuard) + async getBookingForReschedule( + @Param("bookingUid") bookingUid: string, + @GetOptionalUser() user: AuthOptionalUser + ): Promise> { + const booking = await getBookingForReschedule(bookingUid, user?.id); if (!booking) { throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index 5f9e2704e8..7c15255c8b 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -290,7 +290,7 @@ export class BookingsController_2024_08_13 { { $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) }, ], }, - description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). + description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2). If you're rescheduling a seated booking as org admin of booking host, pass booking input for Reschedule Booking (Option 1) along with your access token in the request header. If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`, @@ -328,7 +328,7 @@ export class BookingsController_2024_08_13 { \nCancelling seated bookings: It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host. \n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking. - \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees. + \n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees. \nCancelling recurring seated bookings: For recurring seated bookings it is not possible to cancel all of them with 1 call diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts index 5647960365..d1ed2103f7 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts @@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => { let firstManagedUserEventTypeId: number; let eventTypeRequiresConfirmationId: number; + let seatedEventTypeId: number; const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`; let orgAdminManagedUser: CreateManagedUserData; @@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => { eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id; }); + it("should create a seated event type for first managed user", async () => { + const seatedEventType = await eventTypesRepositoryFixture.create( + { + title: `managed-user-bookings-seated-event-type-${randomString()}`, + slug: `managed-user-bookings-seated-event-type-${randomString()}`, + length: 30, + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + }, + firstManagedUser.user.id + ); + seatedEventTypeId = seatedEventType.id; + }); + describe("bookings using original emails", () => { it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => { const body: CreateBookingInput_2024_08_13 = { @@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => { }); }); + describe("seated booking management by org admin", () => { + let seatedBookingUid: string; + + it("should create a seated booking with multiple attendees", async () => { + const bodyOne: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: secondManagedUser.user.name!, + email: secondManagedUser.user.email, + timeZone: secondManagedUser.user.timeZone, + language: secondManagedUser.user.locale, + }, + }; + + const responseOne = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bodyOne) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBodyForFirstAttendee: ApiSuccessResponse = responseOne.body; + expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS); + expect(responseBodyForFirstAttendee.data).toBeDefined(); + + const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13; + seatedBookingUid = bookingDataForFirstAttendee.uid; + expect(bookingDataForFirstAttendee.attendees).toBeDefined(); + expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0); + + const bodyTwo: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeId, + attendee: { + name: thirdManagedUser.user.name!, + email: thirdManagedUser.user.email, + timeZone: thirdManagedUser.user.timeZone, + language: thirdManagedUser.user.locale, + }, + }; + + const responseTwo = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bodyTwo) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBodyForSecondAttendee: ApiSuccessResponse = responseTwo.body; + expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS); + expect(responseBodyForSecondAttendee.data).toBeDefined(); + + const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13; + expect(bookingDataForSecondAttendee.attendees).toBeDefined(); + expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0); + }); + + it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => { + const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString(); + + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${seatedBookingUid}/reschedule`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .send({ + start: newStartTime, + }) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const bookingData = responseBody.data as BookingOutput_2024_08_13; + expect(bookingData.start).toEqual(newStartTime); + + seatedBookingUid = bookingData.uid; + }); + + it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${seatedBookingUid}/cancel`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .send({ + cancellationReason: "Org admin cancelled the booking", + }) + .expect(200); + + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const bookingData = responseBody.data as BookingOutput_2024_08_13; + expect(bookingData.status).toEqual("cancelled"); + expect(bookingData.uid).toEqual(seatedBookingUid); + + const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid); + expect(cancelledBooking?.status).toEqual("CANCELLED"); + }); + }); + describe("event type booking requires authentication", () => { let eventTypeRequiringAuthenticationId: number; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts index 812f0b580c..4c6f74700e 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts @@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 { }); } + async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: { + userId: true, + seatsReferences: { + take: 1, + }, + }, + }); + } + async getByUid(bookingUid: string) { return this.dbRead.prisma.booking.findUnique({ where: { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index de6660d4ef..aa4c0c82fb 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -49,6 +49,7 @@ import { confirmBookingHandler, getCalendarLinks, } from "@calcom/platform-libraries"; +import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings"; import { CreateBookingInput_2024_08_13, CreateBookingInput, @@ -64,6 +65,7 @@ import { RescheduleBookingInput, CancelBookingInput, } from "@calcom/platform-types"; +import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types"; import type { PrismaClient } from "@calcom/prisma"; import type { EventType, User, Team } from "@calcom/prisma/client"; @@ -762,16 +764,26 @@ export class BookingsService_2024_08_13 { authUser: AuthOptionalUser ) { try { - await this.canRescheduleBooking(bookingUid); + const isIndividualSeatRequest = this.isRescheduleSeatedBody(body); + const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat( + bookingUid, + isIndividualSeatRequest, + authUser + ); + const bookingRequest = await this.inputService.createRescheduleBookingRequest( request, bookingUid, - body + body, + isIndividualSeatReschedule ); + + await this.canRescheduleBooking(bookingUid); + const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, bookingMeta: { - userId: bookingRequest.userId, + userId: bookingRequest.userId ?? authUser?.id, hostname: bookingRequest.headers?.host || "", platformClientId: bookingRequest.platformClientId, platformRescheduleUrl: bookingRequest.platformRescheduleUrl, @@ -840,6 +852,57 @@ export class BookingsService_2024_08_13 { return booking; } + async shouldRescheduleIndividualSeat( + bookingUid: string, + isIndividualSeatReschedule: boolean, + authUser: AuthOptionalUser + ) { + const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid); + + if (!booking) { + throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`); + } + + const hasSeatsPresent = booking.seatsReferences.length > 0; + + if (!hasSeatsPresent) return false; + + return await this.isIndividualSeatOrOrgAdminReschedule( + isIndividualSeatReschedule, + booking.userId, + authUser?.id + ); + } + + async isIndividualSeatOrOrgAdminReschedule( + isIndividualSeatReschedule: boolean, + bookingUserId: number | null, + authUserId?: number | null + ) { + if (isIndividualSeatReschedule) { + return true; + } + + if (!authUserId) { + throw new Error(`No auth user found`); + } + + if (!bookingUserId) { + throw new Error(`No user found for booking`); + } + + const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost( + authUserId, + bookingUserId + ); + + return isOrgAdmin; + } + + isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 { + return "seatUid" in body; + } + async cancelBooking( request: Request, bookingUid: string, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index c308529f25..272aed1726 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 { async createRescheduleBookingRequest( request: Request, bookingUid: string, - body: RescheduleBookingInput + body: RescheduleBookingInput, + isIndividualSeatReschedule: boolean ): Promise { - const bodyTransformed = this.isRescheduleSeatedBody(body) - ? await this.transformInputRescheduleSeatedBooking(bookingUid, body) - : await this.transformInputRescheduleBooking(bookingUid, body); + const bodyTransformed = + isIndividualSeatReschedule && "seatUid" in body + ? await this.transformInputRescheduleSeatedBooking(bookingUid, body) + : await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule); const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams( bodyTransformed.eventTypeId @@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 { }; } - async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) { + async transformInputRescheduleBooking( + bookingUid: string, + inputBooking: RescheduleBookingInput_2024_08_13, + isIndividualSeatReschedule: boolean + ) { const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); if (!booking) { throw new NotFoundException(`Booking with uid=${bookingUid} not found`); @@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 { if (!eventType) { throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`); } - if (eventType.seatsPerTimeSlot) { + if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) { throw new BadRequestException( `Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2` ); @@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 { const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone); const endTime = startTime.plus({ minutes: eventType.length }); - return { start: startTime.toISO(), end: endTime.toISO(), diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 6b24bb913c..01b40ff05a 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -28,6 +28,7 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; // TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export import prisma from "@calcom/prisma"; @@ -144,8 +145,18 @@ async function handler(input: CancelBookingInput) { const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId; - if (!userIsHost && !userIsOwnerOfEventType) { - throw new HttpError({ statusCode: 401, message: "User not a host of this event" }); + const userIsOrgAdminOfBookingUser = + userId && + (await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost( + userId, + bookingToDelete.userId + )); + + if (!userIsHost && !userIsOwnerOfEventType && !userIsOrgAdminOfBookingUser) { + throw new HttpError({ + statusCode: 401, + message: "User not a host of this event or an admin of the booking user", + }); } } diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index a7c962a5aa..109ee3f33b 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -1,8 +1,8 @@ -// eslint-disable-next-line no-restricted-imports import dayjs from "@calcom/dayjs"; -import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials"; import EventManager from "@calcom/features/bookings/lib/EventManager"; +import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials"; import { HttpError } from "@calcom/lib/http-error"; +import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import type { Person } from "@calcom/types/Calendar"; @@ -98,9 +98,17 @@ const rescheduleSeatedBooking = async ( }; if (!bookingSeat) { + const isOrgAdmin = + reqUserId && + seatedBooking.user && + (await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost( + reqUserId, + seatedBooking.user?.id + )); // if no bookingSeat is given and the userId != owner, 401. + // if no bookingSeat is given, also check if the request user is an org admin of the booking user // TODO: Next step; Evaluate ownership, what about teams? - if (seatedBooking.user?.id !== reqUserId) { + if (seatedBooking.user?.id !== reqUserId && !isOrgAdmin) { throw new HttpError({ statusCode: 401 }); } diff --git a/packages/lib/server/repository/PrismaOrgMembershipRepository.ts b/packages/lib/server/repository/PrismaOrgMembershipRepository.ts new file mode 100644 index 0000000000..f70ce8cb93 --- /dev/null +++ b/packages/lib/server/repository/PrismaOrgMembershipRepository.ts @@ -0,0 +1,61 @@ +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +export class PrismaOrgMembershipRepository { + static async getOrgIdsWhereAdmin(loggedInUserId: number) { + const loggedInUserOrgMemberships = await prisma.membership.findMany({ + where: { + userId: loggedInUserId, + role: { + in: [MembershipRole.OWNER, MembershipRole.ADMIN], + }, + team: { + parentId: null, + }, + }, + select: { + teamId: true, + }, + }); + + return loggedInUserOrgMemberships.map((m) => m.teamId); + } + + static async isLoggedInUserOrgAdminOfBookingHost(loggedInUserId: number, bookingUserId: number) { + const orgIdsWhereLoggedInUserAdmin = await this.getOrgIdsWhereAdmin(loggedInUserId); + + if (orgIdsWhereLoggedInUserAdmin.length === 0) { + return false; + } + + const bookingUserOrgMembership = await prisma.membership.findFirst({ + where: { + userId: bookingUserId, + teamId: { + in: orgIdsWhereLoggedInUserAdmin, + }, + team: { + parentId: null, + }, + }, + select: { + userId: true, + }, + }); + + if (bookingUserOrgMembership) return true; + + const bookingUserOrgTeamMembership = await prisma.membership.findFirst({ + where: { + userId: bookingUserId, + team: { + parentId: { + in: orgIdsWhereLoggedInUserAdmin, + }, + }, + }, + }); + + return !!bookingUserOrgTeamMembership; + } +} diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts index eceb5447f6..db2be613f1 100644 --- a/packages/platform/libraries/bookings.ts +++ b/packages/platform/libraries/bookings.ts @@ -8,4 +8,5 @@ export type { InstantBookingCreateResult, RegularBookingCreateResult, } from "@calcom/features/bookings/lib/dto/types"; +export { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository"; export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler"; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index b1545acdd2..2dadd50a4b 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -17,6 +17,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; @@ -411,7 +412,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { } } - const message = `Booking ${confirmed}` ? "confirmed" : "rejected"; + const message = confirmed ? "Booking confirmed" : "Booking rejected"; const status = confirmed ? BookingStatus.ACCEPTED : BookingStatus.REJECTED; return { message, status }; @@ -472,63 +473,12 @@ const checkIfUserIsAuthorizedToConfirmBooking = async ({ if (membership) return; } - if (bookingUserId && (await isLoggedInUserOrgAdminOfBookingUser(loggedInUserId, bookingUserId))) { + if ( + bookingUserId && + (await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(loggedInUserId, bookingUserId)) + ) { return; } throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not authorized to confirm this booking" }); }; - -async function isLoggedInUserOrgAdminOfBookingUser(loggedInUserId: number, bookingUserId: number) { - const orgIdsWhereLoggedInUserAdmin = await getOrgIdsWhereAdmin(loggedInUserId); - - if (orgIdsWhereLoggedInUserAdmin.length === 0) { - return false; - } - - const bookingUserOrgMembership = await prisma.membership.findFirst({ - where: { - userId: bookingUserId, - teamId: { - in: orgIdsWhereLoggedInUserAdmin, - }, - team: { - parentId: null, - }, - }, - }); - - if (bookingUserOrgMembership) return true; - - const bookingUserOrgTeamMembership = await prisma.membership.findFirst({ - where: { - userId: bookingUserId, - team: { - parentId: { - in: orgIdsWhereLoggedInUserAdmin, - }, - }, - }, - }); - - return !!bookingUserOrgTeamMembership; -} - -async function getOrgIdsWhereAdmin(loggedInUserId: number) { - const loggedInUserOrgMemberships = await prisma.membership.findMany({ - where: { - userId: loggedInUserId, - role: { - in: [MembershipRole.OWNER, MembershipRole.ADMIN], - }, - team: { - parentId: null, - }, - }, - select: { - teamId: true, - }, - }); - - return loggedInUserOrgMemberships.map((m) => m.teamId); -}