diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts index da25f3a596ea92..2f0150ec24409c 100644 --- a/apps/api/v1/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -4,7 +4,7 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; import { createMocks } from "node-mocks-http"; -import { describe, expect, test, vi, beforeEach } from "vitest"; +import { describe, expect, test, vi, beforeEach, type Mock } from "vitest"; import dayjs from "@calcom/dayjs"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; @@ -67,8 +67,15 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({ const mockFindOriginalRescheduledBooking = vi.fn(); vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ - BookingRepository: vi.fn().mockImplementation(() => ({ + BookingRepository: vi.fn().mockImplementation((prismaClient) => ({ findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking, + create: vi.fn().mockImplementation((args, tx) => { + return (tx || prismaClient).booking.create(args); + }), + update: vi.fn().mockImplementation((args, tx) => { + return (tx || prismaClient).booking.update(args); + }), + getValidBookingFromEventTypeForAttendee: vi.fn().mockResolvedValue(null), })), })); @@ -166,7 +173,7 @@ vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({ })); vi.mock("@calcom/lib/server/i18n", () => { - const mockT = (key: string, options?: any) => { + const mockT = (key: string, options?: Record) => { if (key === "event_between_users") { return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`; } @@ -236,12 +243,12 @@ describe("POST /api/bookings", () => { vi.clearAllMocks(); mockFindOriginalRescheduledBooking.mockResolvedValue(null); - (getEventTypesFromDB as any).mockResolvedValue(mockEventTypeData.eventType); + (getEventTypesFromDB as Mock).mockResolvedValue(mockEventTypeData.eventType); }); describe("Errors", () => { test("Missing required data", async () => { - (getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.RequestBodyInvalid)); + (getEventTypesFromDB as Mock).mockRejectedValue(new Error(ErrorCode.RequestBodyInvalid)); const { req, res } = createMocks({ method: "POST", @@ -259,7 +266,7 @@ describe("POST /api/bookings", () => { }); test("Invalid eventTypeId", async () => { - (getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.EventTypeNotFound)); + (getEventTypesFromDB as Mock).mockRejectedValue(new Error(ErrorCode.EventTypeNotFound)); const { req, res } = createMocks({ method: "POST", @@ -439,10 +446,11 @@ describe("POST /api/bookings", () => { oneTimePassword: null, creationSource: "API_V1", }); + prismaMock.booking.create.mockResolvedValue(mockBooking); prismaMock.$transaction.mockImplementation(async (callback) => { - const mockTx = { + const _mockTx = { booking: { - create: prismaMock.booking.create.mockResolvedValue(mockBooking), + create: prismaMock.booking.create, update: vi.fn().mockResolvedValue({}), }, app_RoutingForms_FormResponse: { @@ -516,10 +524,13 @@ describe("POST /api/bookings", () => { oneTimePassword: null, fromReschedule: "original-booking-uid", }); + + prismaMock.booking.create.mockResolvedValue(mockBooking); + prismaMock.$transaction.mockImplementation(async (callback) => { - const mockTx = { + const _mockTx = { booking: { - create: prismaMock.booking.create.mockResolvedValue(mockBooking), + create: prismaMock.booking.create, update: vi.fn().mockResolvedValue({}), }, app_RoutingForms_FormResponse: { @@ -580,10 +591,11 @@ describe("POST /api/bookings", () => { oneTimePassword: null, creationSource: "API_V1", }); + prismaMock.booking.create.mockResolvedValue(mockBooking); prismaMock.$transaction.mockImplementation(async (callback) => { - const mockTx = { + const _mockTx = { booking: { - create: prismaMock.booking.create.mockResolvedValue(mockBooking), + create: prismaMock.booking.create, update: vi.fn().mockResolvedValue({}), }, app_RoutingForms_FormResponse: { @@ -639,7 +651,7 @@ describe("POST /api/bookings", () => { users: [buildUser()], }); - const mockBookings = Array.from(Array(12).keys()).map((i) => + const _mockBookings = Array.from(Array(12).keys()).map((i) => buildBooking({ id: i + 1, uid: `recurring-booking-${i}` }) ); @@ -698,7 +710,7 @@ describe("POST /api/bookings", () => { }); const createdAt = new Date(); - const mockBookings = Array.from(Array(12).keys()).map((i) => + const _mockBookings = Array.from(Array(12).keys()).map((i) => buildBooking({ id: i + 1, uid: `webhook-booking-${i}`, createdAt }) ); 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 ebad60d604075e..4bf3c1482e4893 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 @@ -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, @@ -208,6 +209,7 @@ export class BookingsController_2024_04_15 { await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid); const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed); + const reservedSlotUid = getReservedSlotUidFromRequest(req); const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, bookingMeta: { @@ -220,6 +222,7 @@ export class BookingsController_2024_04_15 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid, }, }); if (booking.userId && booking.uid && booking.startTime) { @@ -336,6 +339,7 @@ export class BookingsController_2024_04_15 { } } const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed); + const reservedSlotUid = getReservedSlotUidFromRequest(req); const createdBookings: BookingResponse[] = await this.recurringBookingService.createBooking({ bookingData: body.map((booking) => ({ ...booking, creationSource: CreationSource.API_V2 })), bookingMeta: { @@ -347,6 +351,7 @@ export class BookingsController_2024_04_15 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, noEmail: bookingRequest.body.noEmail, + reservedSlotUid, }, }); @@ -647,6 +652,19 @@ export class BookingsController_2024_04_15 { type === "no-show" ? `Error while marking no-show.` : `Error while creating ${type ? type + " " : ""}booking.`; + + if ( + typeof err === "object" && + err !== null && + "message" in err && + err.message === "reserved_slot_not_first_in_line" + ) { + const secondsUntilRelease = + ("data" in err && (err.data as { secondsUntilRelease?: number })?.secondsUntilRelease) ?? 300; + const message = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${secondsUntilRelease} seconds.`; + throw new HttpException(message, 409); + } + if (err instanceof HttpError) { const httpError = err as HttpError; throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/reserved-slot-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/reserved-slot-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..f20a053c68bc5b --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/reserved-slot-bookings.e2e-spec.ts @@ -0,0 +1,537 @@ +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 { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-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-20240415-user-${randomString()}@api.com`; + let user: User; + let eventTypeId: number; + const eventTypeSlug = `reserved-slot-20240415-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); + + 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 { + 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", () => { + describe("normal event type", () => { + 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(), + end: endTime.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 = 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-22T11:30:00.000Z"); + const endTime = new Date("2040-05-22T12:30:00.000Z"); + + await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + end: endTime.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 = 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(), + end: endTime.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(409); + + const message: string = response.body.error.message; + const match = message.match(/(\d+) seconds\.$/); + expect(match).not.toBeNull(); + const secondsFromMessage = parseInt(match![1], 10); + const expected = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${secondsFromMessage} seconds.`; + expect(message).toEqual(expected); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); + }); + + it("should allow booking with second reservedSlotUid when the first reservation has expired", async () => { + const firstReservedSlotUid = `reserved-slot-first-expired-${randomString()}`; + const secondReservedSlotUid = `reserved-slot-second-active-${randomString()}`; + const startTime = new Date("2040-05-24T09:30:00.000Z"); + const endTime = new Date("2040-05-24T10:30:00.000Z"); + + await selectedSlotRepositoryFixture.create({ + userId: user.id, + eventTypeId, + uid: firstReservedSlotUid, + slotUtcStartDate: startTime, + slotUtcEndDate: endTime, + releaseAt: DateTime.utc().minus({ minutes: 1 }).toJSDate(), + isSeat: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await selectedSlotRepositoryFixture.create({ + userId: user.id, + eventTypeId, + uid: secondReservedSlotUid, + slotUtcStartDate: startTime, + slotUtcEndDate: endTime, + releaseAt: DateTime.utc().plus({ minutes: 15 }).toJSDate(), + isSeat: false, + }); + + const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + end: endTime.toISOString(), + eventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid: secondReservedSlotUid, + }; + + 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 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotRemoved = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotRemoved).toBeNull(); + + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + }); + }); + + describe("recurring event type", () => { + let recurringEventTypeId: number; + const recurringEventTypeSlug = `recurring-reserved-slot-20240415-event-type-${randomString()}`; + + beforeAll(async () => { + const recurringEvent = await eventTypesRepositoryFixture.create( + { + title: `recurring-reserved-slot-bookings-2024-04-15-event-type-${randomString()}`, + slug: recurringEventTypeSlug, + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + metadata: { + disableStandardEmails: { + all: { + attendee: true, + host: true, + }, + }, + }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + }); + + it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + const reservedSlotUid = `reserved-slot-${randomString()}`; + const startTime1 = new Date("2040-05-23T09:30:00.000Z"); + const endTime1 = new Date("2040-05-23T10:30:00.000Z"); + const startTime2 = new Date("2040-05-30T09:30:00.000Z"); + const endTime2 = new Date("2040-05-30T10:30:00.000Z"); + const startTime3 = new Date("2040-06-06T09:30:00.000Z"); + const endTime3 = new Date("2040-06-06T10:30:00.000Z"); + + await createReservedSlot(user.id, recurringEventTypeId, reservedSlotUid, startTime1, endTime1); + + const bookingData: CreateRecurringBookingInput_2024_04_15[] = [ + { + start: startTime1.toISOString(), + end: endTime1.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in cookie", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + recurringEventId: "test-reserved-slot-recurring-id1", + }, + { + start: startTime2.toISOString(), + end: endTime2.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in cookie", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + recurringEventId: "test-reserved-slot-recurring-id2", + }, + { + start: startTime3.toISOString(), + end: endTime3.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in cookie", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + recurringEventId: "test-reserved-slot-recurring-id3", + }, + ]; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings/recurring") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .set("Cookie", `${RESERVED_SLOT_UID_COOKIE_NAME}=${reservedSlotUid}`) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(Array.isArray(responseBody.data)).toBe(true); + expect(responseBody.data[0].id).toBeDefined(); + + 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 startTime1 = new Date("2040-05-21T11:30:00.000Z"); + const endTime1 = new Date("2040-05-21T12:30:00.000Z"); + const startTime2 = new Date("2040-05-28T11:30:00.000Z"); + const endTime2 = new Date("2040-05-28T12:30:00.000Z"); + const startTime3 = new Date("2040-06-04T11:30:00.000Z"); + const endTime3 = new Date("2040-06-04T12:30:00.000Z"); + + await createReservedSlot(user.id, recurringEventTypeId, reservedSlotUid, startTime1, endTime1); + + const bookingData: (CreateRecurringBookingInput_2024_04_15 & { reservedSlotUid: string })[] = [ + { + start: startTime1.toISOString(), + end: endTime1.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in request body", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid, + recurringEventId: "test-reserved-slot-recurring-id-body4", + }, + { + start: startTime2.toISOString(), + end: endTime2.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in request body", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid, + recurringEventId: "test-reserved-slot-recurring-id-body5", + }, + { + start: startTime3.toISOString(), + end: endTime3.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee recurring uid in request body", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid, + recurringEventId: "test-reserved-slot-recurring-id-body6", + }, + ]; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings/recurring") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(201); + + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(Array.isArray(responseBody.data)).toBe(true); + expect(responseBody.data[0].id).toBeDefined(); + + 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, recurringEventTypeId, firstReservedSlotUid, startTime, endTime); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await createReservedSlot(user.id, recurringEventTypeId, secondReservedSlotUid, startTime, endTime); + + const bookingData: (CreateRecurringBookingInput_2024_04_15 & { reservedSlotUid: string })[] = [ + { + start: startTime.toISOString(), + end: endTime.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid: secondReservedSlotUid, + recurringEventId: "test-reserved-slot-recurring-conflict", + }, + ]; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings/recurring") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(409); + + const message: string = response.body.error.message; + const match = message.match(/(\d+) seconds\.$/); + expect(match).not.toBeNull(); + const secondsFromMessage = parseInt(match![1], 10); + const expected = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${secondsFromMessage} seconds.`; + expect(message).toEqual(expected); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + 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(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 7fdc39cbb7159e..2b17f214390b22 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -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; @@ -226,4 +227,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) and non-seated bookings aka 1 on 1 bookings and recurring bookings. Instant bookings don't support reserved slot uid because for them slots are not reserved.", + example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb", + }) + @IsOptional() + @IsString() + reservedSlotUid?: string; } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reserved-slot-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reserved-slot-bookings.e2e-spec.ts new file mode 100644 index 00000000000000..326a08d46f6b6b --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reserved-slot-bookings.e2e-spec.ts @@ -0,0 +1,437 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +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_08_13 } from "@calcom/platform-constants"; +import { RESERVED_SLOT_UID_COOKIE_NAME } from "@calcom/platform-libraries/slots"; +import type { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import type { User, SelectedSlots } from "@calcom/prisma/client"; + +describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { + 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-20240813-user-${randomString()}@api.com`; + let user: User; + let eventTypeId: number; + const eventTypeSlug = `reserved-slot-20240813-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); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: `reserved-slot-bookings-2024-08-13-schedule-${randomString()}`, + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(user.id, userSchedule); + + const event = await eventTypesRepositoryFixture.create( + { + title: `reserved-slot-bookings-2024-08-13-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 { + 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", () => { + describe("normal event type", () => { + 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_08_13 = { + start: startTime.toISOString(), + eventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Cookie", `${RESERVED_SLOT_UID_COOKIE_NAME}=${reservedSlotUid}`) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect((responseBody.data as BookingOutput_2024_08_13).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-22T11:30:00.000Z"); + const endTime = new Date("2040-05-22T12:30:00.000Z"); + + await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_08_13 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + reservedSlotUid, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect((responseBody.data as BookingOutput_2024_08_13).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_08_13 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + 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_08_13) + .expect(409); + + const message: string = response.body.error.message; + const match = message.match(/(\d+) seconds\.$/); + expect(match).not.toBeNull(); + const secondsFromMessage = parseInt(match![1], 10); + const expected = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${secondsFromMessage} seconds.`; + expect(message).toEqual(expected); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); + }); + + it("should allow booking with second reservedSlotUid when the first reservation has expired", async () => { + const firstReservedSlotUid = `reserved-slot-first-expired-${randomString()}`; + const secondReservedSlotUid = `reserved-slot-second-active-${randomString()}`; + + const startTime = new Date("2040-05-24T09:30:00.000Z"); + const endTime = new Date("2040-05-24T10:30:00.000Z"); + + await selectedSlotRepositoryFixture.create({ + userId: user.id, + eventTypeId, + uid: firstReservedSlotUid, + slotUtcStartDate: startTime, + slotUtcEndDate: endTime, + releaseAt: DateTime.utc().minus({ minutes: 1 }).toJSDate(), + isSeat: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await selectedSlotRepositoryFixture.create({ + userId: user.id, + eventTypeId, + uid: secondReservedSlotUid, + slotUtcStartDate: startTime, + slotUtcEndDate: endTime, + releaseAt: DateTime.utc().plus({ minutes: 15 }).toJSDate(), + isSeat: false, + }); + + const bookingData: CreateBookingInput_2024_08_13 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + reservedSlotUid: secondReservedSlotUid, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect((responseBody.data as BookingOutput_2024_08_13).id).toBeDefined(); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotRemoved = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotRemoved).toBeNull(); + + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + }); + }); + + describe("recurring event type", () => { + let recurringEventTypeId: number; + const recurringEventTypeSlug = `recurring-reserved-slot-20240813-event-type-${randomString()}`; + + beforeAll(async () => { + const recurringEvent = await eventTypesRepositoryFixture.create( + { + title: `recurring-reserved-slot-bookings-2024-08-13-event-type-${randomString()}`, + slug: recurringEventTypeSlug, + length: 60, + recurringEvent: { freq: 2, count: 3, interval: 1 }, + metadata: { + disableStandardEmails: { + all: { + attendee: true, + host: true, + }, + }, + }, + }, + user.id + ); + recurringEventTypeId = recurringEvent.id; + }); + + it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + const reservedSlotUid = `reserved-slot-${randomString()}`; + const startTime = new Date("2040-05-23T09:30:00.000Z"); + const endTime = new Date("2040-05-23T10:30:00.000Z"); + + await createReservedSlot(user.id, recurringEventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_08_13 = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Cookie", `${RESERVED_SLOT_UID_COOKIE_NAME}=${reservedSlotUid}`) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(Array.isArray(responseBody.data)).toBe(true); + expect((responseBody.data as RecurringBookingOutput_2024_08_13[])[0].id).toBeDefined(); + + 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, recurringEventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_08_13 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + reservedSlotUid, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201); + + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(Array.isArray(responseBody.data)).toBe(true); + expect((responseBody.data as RecurringBookingOutput_2024_08_13[])[0].id).toBeDefined(); + + 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, recurringEventTypeId, firstReservedSlotUid, startTime, endTime); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await createReservedSlot(user.id, recurringEventTypeId, secondReservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_08_13 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + reservedSlotUid: secondReservedSlotUid, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(409); + + const message: string = response.body.error.message; + const match = message.match(/(\d+) seconds\.$/); + expect(match).not.toBeNull(); + const secondsFromMessage = parseInt(match![1], 10); + const expected = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${secondsFromMessage} seconds.`; + expect(message).toEqual(expected); + + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + 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(); + }); + }); +}); 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 bca52edae34964..8791862fbf3e57 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 @@ -463,6 +463,7 @@ export class BookingsService_2024_08_13 { platformBookingLocation: bookingRequest.platformBookingLocation, noEmail: bookingRequest.noEmail, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); const ids = bookings.map((booking) => booking.id || 0); @@ -487,6 +488,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); return this.outputService.getOutputCreateRecurringSeatedBookings( @@ -512,6 +514,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); @@ -546,6 +549,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); @@ -753,6 +757,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); if (!booking.uid) { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts index beeadd63950dc7..e85cad7a4f44f3 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/errors.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException, ConflictException } from "@nestjs/common"; import { Logger } from "@nestjs/common"; import { CreateBookingInput } from "@calcom/platform-types"; @@ -34,7 +34,7 @@ export class ErrorsBookingsService_2024_08_13 { handleBookingError(error: unknown, bookingTeamEventType: boolean): never { const hostsUnavaile = "One of the hosts either already has booking at this time or is not available"; - if (error instanceof Error) { + if (error instanceof Error || (typeof error === "object" && error !== null && "message" in error)) { if (error.message === "no_available_users_found_error") { if (bookingTeamEventType) { throw new BadRequestException(hostsUnavaile); @@ -61,6 +61,11 @@ export class ErrorsBookingsService_2024_08_13 { message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`; } throw new BadRequestException(message); + } else 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 booking time slot before you. This time slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; + throw new ConflictException(message); } } throw error; 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 5e6e92977e04be..ab8edcf8e28fa1 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 @@ -31,6 +31,7 @@ import { z } from "zod"; import { CreationSource } from "@calcom/platform-libraries"; import { EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types"; +import { getReservedSlotUidFromRequest } from "@calcom/platform-libraries/slots"; import type { CancelBookingInput, CancelBookingInput_2024_08_13, @@ -50,6 +51,7 @@ import type { EventType } from "@calcom/prisma/client"; type BookingRequest = NextApiRequest & { userId: number | undefined; noEmail: boolean | undefined; + reservedSlotUid: string | undefined; } & OAuthRequestParams; type OAuthRequestParams = { @@ -107,7 +109,7 @@ export class InputBookingsService_2024_08_13 { eventType, oAuthClientParams?.platformClientId ); - + const reservedSlotUid = getReservedSlotUidFromRequest(request); const newRequest = { ...request }; const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; @@ -118,15 +120,19 @@ export class InputBookingsService_2024_08_13 { }); if (oAuthClientParams) { - Object.assign(newRequest, { userId, ...oAuthClientParams }); + Object.assign(newRequest, { userId, ...oAuthClientParams, reservedSlotUid }); newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled, creationSource: CreationSource.API_V2, }; } else { - Object.assign(newRequest, { userId }); - newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 }; + Object.assign(newRequest, { userId, reservedSlotUid }); + newRequest.body = { + ...bodyTransformed, + noEmail: false, + creationSource: CreationSource.API_V2, + }; } return newRequest as unknown as BookingRequest; @@ -247,6 +253,7 @@ export class InputBookingsService_2024_08_13 { oAuthClientParams?.platformClientId ); + const reservedSlotUid = getReservedSlotUidFromRequest(request); const newRequest = { ...request }; const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; @@ -255,9 +262,10 @@ export class InputBookingsService_2024_08_13 { userId, ...oAuthClientParams, noEmail: !oAuthClientParams.arePlatformEmailsEnabled, + reservedSlotUid, }); } else { - Object.assign(newRequest, { userId }); + Object.assign(newRequest, { userId, reservedSlotUid }); } newRequest.body = bodyTransformed.map((event) => ({ diff --git a/apps/api/v2/src/lib/modules/regular-booking.module.ts b/apps/api/v2/src/lib/modules/regular-booking.module.ts index d7f34a96eb8203..ff1bb6f22b7bb8 100644 --- a/apps/api/v2/src/lib/modules/regular-booking.module.ts +++ b/apps/api/v2/src/lib/modules/regular-booking.module.ts @@ -4,6 +4,8 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repos import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; +import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; +import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { BookingEmailSmsService } from "@/lib/services/booking-emails-sms-service"; import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; @@ -27,6 +29,7 @@ import { Module, Scope } from "@nestjs/common"; PrismaFeaturesRepository, PrismaHostRepository, PrismaOOORepository, + PrismaSelectedSlotRepository, PrismaUserRepository, { provide: Logger, @@ -46,6 +49,7 @@ import { Module, Scope } from "@nestjs/common"; BookingEmailAndSmsTriggerTaskerService, BookingEmailAndSmsTasker, RegularBookingService, + PrismaRoutingFormResponseRepository, ], exports: [RegularBookingService], }) diff --git a/apps/api/v2/src/lib/services/regular-booking.service.ts b/apps/api/v2/src/lib/services/regular-booking.service.ts index 0a6e4d07f4737d..79af2239c860e5 100644 --- a/apps/api/v2/src/lib/services/regular-booking.service.ts +++ b/apps/api/v2/src/lib/services/regular-booking.service.ts @@ -1,4 +1,6 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; +import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository"; import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; @@ -18,9 +20,11 @@ export class RegularBookingService extends BaseRegularBookingService { checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService, prismaWriteService: PrismaWriteService, bookingRepository: PrismaBookingRepository, + selectedSlotsRepository: PrismaSelectedSlotRepository, hashedLinkService: HashedLinkService, luckyUserService: LuckyUserService, userRepository: PrismaUserRepository, + routingFormResponseRepository: PrismaRoutingFormResponseRepository, bookingEmailAndSmsTasker: BookingEmailAndSmsTasker, featuresRepository: PrismaFeaturesRepository, bookingEventHandler: BookingEventHandlerService @@ -29,9 +33,11 @@ export class RegularBookingService extends BaseRegularBookingService { checkBookingAndDurationLimitsService, prismaClient: prismaWriteService.prisma as unknown as PrismaClient, bookingRepository, + selectedSlotsRepository, hashedLinkService, luckyUserService, userRepository, + routingFormResponseRepository, bookingEmailAndSmsTasker, featuresRepository, bookingEventHandler, diff --git a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts index 2c83a8dfe19a81..918b09294072e5 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts @@ -7,7 +7,7 @@ import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/servic import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger"; import { Response as ExpressResponse, Request as ExpressRequest } from "express"; import { @@ -18,6 +18,10 @@ import { VERSION_2024_08_13, } from "@calcom/platform-constants"; import { TRPCError } from "@calcom/platform-libraries"; +import { + RESERVED_SLOT_UID_COOKIE_NAME, + getReservedSlotUidFromCookies, +} from "@calcom/platform-libraries/slots"; import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types"; import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types"; @@ -57,9 +61,9 @@ export class SlotsController_2024_04_15 { @Res({ passthrough: true }) res: ExpressResponse, @Req() req: ExpressRequest ): Promise> { - const uid = await this.slotsService.reserveSlot(body, req.cookies?.uid); + const uid = await this.slotsService.reserveSlot(body, getReservedSlotUidFromCookies(req)); - res.cookie("uid", uid); + res.cookie(RESERVED_SLOT_UID_COOKIE_NAME, uid); return { status: SUCCESS_STATUS, data: uid, @@ -81,7 +85,7 @@ export class SlotsController_2024_04_15 { @Query() params: RemoveSelectedSlotInput_2024_04_15, @Req() req: ExpressRequest ): Promise { - const uid = req.cookies?.uid || params.uid; + const uid = getReservedSlotUidFromCookies(req) || params.uid; await this.slotsService.deleteSelectedslot(uid); diff --git a/apps/api/v2/test/fixtures/repository/selected-slot.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/selected-slot.repository.fixture.ts index 1ba1ff61d5bfca..e8eeff92c35baa 100644 --- a/apps/api/v2/test/fixtures/repository/selected-slot.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/selected-slot.repository.fixture.ts @@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { TestingModule } from "@nestjs/testing"; -import type { SelectedSlots } from "@calcom/prisma/client"; +import type { SelectedSlots, Prisma } from "@calcom/prisma/client"; export class SelectedSlotRepositoryFixture { private prismaReadClient: PrismaReadService["prisma"]; @@ -17,6 +17,10 @@ export class SelectedSlotRepositoryFixture { return this.prismaReadClient.selectedSlots.findFirst({ where: { uid } }); } + async create(data: Prisma.SelectedSlotsCreateInput) { + return this.prismaWriteClient.selectedSlots.create({ data }); + } + async deleteByUId(uid: SelectedSlots["uid"]) { return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } }); } diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 3782520a947207..5cf3d9c38856d5 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -13,6 +13,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import type { TraceContext } from "@calcom/lib/tracing"; import { prisma } from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; +import { getReservedSlotUidFromRequest } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; async function handler(req: NextApiRequest & { userId?: number; traceContext: TraceContext }) { const userIp = getIP(req); @@ -47,12 +48,14 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr }; const regularBookingService = getRegularBookingService(); + const reservedSlotUid = getReservedSlotUidFromRequest(req); const booking = await regularBookingService.createBooking({ bookingData: req.body, bookingMeta: { userId: session?.user?.id || -1, hostname: req.headers.host || "", forcedSlug: req.headers["x-cal-force-slug"] as string | undefined, + reservedSlotUid, traceContext: req.traceContext, }, }); diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 23b9a28fae670c..54ffd3b488d3c2 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -8,6 +8,7 @@ import getIP from "@calcom/lib/getIP"; import { piiHasher } from "@calcom/lib/server/PiiHasher"; import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import { getReservedSlotUidFromRequest } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; // @TODO: Didn't look at the contents of this function in order to not break old booking page. @@ -44,6 +45,7 @@ async function handler(req: NextApiRequest & RequestMeta) { /* To mimic API behavior and comply with types */ const recurringBookingService = getRecurringBookingService(); + const reservedSlotUid = getReservedSlotUidFromRequest(req); const createdBookings: BookingResponse[] = await recurringBookingService.createBooking({ bookingData: req.body, bookingMeta: { @@ -54,6 +56,7 @@ async function handler(req: NextApiRequest & RequestMeta) { platformRescheduleUrl: req.platformRescheduleUrl, platformBookingLocation: req.platformBookingLocation, noEmail: req.noEmail, + reservedSlotUid, }, }); diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 213d47db96777f..848851789a3a30 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -105,7 +105,7 @@ test.describe("free user", () => { await page.goto(`/${free.username}`); }); - test("cannot book same slot multiple times", async ({ page, users, emails }) => { + test("cannot book same slot multiple times", async ({ page, users, emails: _emails }) => { const [user] = users.get(); const bookerObj = { @@ -114,7 +114,6 @@ test.describe("free user", () => { }; // Click first event type await page.click('[data-testid="event-type-link"]'); - await selectFirstAvailableTimeSlotNextMonth(page); await bookTimeSlot(page, bookerObj); @@ -124,7 +123,7 @@ test.describe("free user", () => { // Make sure we're navigated to the success page await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - const { title: eventTitle } = await user.getFirstEventAsOwner(); + const { title: _eventTitle } = await user.getFirstEventAsOwner(); await page.goto(bookingUrl); @@ -255,7 +254,7 @@ test.describe("pro user", () => { await expect(cancelledHeadline).toBeVisible(); const bookingCancelledId = new URL(page.url()).pathname.split("/booking/")[1]; - const { slug: eventSlug } = await pro.getFirstEventAsOwner(); + const { slug: _eventSlug } = await pro.getFirstEventAsOwner(); await page.goto(`/reschedule/${bookingCancelledId}`); @@ -280,7 +279,7 @@ test.describe("pro user", () => { await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible(); }); - test("can book an unconfirmed event multiple times", async ({ page, users }) => { + test("can book an unconfirmed event multiple times", async ({ page, users: _users }) => { await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click(); await selectFirstAvailableTimeSlotNextMonth(page); @@ -297,7 +296,7 @@ test.describe("pro user", () => { test("booking an unconfirmed event with the same email brings you to the original request", async ({ page, - users, + users: _users, }) => { await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click(); await selectFirstAvailableTimeSlotNextMonth(page); @@ -312,7 +311,7 @@ test.describe("pro user", () => { await expect(page.locator("[data-testid=success-page]")).toBeVisible(); }); - test("can book with multiple guests", async ({ page, users }) => { + test("can book with multiple guests", async ({ page, users: _users }) => { const additionalGuests = ["test@gmail.com", "test2@gmail.com"]; await page.click('[data-testid="event-type-link"]'); @@ -335,7 +334,7 @@ test.describe("pro user", () => { await Promise.all(promises); }); - test("Time slots should be reserved when selected", async ({ context, page, browser }) => { + test("Time slots should be reserved when selected", async ({ context: _context, page, browser }) => { const initialUrl = page.url(); await page.locator('[data-testid="event-type-link"]').first().click(); await selectFirstAvailableTimeSlotNextMonth(page); @@ -419,7 +418,7 @@ test.describe("prefill", () => { test("Persist the field values when going back and coming back to the booking form", async ({ page, - users, + users: _users, }) => { await page.goto("/pro/30min"); await selectFirstAvailableTimeSlotNextMonth(page); @@ -434,7 +433,7 @@ test.describe("prefill", () => { await expect(page.locator('[name="notes"]')).toHaveValue("Test notes"); }); - test("logged out", async ({ page, users }) => { + test("logged out", async ({ page, users: _users }) => { await page.goto("/pro/30min"); await test.step("from query params", async () => { @@ -708,11 +707,16 @@ test("Should throw error when both seatsPerTimeSlot and recurringEvent are set", }); test.describe("GTM container", () => { - test.beforeEach(async ({ page, users }) => { + test.beforeEach(async ({ page: _page, users }) => { await users.create(); }); - test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => { + test("global GTM should not be loaded on private booking link", async ({ + page, + users, + emails: _emails, + prisma, + }) => { const [user] = users.get(); const eventType = await user.getFirstEventAsOwner(); @@ -794,3 +798,72 @@ test.describe("Past booking cancellation", () => { await expect(page.locator('[data-testid="cancel"]')).toBeHidden(); }); }); + +test.describe("reservedSlotUid in request body", () => { + test.beforeEach(async ({ page, users }) => { + const pro = await users.create(); + await page.goto(`/${pro.username}`); + }); + + test("web app booker sends reservedSlotUid in request body", async ({ page, prisma }) => { + let bookingRequestBody: { reservedSlotUid: string | null } = { reservedSlotUid: null }; + let reservedSlotUidFromReserve: string | null = null; + let reservedSlotUidFromBooking: string | null = null; + + page.on("request", (request) => { + if (request.url().includes("/api/book/event") && request.method() === "POST") { + const body = request.postDataJSON(); + bookingRequestBody = body; + reservedSlotUidFromBooking = body?.reservedSlotUid ?? null; + } + }); + + await page.click('[data-testid="event-type-link"]'); + + const reserveRespPromise = page.waitForResponse( + (response) => response.url().includes("slots/reserveSlot") && response.request().method() === "POST" + ); + + await selectFirstAvailableTimeSlotNextMonth(page); + + const reserveResp = await reserveRespPromise; + const reserveJson = await reserveResp.json(); + const uidFromReserve = + reserveJson?.result?.data?.json?.uid ?? + (Array.isArray(reserveJson) ? reserveJson[0]?.result?.data?.json?.uid : undefined); + if (!uidFromReserve) { + throw new Error("reserveSlot response did not contain a uid"); + } + reservedSlotUidFromReserve = uidFromReserve; + + const preSelectedSlot = await prisma.selectedSlots.findFirst({ where: { uid: uidFromReserve } }); + expect(preSelectedSlot).not.toBeNull(); + + await page.fill('[name="name"]', testName); + await page.fill('[name="email"]', testEmail); + + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/book/event") && response.request().method() === "POST" + ); + + await page.locator('[data-testid="confirm-book-button"]').click(); + + const _response = await responsePromise; + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + expect(bookingRequestBody).toBeTruthy(); + expect(bookingRequestBody).toHaveProperty("reservedSlotUid"); + expect(typeof bookingRequestBody.reservedSlotUid).toBe("string"); + if (bookingRequestBody.reservedSlotUid) { + expect(bookingRequestBody.reservedSlotUid.length).toBeGreaterThan(0); + expect(reservedSlotUidFromBooking).toEqual(reservedSlotUidFromReserve); + const selectedSlot = await prisma.selectedSlots.findFirst({ + where: { uid: bookingRequestBody.reservedSlotUid as string }, + }); + expect(selectedSlot).toBeNull(); + } else { + throw new Error("reservedSlotUid is not set"); + } + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9d0d0f85ac6a7a..987af962908a03 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3842,6 +3842,7 @@ "collapse_panel": "Collapse Panel", "email_verification_required": "Email verification is required for this event type", "invalid_verification_code": "Invalid verification code provided", + "reserved_slot_not_first_in_line": "Another guest reserved this booking time slot before you. Try again in {{secondsUntilRelease}} seconds or pick a different time.", "you_have_one_team": "You have one team", "consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.", "consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 6e2edd145364a6..1c5abb4dc295d5 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -2819,6 +2819,7 @@ "organization.attributes.update", "organization.attributes.delete", "organization.attributes.create", + "organization.attributes.editUsers", "routingForm.create", "routingForm.read", "routingForm.update", @@ -26227,6 +26228,7 @@ "organization.attributes.update", "organization.attributes.delete", "organization.attributes.create", + "organization.attributes.editUsers", "routingForm.create", "routingForm.read", "routingForm.update", @@ -26330,6 +26332,7 @@ "organization.attributes.update", "organization.attributes.delete", "organization.attributes.create", + "organization.attributes.editUsers", "routingForm.create", "routingForm.read", "routingForm.update", @@ -26461,6 +26464,7 @@ "organization.attributes.update", "organization.attributes.delete", "organization.attributes.create", + "organization.attributes.editUsers", "routingForm.create", "routingForm.read", "routingForm.update", @@ -26563,6 +26567,7 @@ "organization.attributes.update", "organization.attributes.delete", "organization.attributes.create", + "organization.attributes.editUsers", "routingForm.create", "routingForm.read", "routingForm.update", @@ -27400,6 +27405,14 @@ "type": "string", "nullable": true }, + "avatarUrl": { + "type": "string", + "nullable": true + }, + "bio": { + "type": "string", + "nullable": true + }, "timeFormat": { "type": "number" }, @@ -27426,6 +27439,8 @@ "username", "email", "name", + "avatarUrl", + "bio", "timeFormat", "defaultScheduleId", "weekStart", @@ -27796,6 +27811,11 @@ "type": "string", "description": "Email verification code required when event type has email verification enabled.", "example": "123456" + }, + "reservedSlotUid": { + "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) and non-seated bookings aka 1 on 1 bookings and recurring bookings. Instant bookings don't support reserved slot uid because for them slots are not reserved.", + "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" } }, "required": ["start", "attendee"] @@ -27920,6 +27940,11 @@ "description": "Email verification code required when event type has email verification enabled.", "example": "123456" }, + "reservedSlotUid": { + "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) and non-seated bookings aka 1 on 1 bookings and recurring bookings. Instant bookings don't support reserved slot uid because for them slots are not reserved.", + "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" + }, "instant": { "type": "boolean", "description": "Flag indicating if the booking is an instant booking. Only available for team events.", @@ -28048,6 +28073,11 @@ "description": "Email verification code required when event type has email verification enabled.", "example": "123456" }, + "reservedSlotUid": { + "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) and non-seated bookings aka 1 on 1 bookings and recurring bookings. Instant bookings don't support reserved slot uid because for them slots are not reserved.", + "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" + }, "recurrenceCount": { "type": "number", "description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count", diff --git a/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts b/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts index 8c50488ca2aa03..ffaa950b765241 100644 --- a/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/embed-pages.e2e.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; -// eslint-disable-next-line no-restricted-imports + import { test } from "@calcom/web/playwright/lib/fixtures"; import "../../src/types"; @@ -127,4 +127,53 @@ test.describe("Embed Pages", () => { expect(embedTheme).toBe(theme); }); }); + + test.describe("reservedSlotUid in request body", () => { + test("embed booker sends reservedSlotUid in request body", async ({ page }) => { + let bookingRequestBody: { reservedSlotUid: string | null } = { reservedSlotUid: null }; + + page.on("request", (request) => { + if (request.url().includes("/api/book/event") && request.method() === "POST") { + bookingRequestBody = request.postDataJSON(); + } + }); + + await page.evaluate(() => { + window.name = "cal-embed="; + }); + + await page.goto("http://localhost:3000/free/30min/embed"); + + await page.waitForSelector('[data-testid="booker-container"]'); + + await page.click('[data-testid="incrementMonth"]'); + await page.waitForSelector('[data-testid="day"][data-disabled="false"]'); + await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); + + await page.waitForSelector('[data-testid="time"]'); + await page.locator('[data-testid="time"]').nth(0).click(); + + await page.fill('[name="name"]', "Test User Embed"); + await page.fill('[name="email"]', "test-embed@example.com"); + + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/book/event") && response.request().method() === "POST" + ); + + await page.locator('[data-testid="confirm-book-button"]').click(); + + await responsePromise; + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + expect(bookingRequestBody).toBeTruthy(); + expect(bookingRequestBody).toHaveProperty("reservedSlotUid"); + expect(typeof bookingRequestBody.reservedSlotUid).toBe("string"); + if (bookingRequestBody.reservedSlotUid) { + expect(bookingRequestBody.reservedSlotUid.length).toBeGreaterThan(0); + } else { + throw new Error("reservedSlotUid is not set"); + } + }); + }); }); diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index a9abddc0cb7b0c..86b9d615a96bf9 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -302,6 +302,7 @@ const getError = ({ let date = ""; let count = 0; + let secondsUntilRelease = 0; if (error.message === ErrorCode.BookerLimitExceededReschedule) { const formattedDate = formatEventFromTime({ @@ -317,12 +318,16 @@ const getError = ({ count = error.data.count; } + if (error.message === "reserved_slot_not_first_in_line" && error.data?.secondsUntilRelease) { + secondsUntilRelease = error.data.secondsUntilRelease; + } + const messageKey = error.message === ErrorCode.BookerLimitExceeded ? "booker_upcoming_limit_reached" : error.message; return error?.message ? ( <> - {responseVercelIdHeader ?? ""} {t(messageKey, { date, count })} + {responseVercelIdHeader ?? ""} {t(messageKey, { date, count, secondsUntilRelease })} ) : ( <>{t("can_you_try_again")} diff --git a/packages/features/bookings/Booker/components/hooks/useSlots.ts b/packages/features/bookings/Booker/components/hooks/useSlots.ts index faebef50984eec..6f75f1772dc455 100644 --- a/packages/features/bookings/Booker/components/hooks/useSlots.ts +++ b/packages/features/bookings/Booker/components/hooks/useSlots.ts @@ -3,7 +3,6 @@ import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; import { MINUTES_TO_BOOK, @@ -22,12 +21,12 @@ const useQuickAvailabilityChecks = ({ eventTypeId, eventDuration, timeslotsAsISOString, - slotReservationId, + reservedSlotUid, }: { eventTypeId: number | undefined; eventDuration: number; timeslotsAsISOString: string[]; - slotReservationId: string | undefined | null; + reservedSlotUid: string | undefined | null; }) => { // Maintain a cache to ensure previous state is maintained as the request is fetched // It is important because tentatively selecting a new timeslot will cause a new request which is uncached. @@ -51,16 +50,16 @@ const useQuickAvailabilityChecks = ({ { slots: slotsToCheck, // enabled flag can't be true if eventTypeId is nullish - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + eventTypeId: eventTypeId!, }, { refetchInterval: PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS * 1000, refetchOnWindowFocus: true, - // We must have slotReservationId because it is possible that slotReservationId is set right after isAvailable request is made and we accidentally could consider the slot as unavailable. + // We must have reservedSlotUid because it is possible that reservedSlotUid is set right after isAvailable request is made and we accidentally could consider the slot as unavailable. // isAvailable request also prevents querying reserved slots if uid is not set. We are safe from both sides. // TODO: We should move to creating uuid client side for reservation and not waiting for server to set uid cookie. - enabled: !!(eventTypeId && timeslotsAsISOString.length > 0 && slotReservationId), + enabled: !!(eventTypeId && timeslotsAsISOString.length > 0 && reservedSlotUid), staleTime: PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS * 1000, } ); @@ -90,7 +89,10 @@ export const useSlots = (event: { id: number; length: number } | null) => { ], shallow ); - const [slotReservationId, setSlotReservationId] = useSlotReservationId(); + const [reservedSlotUid, setReservedSlotUid] = useBookerStoreContext( + (state) => [state.reservedSlotUid, state.setReservedSlotUid], + shallow + ); const reserveSlotMutation = trpc.viewer.slots.reserveSlot.useMutation({ trpc: { context: { @@ -98,7 +100,7 @@ export const useSlots = (event: { id: number; length: number } | null) => { }, }, onSuccess: (data) => { - setSlotReservationId(data.uid); + setReservedSlotUid(data.uid); }, }); const removeSelectedSlot = trpc.viewer.slots.removeSelectedSlotMark.useMutation({ @@ -106,8 +108,8 @@ export const useSlots = (event: { id: number; length: number } | null) => { }); const handleRemoveSlot = () => { - if (event?.id && slotReservationId) { - removeSelectedSlot.mutate({ uid: slotReservationId }); + if (event?.id && reservedSlotUid) { + removeSelectedSlot.mutate({ uid: reservedSlotUid }); } }; @@ -123,7 +125,7 @@ export const useSlots = (event: { id: number; length: number } | null) => { eventTypeId, eventDuration, timeslotsAsISOString: allUniqueSelectedTimeslots, - slotReservationId, + reservedSlotUid, }); // In case of skipConfirm flow selectedTimeslot would never be set and instead we could have multiple tentatively selected timeslots, so we pick the latest one from it. @@ -151,7 +153,6 @@ export const useSlots = (event: { id: number; length: number } | null) => { handleRemoveSlot(); clearInterval(interval); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [event?.id, timeSlotToBeBooked]); return { @@ -159,7 +160,7 @@ export const useSlots = (event: { id: number; length: number } | null) => { setTentativeSelectedTimeslots, selectedTimeslot, tentativeSelectedTimeslots, - slotReservationId, + reservedSlotUid, allSelectedTimeslots, /** * Faster but not that accurate as getSchedule, but doesn't give false positive, so it is usable diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index c6f2627a582bee..fffb25bf2041ca 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -189,7 +189,9 @@ export type BookerStore = { * forth between timeslots and form. Gets cleared on submit * to prevent sticky data. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any formValues: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any setFormValues: (values: Record) => void; /** * Force event being a team event, so we only query for team events instead @@ -214,6 +216,8 @@ export type BookerStore = { crmRecordId?: string | null; isPlatform?: boolean; allowUpdatingUrlParams?: boolean; + reservedSlotUid: string | null; + setReservedSlotUid: (reservedSlotUid: string | null) => void; defaultPhoneCountry?: CountryCode | null; }; @@ -486,6 +490,7 @@ export const createBookerStore = () => } }, formValues: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any setFormValues: (formValues: Record) => { set({ formValues }); }, @@ -493,6 +498,10 @@ export const createBookerStore = () => setOrg: (org: string | null | undefined) => { set({ org }); }, + reservedSlotUid: null, + setReservedSlotUid: (reservedSlotUid: string | null) => { + set({ reservedSlotUid }); + }, isPlatform: false, allowUpdatingUrlParams: true, defaultPhoneCountry: null, diff --git a/packages/features/bookings/Booker/useSlotReservationId.ts b/packages/features/bookings/Booker/useSlotReservationId.ts deleted file mode 100644 index 3639543dbf498d..00000000000000 --- a/packages/features/bookings/Booker/useSlotReservationId.ts +++ /dev/null @@ -1,15 +0,0 @@ -// TODO: It would be lost on refresh, so we need to persist it. -// Though, we are persisting it in a cookie(`uid` cookie is set through reserveSlot call) -// but that becomes a third party cookie in context of embed and thus isn't accessible inside embed -// So, we need to persist it in top window as first party cookie in that case. -let slotReservationId: null | string = null; - -export const useSlotReservationId = () => { - function set(uid: string) { - slotReservationId = uid; - } - function get() { - return slotReservationId; - } - return [get(), set] as const; -}; diff --git a/packages/features/bookings/di/InstantBookingCreateService.module.ts b/packages/features/bookings/di/InstantBookingCreateService.module.ts index 741615330da87f..1ff3465def6074 100644 --- a/packages/features/bookings/di/InstantBookingCreateService.module.ts +++ b/packages/features/bookings/di/InstantBookingCreateService.module.ts @@ -1,7 +1,7 @@ import { InstantBookingCreateService } from "@calcom/features/bookings/lib/service/InstantBookingCreateService"; import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di"; -import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; export const instantBookingCreateServiceModule = createModule(); const token = DI_TOKENS.INSTANT_BOOKING_CREATE_SERVICE; diff --git a/packages/features/bookings/di/RegularBookingService.module.ts b/packages/features/bookings/di/RegularBookingService.module.ts index 3131efc37c4c57..786651703c9a90 100644 --- a/packages/features/bookings/di/RegularBookingService.module.ts +++ b/packages/features/bookings/di/RegularBookingService.module.ts @@ -7,10 +7,11 @@ import { moduleLoader as checkBookingAndDurationLimitsModuleLoader } from "@calc import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features"; import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/di/modules/LuckyUser"; import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { moduleLoader as routingFormResponseRepositoryModuleLoader } from "@calcom/features/di/modules/RoutingFormResponse"; +import { moduleLoader as selectedSlotsRepositoryModuleLoader } from "@calcom/features/di/modules/SelectedSlots"; import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as hashedLinkServiceModuleLoader } from "@calcom/features/hashedLink/di/HashedLinkService.module"; - import { moduleLoader as bookingEmailAndSmsTaskerModuleLoader } from "./tasker/BookingEmailAndSmsTasker.module"; const thisModule = createModule(); @@ -26,9 +27,11 @@ const loadModule = bindModuleToClassOnToken({ prismaClient: prismaModuleLoader, checkBookingAndDurationLimitsService: checkBookingAndDurationLimitsModuleLoader, bookingRepository: bookingRepositoryModuleLoader, + selectedSlotsRepository: selectedSlotsRepositoryModuleLoader, luckyUserService: luckyUserServiceModuleLoader, userRepository: userRepositoryModuleLoader, hashedLinkService: hashedLinkServiceModuleLoader, + routingFormResponseRepository: routingFormResponseRepositoryModuleLoader, bookingEmailAndSmsTasker: bookingEmailAndSmsTaskerModuleLoader, featuresRepository: featuresRepositoryModuleLoader, bookingEventHandler: bookingEventHandlerModuleLoader, diff --git a/packages/features/bookings/lib/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index c30947f343748c..925e489d29b251 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -51,6 +51,7 @@ export const bookingCreateBodySchema = z.object({ dub_id: z.string().nullish(), creationSource: z.nativeEnum(CreationSource).optional(), verificationCode: z.string().optional(), + reservedSlotUid: z.string().optional(), }); export type BookingCreateBody = z.input; diff --git a/packages/features/bookings/lib/client/booking-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/client/booking-event-form/booking-to-mutation-input-mapper.tsx index 3ef9aeced3bf0c..cb0ea7ae63f343 100644 --- a/packages/features/bookings/lib/client/booking-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/client/booking-event-form/booking-to-mutation-input-mapper.tsx @@ -32,6 +32,7 @@ export type BookingOptions = { routingFormSearchParams?: RoutingFormSearchParams; isDryRunProp?: boolean; verificationCode?: string; + reservedSlotUid?: string; }; export const mapBookingToMutationInput = ({ @@ -56,6 +57,7 @@ export const mapBookingToMutationInput = ({ routingFormSearchParams, isDryRunProp, verificationCode, + reservedSlotUid, }: BookingOptions): BookingCreateBody => { const searchParams = new URLSearchParams(routingFormSearchParams ?? window.location.search); const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); @@ -98,6 +100,7 @@ export const mapBookingToMutationInput = ({ _isDryRun, dub_id, verificationCode, + reservedSlotUid, }; }; diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 75e6efc10bd05a..722c34f34e5581 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -38,6 +38,7 @@ export type CreateBookingMeta = { hostname?: string; forcedSlug?: string; noEmail?: boolean; + reservedSlotUid?: string; traceContext?: TraceContext; } & PlatformParams; diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index fff6b0f342d266..a2b45375f349ba 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -5,12 +5,14 @@ import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-fo import dayjs from "@calcom/dayjs"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import type { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import prisma from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { BookingRepository } from "../../repositories/BookingRepository"; import type { TgetBookingDataSchema } from "../getBookingDataSchema"; import type { AwaitedBookingData, EventTypeId } from "./getBookingData"; import type { NewBookingEventType } from "./getEventTypesFromDB"; @@ -20,7 +22,7 @@ import type { PaymentAppData, Tracking } from "./types"; type ReqBodyWithEnd = TgetBookingDataSchema & { end: string }; -type CreateBookingParams = { +export type CreateBookingParams = { uid: short.SUUID; routingFormResponseId: number | undefined; reroutingFormResponses: z.infer | null; @@ -72,20 +74,26 @@ async function getAssociatedBookingForFormResponse(formResponseId: number) { return formResponse?.routedToBookingUid ?? null; } -// Define the function with underscore prefix -const _createBooking = async ({ - uid, - reqBody, - eventType, - input, - evt, - originalRescheduledBooking, - routingFormResponseId, - reroutingFormResponses, - rescheduledBy, - creationSource, - tracking, -}: CreateBookingParams & { rescheduledBy: string | undefined }) => { +const _createBooking = async ( + { + uid, + reqBody, + eventType, + input, + evt, + originalRescheduledBooking, + routingFormResponseId, + reroutingFormResponses, + rescheduledBy, + creationSource, + tracking, + }: CreateBookingParams & { rescheduledBy: string | undefined }, + deps: { + tx: Prisma.TransactionClient; + routingFormResponseRepository: RoutingFormResponseRepository; + bookingRepository: BookingRepository; + } +) => { updateEventDetails(evt, originalRescheduledBooking); const associatedBookingForFormResponse = routingFormResponseId ? await getAssociatedBookingForFormResponse(routingFormResponseId) @@ -109,7 +117,8 @@ const _createBooking = async ({ bookingAndAssociatedData, originalRescheduledBooking, eventType.paymentAppData, - eventType.organizerUser + eventType.organizerUser, + deps ); function shouldConnectBookingToFormResponse() { @@ -136,7 +145,12 @@ async function saveBooking( bookingAndAssociatedData: ReturnType, originalRescheduledBooking: OriginalRescheduledBooking, paymentAppData: PaymentAppData, - organizerUser: CreateBookingParams["eventType"]["organizerUser"] + organizerUser: CreateBookingParams["eventType"]["organizerUser"], + deps: { + tx: Prisma.TransactionClient; + routingFormResponseRepository: RoutingFormResponseRepository; + bookingRepository: BookingRepository; + } ) { const { newBookingData, reroutingFormResponseUpdateData, originalBookingUpdateDataForCancellation } = bookingAndAssociatedData; @@ -172,18 +186,20 @@ async function saveBooking( /** * Reschedule(Cancellation + Creation) with an update of reroutingFormResponse should be atomic */ - return prisma.$transaction(async (tx) => { + const run = async (client: Prisma.TransactionClient) => { if (originalBookingUpdateDataForCancellation) { - await tx.booking.update(originalBookingUpdateDataForCancellation); + await deps.bookingRepository.update(originalBookingUpdateDataForCancellation, client); } - const booking = await tx.booking.create(createBookingObj); + const booking = await deps.bookingRepository.create(createBookingObj, client); if (reroutingFormResponseUpdateData) { - await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData); + await deps.routingFormResponseRepository.update(reroutingFormResponseUpdateData, client); } return booking; - }); + }; + + return run(deps.tx); } function getEventTypeRel(eventTypeId: EventTypeId) { diff --git a/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts b/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts new file mode 100644 index 00000000000000..72ec61c2bc929a --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts @@ -0,0 +1,51 @@ +import dayjs from "@calcom/dayjs"; +import { HttpError } from "@calcom/lib/http-error"; +import type { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/PrismaSelectedSlotRepository"; +import type { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; +import type { PrismaClient } from "@calcom/prisma"; + +import type { BookingRepository } from "../../repositories/BookingRepository"; +import { createBooking } from "./createBooking"; +import type { CreateBookingParams } from "./createBooking"; + +type ReservedSlot = { + eventTypeId: number; + slotUtcStart: Date; + slotUtcEnd: Date; + reservedSlotUid: string; +}; + +export async function createBookingWithReservedSlot( + deps: { + prismaClient: PrismaClient; + selectedSlotsRepository: PrismaSelectedSlotRepository; + routingFormResponseRepository: RoutingFormResponseRepository; + bookingRepository: BookingRepository; + }, + args: CreateBookingParams & { rescheduledBy: string | undefined }, + reservedSlot: ReservedSlot +) { + return deps.prismaClient.$transaction(async (tx) => { + const earliestActive = await deps.selectedSlotsRepository.findEarliestActiveSlot(reservedSlot, tx); + + if (earliestActive && earliestActive.uid !== reservedSlot.reservedSlotUid) { + const now = dayjs.utc().toDate(); + const secondsUntilRelease = dayjs(earliestActive.releaseAt).diff(now, "second"); + throw new HttpError({ + statusCode: 409, + message: "reserved_slot_not_first_in_line", + data: { secondsUntilRelease }, + }); + } + + const booking = await createBooking(args, { + tx, + routingFormResponseRepository: deps.routingFormResponseRepository, + bookingRepository: deps.bookingRepository, + }); + + await deps.selectedSlotsRepository.deleteForEvent(reservedSlot, tx); + + return booking; + }); +} diff --git a/packages/features/bookings/lib/service/RecurringBookingService.ts b/packages/features/bookings/lib/service/RecurringBookingService.ts index 3a1cf759ba2c57..8eef5c15840259 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.ts @@ -29,6 +29,7 @@ export const handleNewRecurringBooking = async ( // for round robin, the first slot needs to be handled first to define the lucky user const firstBooking = data[0]; const isRoundRobin = firstBooking.schedulingType === SchedulingType.ROUND_ROBIN; + const isTeamEvent = !!firstBooking.schedulingType; let luckyUsers = undefined; @@ -60,6 +61,8 @@ export const handleNewRecurringBooking = async ( hostname: input.hostname || "", forcedSlug: input.forcedSlug as string | undefined, ...handleBookingMeta, + // note(Lauris): RegularBookingService.ts does not handle team event slot reservation - once it does here we need to pass input.reservedSlotUid + reservedSlotUid: undefined, }, }); luckyUsers = firstBookingResult.luckyUsers; @@ -97,12 +100,17 @@ export const handleNewRecurringBooking = async ( luckyUsers, }; + const isFirstNonTeamEventRecurrence = !isTeamEvent && key === 0; + const promiseEachRecurringBooking = regularBookingService.createBooking({ bookingData: recurringEventData, bookingMeta: { hostname: input.hostname || "", forcedSlug: input.forcedSlug as string | undefined, ...handleBookingMeta, + // note(Lauris): recurring event reserves only 1 slot currently while technically it should reserve slot for each recurrence. When / if it does, we need to have reservedSlotUids array for recurring event types and for each + // pass the reservedSlotUid here. Probably it would be an array of objects where each object has key of reservedSlotUid and then identifier connecting it to specific slot probably based on start time. + reservedSlotUid: isFirstNonTeamEventRecurrence ? input.reservedSlotUid : undefined, }, }); diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 636e16f71c831d..f5946834363ddd 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -73,6 +73,8 @@ import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFre import { safeStringify } from "@calcom/lib/safeStringify"; import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import { getTranslation } from "@calcom/lib/server/i18n"; +import type { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/PrismaSelectedSlotRepository"; +import type { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { distributedTracing } from "@calcom/lib/tracing/factory"; import type { PrismaClient } from "@calcom/prisma"; @@ -106,7 +108,9 @@ import { addVideoCallDataToEvent } from "../handleNewBooking/addVideoCallDataToE import { checkActiveBookingsLimitForBooker } from "../handleNewBooking/checkActiveBookingsLimitForBooker"; import { checkIfBookerEmailIsBlocked } from "../handleNewBooking/checkIfBookerEmailIsBlocked"; import { createBooking } from "../handleNewBooking/createBooking"; +import type { CreateBookingParams } from "../handleNewBooking/createBooking"; import type { Booking } from "../handleNewBooking/createBooking"; +import { createBookingWithReservedSlot } from "../handleNewBooking/createBookingWithReservedSlot"; import { ensureAvailableUsers } from "../handleNewBooking/ensureAvailableUsers"; import { getBookingData } from "../handleNewBooking/getBookingData"; import { getCustomInputsResponses } from "../handleNewBooking/getCustomInputsResponses"; @@ -416,9 +420,11 @@ export interface IBookingServiceDependencies { checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService; prismaClient: PrismaClient; bookingRepository: BookingRepository; + selectedSlotsRepository: PrismaSelectedSlotRepository; luckyUserService: LuckyUserService; userRepository: UserRepository; hashedLinkService: HashedLinkService; + routingFormResponseRepository: RoutingFormResponseRepository; bookingEmailAndSmsTasker: BookingEmailAndSmsTasker; featuresRepository: FeaturesRepository; bookingEventHandler: BookingEventHandlerService; @@ -542,6 +548,8 @@ async function handler( eventType, }); + const bookingStartUtc = dayjs(reqBody.start).utc().toDate(); + const bookingEndUtc = dayjs(reqBody.end).utc().toDate(); const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: tracingLogger }); try { @@ -654,7 +662,7 @@ async function handler( eventTypeId, bookerEmail, bookerPhoneNumber, - startTime: new Date(dayjs(reqBody.start).utc().format()), + startTime: bookingStartUtc, filterForUnconfirmed: !isConfirmedByDefault, }); @@ -818,7 +826,7 @@ async function handler( const booking = await deps.prismaClient.booking.findFirst({ where: { eventTypeId: eventType.id, - startTime: new Date(dayjs(reqBody.start).utc().format()), + startTime: bookingStartUtc, status: BookingStatus.ACCEPTED, }, select: { @@ -1706,7 +1714,7 @@ async function handler( try { if (!isDryRun) { - booking = await createBooking({ + const createArgs: CreateBookingParams = { uid, rescheduledBy: reqBody.rescheduledBy, routingFormResponseId: routingFormResponseId, @@ -1734,7 +1742,32 @@ async function handler( originalRescheduledBooking, creationSource: input.bookingData.creationSource, tracking: reqBody.tracking, - }); + }; + + const isTeamEvent = eventType.schedulingType; + if (input.reservedSlotUid && !eventType.seatsPerTimeSlot && !isTeamEvent) { + booking = await createBookingWithReservedSlot( + { + prismaClient: deps.prismaClient, + routingFormResponseRepository: deps.routingFormResponseRepository, + selectedSlotsRepository: deps.selectedSlotsRepository, + bookingRepository: deps.bookingRepository, + }, + createArgs, + { + eventTypeId, + slotUtcStart: bookingStartUtc, + slotUtcEnd: bookingEndUtc, + reservedSlotUid: input.reservedSlotUid, + } + ); + } else { + booking = await createBooking(createArgs, { + tx: deps.prismaClient, + routingFormResponseRepository: deps.routingFormResponseRepository, + bookingRepository: deps.bookingRepository, + }); + } if (booking?.userId) { const usersRepository = new UsersRepository(); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 0cd30bafdf8245..8702748265a512 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -323,6 +323,21 @@ const selectStatementToGetBookingForCalEventBuilder = { export class BookingRepository { constructor(private prismaClient: PrismaClient) {} + async create( + args: Prisma.SelectSubset, + tx?: Prisma.TransactionClient + ) { + if (tx) { + return tx.booking.create(args); + } + return this.prismaClient.booking.create(args); + } + + async update(args: Prisma.BookingUpdateArgs, tx?: Prisma.TransactionClient) { + const db = tx ?? this.prismaClient; + return db.booking.update(args); + } + async getBookingAttendees(bookingId: number) { return await this.prismaClient.attendee.findMany({ where: { diff --git a/packages/features/di/modules/RoutingFormResponse.ts b/packages/features/di/modules/RoutingFormResponse.ts index 8fadfe109bc0ce..2e9570cd92c264 100644 --- a/packages/features/di/modules/RoutingFormResponse.ts +++ b/packages/features/di/modules/RoutingFormResponse.ts @@ -1,9 +1,21 @@ +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; -import { createModule } from "../di"; +import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di"; export const routingFormResponseRepositoryModule = createModule(); -routingFormResponseRepositoryModule - .bind(DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY) - .toClass(RoutingFormResponseRepository, [DI_TOKENS.PRISMA_CLIENT]); +const token = DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY; +const moduleToken = DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: routingFormResponseRepositoryModule, + moduleToken, + token, + classs: RoutingFormResponseRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/di/modules/SelectedSlots.ts b/packages/features/di/modules/SelectedSlots.ts index c106559b482dec..297ca34acd1b2d 100644 --- a/packages/features/di/modules/SelectedSlots.ts +++ b/packages/features/di/modules/SelectedSlots.ts @@ -1,9 +1,21 @@ +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/PrismaSelectedSlotRepository"; -import { createModule } from "../di"; +import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di"; export const selectedSlotsRepositoryModule = createModule(); -selectedSlotsRepositoryModule - .bind(DI_TOKENS.SELECTED_SLOT_REPOSITORY) - .toClass(PrismaSelectedSlotRepository, [DI_TOKENS.PRISMA_CLIENT]); +const token = DI_TOKENS.SELECTED_SLOT_REPOSITORY; +const moduleToken = DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: selectedSlotsRepositoryModule, + moduleToken, + token, + classs: PrismaSelectedSlotRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/lib/server/repository/PrismaSelectedSlotRepository.ts b/packages/lib/server/repository/PrismaSelectedSlotRepository.ts index da0cff8905a3ec..7900bd1b02faa7 100644 --- a/packages/lib/server/repository/PrismaSelectedSlotRepository.ts +++ b/packages/lib/server/repository/PrismaSelectedSlotRepository.ts @@ -1,3 +1,4 @@ +import dayjs from "@calcom/dayjs"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -89,4 +90,47 @@ export class PrismaSelectedSlotRepository implements ISelectedSlotRepository { }, }); } + + async deleteForEvent( + reservedSlot: { + eventTypeId: number; + slotUtcStart: Date; + slotUtcEnd: Date; + reservedSlotUid: string; + }, + tx?: Prisma.TransactionClient + ) { + const db = tx || this.prismaClient; + return db.selectedSlots.deleteMany({ + where: { + eventTypeId: reservedSlot.eventTypeId, + slotUtcStartDate: reservedSlot.slotUtcStart, + slotUtcEndDate: reservedSlot.slotUtcEnd, + uid: reservedSlot.reservedSlotUid, + }, + }); + } + + async findEarliestActiveSlot( + reservedSlot: { + eventTypeId: number; + slotUtcStart: Date; + slotUtcEnd: Date; + }, + tx?: Prisma.TransactionClient + ) { + const db = tx || this.prismaClient; + const now = dayjs.utc().toDate(); + + return await db.selectedSlots.findFirst({ + where: { + eventTypeId: reservedSlot.eventTypeId, + slotUtcStartDate: reservedSlot.slotUtcStart, + slotUtcEndDate: reservedSlot.slotUtcEnd, + releaseAt: { gt: now }, + }, + orderBy: [{ releaseAt: "asc" }, { id: "asc" }], + select: { uid: true, releaseAt: true }, + }); + } } diff --git a/packages/lib/server/repository/formResponse.ts b/packages/lib/server/repository/formResponse.ts index fd8a3de9db00f4..39ceaf9ff66e85 100644 --- a/packages/lib/server/repository/formResponse.ts +++ b/packages/lib/server/repository/formResponse.ts @@ -3,7 +3,7 @@ import type { Prisma } from "@calcom/prisma/client"; interface RecordFormResponseInput { formId: string; - response: Record | Prisma.JsonValue; + response: Record | Prisma.JsonValue; chosenRouteId: string | null; } @@ -159,4 +159,9 @@ export class RoutingFormResponseRepository { }, }); } + + async update(args: Prisma.App_RoutingForms_FormResponseUpdateArgs, tx?: Prisma.TransactionClient) { + const db = tx ?? this.prismaClient; + return db.app_RoutingForms_FormResponse.update(args); + } } diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index b84021d7d38395..7f1c9c25a28fad 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -64,6 +64,7 @@ export const useHandleBookEvent = ({ const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId); const verificationCode = useBookerStoreContext((state) => state.verificationCode); + const reservedSlotUid = useBookerStoreContext((state) => state.reservedSlotUid); const handleError = (err: unknown) => { const errorMessage = err instanceof Error ? t(err.message) : t("can_you_try_again"); showToast(errorMessage, "error"); @@ -115,6 +116,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, isDryRunProp: isBookingDryRun, verificationCode: verificationCode || undefined, + reservedSlotUid: reservedSlotUid || undefined, }; const tracking = getUtmTrackingParameters(searchParams); diff --git a/packages/platform/atoms/hooks/useSlots.ts b/packages/platform/atoms/hooks/useSlots.ts index 005fad29b353d4..79573b68b9e07c 100644 --- a/packages/platform/atoms/hooks/useSlots.ts +++ b/packages/platform/atoms/hooks/useSlots.ts @@ -3,7 +3,6 @@ import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import type { @@ -42,11 +41,14 @@ export const useSlots = ( shallow ); - const [slotReservationId, setSlotReservationId] = useSlotReservationId(); + const [reservedSlotUid, setReservedSlotUid] = useBookerStoreContext( + (state) => [state.reservedSlotUid, state.setReservedSlotUid], + shallow + ); const reserveSlotMutation = useReserveSlot({ onSuccess: (res) => { - setSlotReservationId(res.data); + setReservedSlotUid(res.data); onReserveSlotSuccess?.(res); }, onError: onReserveSlotError, @@ -61,7 +63,7 @@ export const useSlots = ( const handleRemoveSlot = () => { if (event?.data) { - removeSelectedSlot.mutate({ uid: slotReservationId ?? undefined }); + removeSelectedSlot.mutate({ uid: reservedSlotUid ?? undefined }); } }; @@ -98,14 +100,13 @@ export const useSlots = ( handleRemoveSlot(); clearInterval(interval); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [event?.data?.id, timeslot]); return { selectedTimeslot, setSelectedTimeslot, - setSlotReservationId, - slotReservationId, + setReservedSlotUid, + reservedSlotUid, handleReserveSlot, handleRemoveSlot, // TODO: implement slot no longer available feature diff --git a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts index 4a342f4b10a3cc..484cf5fff5ec75 100644 --- a/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts +++ b/packages/platform/examples/base/tests/booker-atom/booker-atom.e2e.ts @@ -41,3 +41,45 @@ test("tweak availability using AvailabilitySettings Atom", async ({ page }) => ); await expect(page.locator('[data-testid="booking-redirect-or-cancel-links"]')).toBeVisible(); }); + +test("atom booker sends reservedSlotUid in request body", async ({ page }) => { + let bookingRequestBody: { reservedSlotUid: string | null } = { reservedSlotUid: null }; + + page.on("request", (request) => { + if (request.url().includes("/bookings") && request.method() === "POST") { + bookingRequestBody = request.postDataJSON(); + } + }); + + await page.goto("/booking"); + + await expect(page.locator('[data-testid="event-type-card"]').first()).toBeVisible(); + await page.locator('[data-testid="event-type-card"]').first().click(); + + await expect(page.locator('[data-testid="booker-container"]')).toBeVisible(); + + await page.locator('[data-testid="day"]:not([data-disabled="true"])').first().click(); + await page.locator('[data-testid="time"]:not([data-disabled="true"])').first().click(); + + await page.fill('[name="name"]', "Test User"); + await page.fill('[name="email"]', "test@example.com"); + + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/bookings") && response.request().method() === "POST" + ); + + await page.locator('[data-testid="confirm-book-button"]').click(); + + await responsePromise; + + await expect(page.locator('[data-testid="booking-success-page"]')).toBeVisible(); + + expect(bookingRequestBody).toBeTruthy(); + expect(bookingRequestBody).toHaveProperty("reservedSlotUid"); + expect(typeof bookingRequestBody.reservedSlotUid).toBe("string"); + if (bookingRequestBody.reservedSlotUid) { + expect(bookingRequestBody.reservedSlotUid.length).toBeGreaterThan(0); + } else { + throw new Error("reservedSlotUid is not set"); + } +}); diff --git a/packages/platform/libraries/slots.ts b/packages/platform/libraries/slots.ts index d3925543f21f6a..68c73c37eb3916 100644 --- a/packages/platform/libraries/slots.ts +++ b/packages/platform/libraries/slots.ts @@ -14,3 +14,9 @@ export { QualifiedHostsService }; export { FilterHostsService }; export { NoSlotsNotificationService }; export { validateRoundRobinSlotAvailability }; + +export { + RESERVED_SLOT_UID_COOKIE_NAME, + getReservedSlotUidFromCookies, + getReservedSlotUidFromRequest, +} from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts index 1cd1cc431dbbc6..a55f8d1b5e11f8 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts @@ -388,6 +388,16 @@ export class CreateBookingInput_2024_08_13 { @IsOptional() @IsString() emailVerificationCode?: 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) and non-seated bookings aka 1 on 1 bookings and recurring bookings. Instant bookings don't support reserved slot uid because for them slots are not reserved.", + example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb", + }) + @IsOptional() + @IsString() + reservedSlotUid?: string; } export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { diff --git a/packages/trpc/server/routers/viewer/slots/_router.tsx b/packages/trpc/server/routers/viewer/slots/_router.tsx index c99b7af3b21ae9..9b57933d2df4b9 100644 --- a/packages/trpc/server/routers/viewer/slots/_router.tsx +++ b/packages/trpc/server/routers/viewer/slots/_router.tsx @@ -1,5 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { getReservedSlotUidFromCookies } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; + import publicProcedure from "../../../procedures/publicProcedure"; import { router } from "../../../trpc"; import { ZIsAvailableInputSchema, ZIsAvailableOutputSchema } from "./isAvailable.schema"; @@ -7,12 +9,6 @@ import { ZRemoveSelectedSlotInputSchema } from "./removeSelectedSlot.schema"; import { ZReserveSlotInputSchema } from "./reserveSlot.schema"; import { ZGetScheduleInputSchema } from "./types"; -type SlotsRouterHandlerCache = { - getSchedule?: typeof import("./getSchedule.handler").getScheduleHandler; - reserveSlot?: typeof import("./reserveSlot.handler").reserveSlotHandler; - isAvailable?: typeof import("./isAvailable.handler").isAvailableHandler; -}; - /** This should be called getAvailableSlots */ export const slotsRouter = router({ getSchedule: publicProcedure.input(ZGetScheduleInputSchema).query(async ({ input, ctx }) => { @@ -47,7 +43,7 @@ export const slotsRouter = router({ .input(ZRemoveSelectedSlotInputSchema) .mutation(async ({ input, ctx }) => { const { req, prisma } = ctx; - const uid = req?.cookies?.uid || input.uid; + const uid = getReservedSlotUidFromCookies(req) || input.uid; if (uid) { await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); } diff --git a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts index 0727af5498e18f..d4129c2ba94890 100644 --- a/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/isAvailable.handler.ts @@ -5,6 +5,7 @@ import { HttpError } from "@calcom/lib/http-error"; import { getPastTimeAndMinimumBookingNoticeBoundsStatus } from "@calcom/lib/isOutOfBounds"; import { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/PrismaSelectedSlotRepository"; import type { PrismaClient } from "@calcom/prisma"; +import { getReservedSlotUidFromCookies } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; import type { TIsAvailableInputSchema, TIsAvailableOutputSchema } from "./isAvailable.schema"; @@ -27,7 +28,7 @@ export const isAvailableHandler = async ({ input, }: IsAvailableOptions): Promise => { const { req } = ctx; - const uid = req?.cookies?.uid; + const uid = getReservedSlotUidFromCookies(req); const { slots, eventTypeId } = input; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts index ccd4f13539dcc0..38caecbc672649 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -13,6 +13,31 @@ import { TRPCError } from "@trpc/server"; import type { TReserveSlotInputSchema } from "./reserveSlot.schema"; +export const RESERVED_SLOT_UID_COOKIE_NAME = "uid"; + +export function getReservedSlotUidFromCookies(req?: { + cookies: Record | undefined; +}) { + return req?.cookies?.[RESERVED_SLOT_UID_COOKIE_NAME]; +} + +export function getReservedSlotUidFromRequest(req?: { + cookies: Record | undefined; + body?: { reservedSlotUid?: string } | Array<{ reservedSlotUid?: string }>; +}) { + const fromCookies = getReservedSlotUidFromCookies(req); + if (fromCookies) { + return fromCookies; + } + if (req?.body && !Array.isArray(req.body)) { + return req.body.reservedSlotUid; + } + if (Array.isArray(req?.body)) { + return req.body[0]?.reservedSlotUid; + } + return undefined; +} + interface ReserveSlotOptions { ctx: { prisma: PrismaClient; @@ -23,7 +48,7 @@ interface ReserveSlotOptions { } export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => { const { prisma, req, res } = ctx; - const uid = req?.cookies?.uid || uuid(); + const uid = getReservedSlotUidFromCookies(req) || uuid(); const { slotUtcStartDate, slotUtcEndDate, eventTypeId, _isDryRun } = input; const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); @@ -114,7 +139,7 @@ export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) => const useSecureCookies = WEBAPP_URL.startsWith("https://"); res?.setHeader( "Set-Cookie", - serialize("uid", uid, { + serialize(RESERVED_SLOT_UID_COOKIE_NAME, uid, { path: "/", sameSite: useSecureCookies ? "none" : "lax", secure: useSecureCookies, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 54325e3a3d8076..83186aa9e3dff6 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -52,6 +52,7 @@ import type { ISelectedSlotRepository } from "@calcom/lib/server/repository/ISel import type { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; import { SchedulingType, PeriodType } from "@calcom/prisma/enums"; +import { getReservedSlotUidFromCookies } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; import type { EventBusyDate, EventBusyDetails } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -1153,7 +1154,7 @@ export class AvailableSlotsService { }); let availableTimeSlots: typeof timeSlots = []; - const bookerClientUid = ctx?.req?.cookies?.uid; + const bookerClientUid = getReservedSlotUidFromCookies(ctx?.req); const isRestrictionScheduleFeatureEnabled = await this.checkRestrictionScheduleEnabled( eventType.team?.id );