Skip to content
Draft
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 @@ -68,6 +68,7 @@ import {
} from "@calcom/platform-libraries";
import { CreationSource } from "@calcom/platform-libraries";
import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings";
import { getReservedSlotUidFromRequest } from "@calcom/platform-libraries/slots";
import {
GetBookingsInput_2024_04_15,
CancelBookingInput_2024_04_15,
Expand Down Expand Up @@ -207,6 +208,7 @@ export class BookingsController_2024_04_15 {
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const reservedSlotUid = getReservedSlotUidFromRequest(req);
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
bookingMeta: {
Expand All @@ -219,6 +221,7 @@ export class BookingsController_2024_04_15 {
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
reservedSlotUid,
},
});
if (booking.userId && booking.uid && booking.startTime) {
Expand Down Expand Up @@ -631,6 +634,15 @@ export class BookingsController_2024_04_15 {
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
throw new HttpException(error.message, 400);
}
if (error.message === "reserved_slot_not_first_in_line") {
const errorData =
"data" in error ? (error.data as { secondsUntilRelease: number }) : { secondsUntilRelease: 300 };
const message = `Someone else reserved this slot before you. This slot will be freed up in ${errorData.secondsUntilRelease} seconds.`;
throw new HttpException(message, 400);
}
if (error.message === "reservation_not_found_or_expired") {
throw new HttpException("The reserved slot was not found or has expired.", 410);
}
throw new InternalServerErrorException(error?.message ?? errMsg);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { DateTime } from "luxon";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { SelectedSlotRepositoryFixture } from "test/fixtures/repository/selected-slot.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";

import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants";
import { BookingResponse } from "@calcom/platform-libraries";
import { RESERVED_SLOT_UID_COOKIE_NAME } from "@calcom/platform-libraries/slots";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { User, SelectedSlots } from "@calcom/prisma/client";

describe("Reserved Slot Bookings Endpoints 2024-04-15", () => {
describe("reservedSlotUid functionality", () => {
let app: INestApplication;

let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotRepositoryFixture: SelectedSlotRepositoryFixture;

const userEmail = `reserved-slot-bookings-user-${randomString()}@api.com`;
let user: User;
let eventTypeId: number;
const eventTypeSlug = `reserved-slot-bookings-event-type-${randomString()}`;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();

userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);

user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});

const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `reserved-slot-bookings-2024-04-15-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);

const event = await eventTypesRepositoryFixture.create(
{
title: `reserved-slot-bookings-2024-04-15-event-type-${randomString()}`,
slug: eventTypeSlug,
length: 60,
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
},
},
},
user.id
);
eventTypeId = event.id;

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});

async function createReservedSlot(
userId: number,
eventTypeId: number,
uid: string,
startDate: Date,
endDate: Date
): Promise<SelectedSlots> {
const releaseAt = DateTime.utc().plus({ minutes: 15 }).toJSDate();

return selectedSlotRepositoryFixture.create({
userId,
eventTypeId,
uid,
slotUtcStartDate: startDate,
slotUtcEndDate: endDate,
releaseAt,
isSeat: false,
});
}

describe("POST /v2/bookings", () => {
it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => {
const reservedSlotUid = `reserved-slot-${randomString()}`;
const startTime = new Date("2040-05-21T09:30:00.000Z");
const endTime = new Date("2040-05-21T10:30:00.000Z");

await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime);

const bookingData: CreateBookingInput_2024_04_15 = {
start: startTime.toISOString(),
eventTypeId,
timeZone: "Europe/Rome",
language: "en",
metadata: {},
hashedLink: null,
responses: {
name: "Test Attendee",
email: `reserved-slot-test-${randomString()}@example.com`,
},
};

const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingData)
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.set("Cookie", `${RESERVED_SLOT_UID_COOKIE_NAME}=${reservedSlotUid}`)
.expect(201);

const responseBody: ApiSuccessResponse<BookingResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();

// Verify the selected slot was removed
const remainingSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlotUid);
expect(remainingSlot).toBeNull();
});

it("should create booking with reservedSlotUid from request body and remove selected slot", async () => {
const reservedSlotUid = `reserved-slot-${randomString()}`;
const startTime = new Date("2040-05-21T11:30:00.000Z");
const endTime = new Date("2040-05-21T12:30:00.000Z");

await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime);

const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = {
start: startTime.toISOString(),
eventTypeId,
timeZone: "Europe/Rome",
language: "en",
metadata: {},
hashedLink: null,
responses: {
name: "Test Attendee",
email: `reserved-slot-test-${randomString()}@example.com`,
},
reservedSlotUid,
};

const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingData)
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.expect(201);

const responseBody: ApiSuccessResponse<BookingResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();

// Verify the selected slot was removed
const remainingSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlotUid);
expect(remainingSlot).toBeNull();
});

it("should fail when trying to book with reservedSlotUid that is not first in line", async () => {
const firstReservedSlotUid = `reserved-slot-first-${randomString()}`;
const secondReservedSlotUid = `reserved-slot-second-${randomString()}`;
const startTime = new Date("2040-05-21T13:30:00.000Z");
const endTime = new Date("2040-05-21T14:30:00.000Z");

await createReservedSlot(user.id, eventTypeId, firstReservedSlotUid, startTime, endTime);

await new Promise((resolve) => setTimeout(resolve, 100));

await createReservedSlot(user.id, eventTypeId, secondReservedSlotUid, startTime, endTime);

const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = {
start: startTime.toISOString(),
eventTypeId,
timeZone: "Europe/Rome",
language: "en",
metadata: {},
hashedLink: null,
responses: {
name: "Test Attendee",
email: `reserved-slot-test-${randomString()}@example.com`,
},
reservedSlotUid: secondReservedSlotUid, // Try to book with the second (not first in line)
};

const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(bookingData)
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
.expect(400);

expect(response.body.message).toContain("Someone else reserved this slot before you");
expect(response.body.message).toContain("will be freed up in");
expect(response.body.message).toContain("seconds");

// Verify both slots still exist
const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid);
const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid);
expect(firstSlotStillExists).toBeTruthy();
expect(secondSlotStillExists).toBeTruthy();

// Clean up
await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid);
await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid);
});
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
propertyName: propertyName,
options: validationOptions,
validator: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validate(value: any) {
if (typeof value === "string") {
return value.trim().length > 0;
Expand Down Expand Up @@ -231,4 +232,14 @@ export class CreateBookingInput_2024_04_15 {
@IsOptional()
@ApiPropertyOptional()
crmOwnerRecordType?: string;

@ApiPropertyOptional({
type: String,
description:
"Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.",
example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb",
})
@IsOptional()
@IsString()
reservedSlotUid?: string;
}
Loading
Loading