Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -172,8 +177,12 @@ export class BookingsController_2024_04_15 {
}

@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
@UseGuards(OptionalApiAuthGuard)
async getBookingForReschedule(
@Param("bookingUid") bookingUid: string,
@GetOptionalUser() user: AuthOptionalUser
): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid, user?.id);

if (!booking) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<BookingOutput_2024_08_13> = 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<BookingOutput_2024_08_13> = 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<BookingOutput_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.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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
confirmBookingHandler,
getCalendarLinks,
} from "@calcom/platform-libraries";
import { PrismaOrgMembershipRepository } from "@calcom/platform-libraries/bookings";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
Expand All @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 12 additions & 7 deletions apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,13 @@ export class InputBookingsService_2024_08_13 {
async createRescheduleBookingRequest(
request: Request,
bookingUid: string,
body: RescheduleBookingInput
body: RescheduleBookingInput,
isIndividualSeatReschedule: boolean
): Promise<BookingRequest> {
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
Expand Down Expand Up @@ -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`);
Expand All @@ -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`
);
Expand Down Expand Up @@ -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(),
Expand Down
15 changes: 13 additions & 2 deletions packages/features/bookings/lib/handleCancelBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
});
}
}

Expand Down
Loading
Loading