Skip to content

Commit b0b94b9

Browse files
fix: allow org admin to cancel and reschedule seated bookings (calcom#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 calcom#24640 which allows org admins to reschedule and cancel seated bookings for users in their organization. Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.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 <sahalrajiv6900@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent af61b6d commit b0b94b9

11 files changed

Lines changed: 313 additions & 75 deletions

File tree

apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ import { InstantBookingCreateService } from "@/lib/services/instant-booking-crea
1313
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
1414
import { RegularBookingService } from "@/lib/services/regular-booking.service";
1515
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
16+
import {
17+
AuthOptionalUser,
18+
GetOptionalUser,
19+
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
1620
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
1721
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
1822
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
23+
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
1924
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
2025
import { BillingService } from "@/modules/billing/services/billing.service";
2126
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -172,8 +177,12 @@ export class BookingsController_2024_04_15 {
172177
}
173178

174179
@Get("/:bookingUid/reschedule")
175-
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
176-
const booking = await getBookingForReschedule(bookingUid);
180+
@UseGuards(OptionalApiAuthGuard)
181+
async getBookingForReschedule(
182+
@Param("bookingUid") bookingUid: string,
183+
@GetOptionalUser() user: AuthOptionalUser
184+
): Promise<ApiResponse<unknown>> {
185+
const booking = await getBookingForReschedule(bookingUid, user?.id);
177186

178187
if (!booking) {
179188
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);

apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class BookingsController_2024_08_13 {
290290
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
291291
],
292292
},
293-
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
293+
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.
294294
295295
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
296296
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 {
328328
\nCancelling seated bookings:
329329
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
330330
\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.
331-
\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.
331+
\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.
332332
333333
\nCancelling recurring seated bookings:
334334
For recurring seated bookings it is not possible to cancel all of them with 1 call

apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe("Managed user bookings 2024-08-13", () => {
7070

7171
let firstManagedUserEventTypeId: number;
7272
let eventTypeRequiresConfirmationId: number;
73+
let seatedEventTypeId: number;
7374

7475
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
7576
let orgAdminManagedUser: CreateManagedUserData;
@@ -309,6 +310,20 @@ describe("Managed user bookings 2024-08-13", () => {
309310
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
310311
});
311312

313+
it("should create a seated event type for first managed user", async () => {
314+
const seatedEventType = await eventTypesRepositoryFixture.create(
315+
{
316+
title: `managed-user-bookings-seated-event-type-${randomString()}`,
317+
slug: `managed-user-bookings-seated-event-type-${randomString()}`,
318+
length: 30,
319+
seatsPerTimeSlot: 5,
320+
seatsShowAttendees: true,
321+
},
322+
firstManagedUser.user.id
323+
);
324+
seatedEventTypeId = seatedEventType.id;
325+
});
326+
312327
describe("bookings using original emails", () => {
313328
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
314329
const body: CreateBookingInput_2024_08_13 = {
@@ -773,6 +788,107 @@ describe("Managed user bookings 2024-08-13", () => {
773788
});
774789
});
775790

791+
describe("seated booking management by org admin", () => {
792+
let seatedBookingUid: string;
793+
794+
it("should create a seated booking with multiple attendees", async () => {
795+
const bodyOne: CreateBookingInput_2024_08_13 = {
796+
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
797+
eventTypeId: seatedEventTypeId,
798+
attendee: {
799+
name: secondManagedUser.user.name!,
800+
email: secondManagedUser.user.email,
801+
timeZone: secondManagedUser.user.timeZone,
802+
language: secondManagedUser.user.locale,
803+
},
804+
};
805+
806+
const responseOne = await request(app.getHttpServer())
807+
.post("/v2/bookings")
808+
.send(bodyOne)
809+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
810+
.expect(201);
811+
812+
const responseBodyForFirstAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseOne.body;
813+
expect(responseBodyForFirstAttendee.status).toEqual(SUCCESS_STATUS);
814+
expect(responseBodyForFirstAttendee.data).toBeDefined();
815+
816+
const bookingDataForFirstAttendee = responseBodyForFirstAttendee.data as BookingOutput_2024_08_13;
817+
seatedBookingUid = bookingDataForFirstAttendee.uid;
818+
expect(bookingDataForFirstAttendee.attendees).toBeDefined();
819+
expect(bookingDataForFirstAttendee.attendees.length).toBeGreaterThan(0);
820+
821+
const bodyTwo: CreateBookingInput_2024_08_13 = {
822+
start: new Date(Date.UTC(2030, 0, 10, 10, 0, 0)).toISOString(),
823+
eventTypeId: seatedEventTypeId,
824+
attendee: {
825+
name: thirdManagedUser.user.name!,
826+
email: thirdManagedUser.user.email,
827+
timeZone: thirdManagedUser.user.timeZone,
828+
language: thirdManagedUser.user.locale,
829+
},
830+
};
831+
832+
const responseTwo = await request(app.getHttpServer())
833+
.post("/v2/bookings")
834+
.send(bodyTwo)
835+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
836+
.expect(201);
837+
838+
const responseBodyForSecondAttendee: ApiSuccessResponse<BookingOutput_2024_08_13> = responseTwo.body;
839+
expect(responseBodyForSecondAttendee.status).toEqual(SUCCESS_STATUS);
840+
expect(responseBodyForSecondAttendee.data).toBeDefined();
841+
842+
const bookingDataForSecondAttendee = responseBodyForSecondAttendee.data as BookingOutput_2024_08_13;
843+
expect(bookingDataForSecondAttendee.attendees).toBeDefined();
844+
expect(bookingDataForSecondAttendee.attendees.length).toBeGreaterThan(0);
845+
});
846+
847+
it("should allow org admin to reschedule all seats in a seated booking for a managed user", async () => {
848+
const newStartTime = new Date(Date.UTC(2030, 0, 10, 11, 0, 0)).toISOString();
849+
850+
const response = await request(app.getHttpServer())
851+
.post(`/v2/bookings/${seatedBookingUid}/reschedule`)
852+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
853+
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
854+
.send({
855+
start: newStartTime,
856+
})
857+
.expect(201);
858+
859+
const responseBody: ApiSuccessResponse<BookingOutput_2024_08_13> = response.body;
860+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
861+
expect(responseBody.data).toBeDefined();
862+
863+
const bookingData = responseBody.data as BookingOutput_2024_08_13;
864+
expect(bookingData.start).toEqual(newStartTime);
865+
866+
seatedBookingUid = bookingData.uid;
867+
});
868+
869+
it("should allow org admin to cancel all seats in a seated booking for a managed user", async () => {
870+
const response = await request(app.getHttpServer())
871+
.post(`/v2/bookings/${seatedBookingUid}/cancel`)
872+
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
873+
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
874+
.send({
875+
cancellationReason: "Org admin cancelled the booking",
876+
})
877+
.expect(200);
878+
879+
const responseBody: GetBookingOutput_2024_08_13 = response.body;
880+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
881+
expect(responseBody.data).toBeDefined();
882+
883+
const bookingData = responseBody.data as BookingOutput_2024_08_13;
884+
expect(bookingData.status).toEqual("cancelled");
885+
expect(bookingData.uid).toEqual(seatedBookingUid);
886+
887+
const cancelledBooking = await bookingsRepositoryFixture.getByUid(seatedBookingUid);
888+
expect(cancelledBooking?.status).toEqual("CANCELLED");
889+
});
890+
});
891+
776892
describe("event type booking requires authentication", () => {
777893
let eventTypeRequiringAuthenticationId: number;
778894

apps/api/v2/src/ee/bookings/2024-08-13/repositories/bookings.repository.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export class BookingsRepository_2024_08_13 {
5050
});
5151
}
5252

53+
async getByUidWithUserIdAndSeatsReferencesCount(bookingUid: string) {
54+
return this.dbRead.prisma.booking.findUnique({
55+
where: {
56+
uid: bookingUid,
57+
},
58+
select: {
59+
userId: true,
60+
seatsReferences: {
61+
take: 1,
62+
},
63+
},
64+
});
65+
}
66+
5367
async getByUid(bookingUid: string) {
5468
return this.dbRead.prisma.booking.findUnique({
5569
where: {

apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
confirmBookingHandler,
5050
getCalendarLinks,
5151
} from "@calcom/platform-libraries";
52+
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
5253
import {
5354
CreateBookingInput_2024_08_13,
5455
CreateBookingInput,
@@ -64,6 +65,7 @@ import {
6465
RescheduleBookingInput,
6566
CancelBookingInput,
6667
} from "@calcom/platform-types";
68+
import type { RescheduleSeatedBookingInput_2024_08_13 } from "@calcom/platform-types";
6769
import type { PrismaClient } from "@calcom/prisma";
6870
import type { EventType, User, Team } from "@calcom/prisma/client";
6971

@@ -762,16 +764,26 @@ export class BookingsService_2024_08_13 {
762764
authUser: AuthOptionalUser
763765
) {
764766
try {
765-
await this.canRescheduleBooking(bookingUid);
767+
const isIndividualSeatRequest = this.isRescheduleSeatedBody(body);
768+
const isIndividualSeatReschedule = await this.shouldRescheduleIndividualSeat(
769+
bookingUid,
770+
isIndividualSeatRequest,
771+
authUser
772+
);
773+
766774
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
767775
request,
768776
bookingUid,
769-
body
777+
body,
778+
isIndividualSeatReschedule
770779
);
780+
781+
await this.canRescheduleBooking(bookingUid);
782+
771783
const booking = await this.regularBookingService.createBooking({
772784
bookingData: bookingRequest.body,
773785
bookingMeta: {
774-
userId: bookingRequest.userId,
786+
userId: bookingRequest.userId ?? authUser?.id,
775787
hostname: bookingRequest.headers?.host || "",
776788
platformClientId: bookingRequest.platformClientId,
777789
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
@@ -840,6 +852,57 @@ export class BookingsService_2024_08_13 {
840852
return booking;
841853
}
842854

855+
async shouldRescheduleIndividualSeat(
856+
bookingUid: string,
857+
isIndividualSeatReschedule: boolean,
858+
authUser: AuthOptionalUser
859+
) {
860+
const booking = await this.bookingsRepository.getByUidWithUserIdAndSeatsReferencesCount(bookingUid);
861+
862+
if (!booking) {
863+
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
864+
}
865+
866+
const hasSeatsPresent = booking.seatsReferences.length > 0;
867+
868+
if (!hasSeatsPresent) return false;
869+
870+
return await this.isIndividualSeatOrOrgAdminReschedule(
871+
isIndividualSeatReschedule,
872+
booking.userId,
873+
authUser?.id
874+
);
875+
}
876+
877+
async isIndividualSeatOrOrgAdminReschedule(
878+
isIndividualSeatReschedule: boolean,
879+
bookingUserId: number | null,
880+
authUserId?: number | null
881+
) {
882+
if (isIndividualSeatReschedule) {
883+
return true;
884+
}
885+
886+
if (!authUserId) {
887+
throw new Error(`No auth user found`);
888+
}
889+
890+
if (!bookingUserId) {
891+
throw new Error(`No user found for booking`);
892+
}
893+
894+
const isOrgAdmin = await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
895+
authUserId,
896+
bookingUserId
897+
);
898+
899+
return isOrgAdmin;
900+
}
901+
902+
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
903+
return "seatUid" in body;
904+
}
905+
843906
async cancelBooking(
844907
request: Request,
845908
bookingUid: string,

apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
507507
async createRescheduleBookingRequest(
508508
request: Request,
509509
bookingUid: string,
510-
body: RescheduleBookingInput
510+
body: RescheduleBookingInput,
511+
isIndividualSeatReschedule: boolean
511512
): Promise<BookingRequest> {
512-
const bodyTransformed = this.isRescheduleSeatedBody(body)
513-
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
514-
: await this.transformInputRescheduleBooking(bookingUid, body);
513+
const bodyTransformed =
514+
isIndividualSeatReschedule && "seatUid" in body
515+
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
516+
: await this.transformInputRescheduleBooking(bookingUid, body, isIndividualSeatReschedule);
515517

516518
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(
517519
bodyTransformed.eventTypeId
@@ -606,7 +608,11 @@ export class InputBookingsService_2024_08_13 {
606608
};
607609
}
608610

609-
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
611+
async transformInputRescheduleBooking(
612+
bookingUid: string,
613+
inputBooking: RescheduleBookingInput_2024_08_13,
614+
isIndividualSeatReschedule: boolean
615+
) {
610616
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
611617
if (!booking) {
612618
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
@@ -618,7 +624,7 @@ export class InputBookingsService_2024_08_13 {
618624
if (!eventType) {
619625
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
620626
}
621-
if (eventType.seatsPerTimeSlot) {
627+
if (eventType.seatsPerTimeSlot && !isIndividualSeatReschedule) {
622628
throw new BadRequestException(
623629
`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`
624630
);
@@ -652,7 +658,6 @@ export class InputBookingsService_2024_08_13 {
652658

653659
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
654660
const endTime = startTime.plus({ minutes: eventType.length });
655-
656661
return {
657662
start: startTime.toISO(),
658663
end: endTime.toISO(),

packages/features/bookings/lib/handleCancelBooking.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
2828
import logger from "@calcom/lib/logger";
2929
import { safeStringify } from "@calcom/lib/safeStringify";
3030
import { getTranslation } from "@calcom/lib/server/i18n";
31+
import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository";
3132
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
3233
// TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export
3334
import prisma from "@calcom/prisma";
@@ -144,8 +145,18 @@ async function handler(input: CancelBookingInput) {
144145

145146
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
146147

147-
if (!userIsHost && !userIsOwnerOfEventType) {
148-
throw new HttpError({ statusCode: 401, message: "User not a host of this event" });
148+
const userIsOrgAdminOfBookingUser =
149+
userId &&
150+
(await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost(
151+
userId,
152+
bookingToDelete.userId
153+
));
154+
155+
if (!userIsHost && !userIsOwnerOfEventType && !userIsOrgAdminOfBookingUser) {
156+
throw new HttpError({
157+
statusCode: 401,
158+
message: "User not a host of this event or an admin of the booking user",
159+
});
149160
}
150161
}
151162

0 commit comments

Comments
 (0)