From 307dce2b99c2c4df610fd171eb0fd792f8d2cd77 Mon Sep 17 00:00:00 2001 From: supalarry Date: Wed, 22 Oct 2025 12:41:12 +0200 Subject: [PATCH 01/18] refactor: reserved slot cookie constant and retrieval helper --- .../slots-2024-04-15/controllers/slots.controller.ts | 12 ++++++++---- packages/platform/libraries/slots.ts | 5 +++++ .../trpc/server/routers/viewer/slots/_router.tsx | 10 +++------- .../routers/viewer/slots/isAvailable.handler.ts | 3 ++- .../routers/viewer/slots/reserveSlot.handler.ts | 12 ++++++++++-- packages/trpc/server/routers/viewer/slots/util.ts | 9 +++++---- 6 files changed, 33 insertions(+), 18 deletions(-) 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/packages/platform/libraries/slots.ts b/packages/platform/libraries/slots.ts index d3925543f21f6a..7269d2cddb7263 100644 --- a/packages/platform/libraries/slots.ts +++ b/packages/platform/libraries/slots.ts @@ -14,3 +14,8 @@ export { QualifiedHostsService }; export { FilterHostsService }; export { NoSlotsNotificationService }; export { validateRoundRobinSlotAvailability }; + +export { + RESERVED_SLOT_UID_COOKIE_NAME, + getReservedSlotUidFromCookies, +} from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; 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..d7e2e8daee20ec 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -13,6 +13,14 @@ 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]; +} + interface ReserveSlotOptions { ctx: { prisma: PrismaClient; @@ -23,7 +31,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 +122,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 68f12eadc587ae..dfe1102f4661d1 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -53,6 +53,7 @@ import type { RoutingFormResponseRepository } from "@calcom/lib/server/repositor import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; import type { ScheduleRepository } from "@calcom/lib/server/repository/schedule"; 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"; @@ -83,7 +84,7 @@ export interface IGetAvailableSlots { emoji?: string | undefined; }[] >; - troubleshooter?: any; + troubleshooter?: unknown; } export type GetAvailableSlotsResponse = Awaited< @@ -460,7 +461,7 @@ export class AvailableSlotsService { rescheduleUid, timeZone, }); - } catch (_) { + } catch { limitManager.addBusyTime(periodStart, unit, timeZone); if ( periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone)) @@ -661,7 +662,7 @@ export class AvailableSlotsService { includeManagedEvents, timeZone, }); - } catch (_) { + } catch { limitManager.addBusyTime(periodStart, unit, timeZone); if ( periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone)) @@ -1156,7 +1157,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 ); From 3f7b8e0768322e0aa28fc09d86919228bf7c2019 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 27 Oct 2025 10:58:16 +0100 Subject: [PATCH 02/18] chore: extract uid from req --- packages/platform/libraries/slots.ts | 1 + .../routers/viewer/slots/reserveSlot.handler.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/platform/libraries/slots.ts b/packages/platform/libraries/slots.ts index 7269d2cddb7263..68c73c37eb3916 100644 --- a/packages/platform/libraries/slots.ts +++ b/packages/platform/libraries/slots.ts @@ -18,4 +18,5 @@ export { validateRoundRobinSlotAvailability }; export { RESERVED_SLOT_UID_COOKIE_NAME, getReservedSlotUidFromCookies, + getReservedSlotUidFromRequest, } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts index d7e2e8daee20ec..75e5ecc2df9f5a 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -21,6 +21,17 @@ export function getReservedSlotUidFromCookies(req?: { return req?.cookies?.[RESERVED_SLOT_UID_COOKIE_NAME]; } +export function getReservedSlotUidFromRequest(req?: { + cookies: Record | undefined; + body?: { reservedSlotUid?: string }; +}) { + const fromCookies = getReservedSlotUidFromCookies(req); + if (fromCookies) { + return fromCookies; + } + return req?.body?.reservedSlotUid; +} + interface ReserveSlotOptions { ctx: { prisma: PrismaClient; From e1f4cc0974e39de840bcf75b6e434a42b35a23d6 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 27 Oct 2025 12:24:59 +0100 Subject: [PATCH 03/18] chore: pass reservedSlotUid from api layer to handlers --- .../controllers/bookings.controller.ts | 8 ++++- .../2024-08-13/services/bookings.service.ts | 5 +++ .../2024-08-13/services/input.service.ts | 20 ++++++++--- apps/web/pages/api/book/event.ts | 3 ++ apps/web/pages/api/book/instant-event.ts | 5 +++ apps/web/pages/api/book/recurring-event.ts | 3 ++ docs/api-reference/v2/openapi.json | 33 +++++++++++++++++-- packages/features/bookings/lib/dto/types.d.ts | 1 + .../service/InstantBookingCreateService.ts | 12 +++++-- .../2024-08-13/inputs/create-booking.input.ts | 10 ++++++ 10 files changed, 88 insertions(+), 12 deletions(-) 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 96ae495ced5fc5..43dec83a503564 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 @@ -59,6 +59,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, @@ -191,6 +192,7 @@ export class BookingsController_2024_04_15 { const { orgSlug, locationUrl } = body; try { const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed); + const reservedSlotUid = getReservedSlotUidFromRequest(req); const booking = await this.regularBookingService.createBooking({ bookingData: bookingRequest.body, bookingMeta: { @@ -203,6 +205,7 @@ export class BookingsController_2024_04_15 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid, }, }); if (booking.userId && booking.uid && booking.startTime) { @@ -502,7 +505,10 @@ export class BookingsController_2024_04_15 { return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams; } - async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise { + async setPlatformAttendeesEmails( + requestBody: BookingRequest["body"], + oAuthClientId: string + ): Promise { if (requestBody?.responses?.email) { requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail( requestBody.responses.email, 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 b9d94bc8f0cb4d..f938518d2d2c3f 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 @@ -499,6 +499,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); @@ -523,6 +524,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); return this.outputService.getOutputCreateRecurringSeatedBookings( @@ -548,6 +550,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); @@ -582,6 +585,7 @@ export class BookingsService_2024_08_13 { platformBookingUrl: bookingRequest.platformBookingUrl, platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, + reservedSlotUid: bookingRequest.reservedSlotUid, }, }); @@ -779,6 +783,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/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index b2a931e08c2ba1..b239ce46bf7fda 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; @@ -123,10 +125,16 @@ export class InputBookingsService_2024_08_13 { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled, creationSource: CreationSource.API_V2, + reservedSlotUid, }; } else { Object.assign(newRequest, { userId }); - newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2 }; + newRequest.body = { + ...bodyTransformed, + noEmail: false, + creationSource: CreationSource.API_V2, + reservedSlotUid, + }; } return newRequest as unknown as BookingRequest; @@ -247,6 +255,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 +264,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) => ({ @@ -554,7 +564,7 @@ export class InputBookingsService_2024_08_13 { } isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 { - return body.hasOwnProperty("seatUid"); + return "seatUid" in body; } async transformInputRescheduleSeatedBooking( @@ -770,7 +780,7 @@ export class InputBookingsService_2024_08_13 { } isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 { - return body.hasOwnProperty("seatUid"); + return "seatUid" in body; } async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) { diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 7f5aa63d875e07..420ea07a819b0d 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -12,6 +12,7 @@ import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken" import { defaultResponder } from "@calcom/lib/server/defaultResponder"; 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 }) { const userIp = getIP(req); @@ -46,12 +47,14 @@ async function handler(req: NextApiRequest & { userId?: number }) { }; 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, }, }); // const booking = await createBookingThroughFactory(); diff --git a/apps/web/pages/api/book/instant-event.ts b/apps/web/pages/api/book/instant-event.ts index f809795c10c744..d54273bc2f6b59 100644 --- a/apps/web/pages/api/book/instant-event.ts +++ b/apps/web/pages/api/book/instant-event.ts @@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP"; import { piiHasher } from "@calcom/lib/server/PiiHasher"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import { CreationSource } from "@calcom/prisma/enums"; +import { getReservedSlotUidFromRequest } from "@calcom/trpc/server/routers/viewer/slots/reserveSlot.handler"; async function handler(req: NextApiRequest & { userId?: number }) { const userIp = getIP(req); @@ -21,10 +22,14 @@ async function handler(req: NextApiRequest & { userId?: number }) { req.body.creationSource = CreationSource.WEBAPP; const instantBookingService = getInstantBookingCreateService(); + const reservedSlotUid = getReservedSlotUidFromRequest(req); // Even though req.body is any type, createBooking validates the schema on run-time. // TODO: We should do the run-time schema validation here and pass a typed bookingData instead and then run-time schema could be removed from createBooking. Then we can remove the any type from req.body. const booking = await instantBookingService.createBooking({ bookingData: req.body, + bookingMeta: { + reservedSlotUid, + }, }); return booking; 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/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 046c06d7cd6360..55074b6246f842 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -21516,7 +21516,7 @@ "type": "string", "description": "Action to perform", "example": "email_host", - "enum": ["email_attendee", "email_address"] + "enum": ["email_attendee", "email_address", "sms_attendee", "sms_number"] } }, "required": ["id", "stepNumber", "recipient", "template", "sender", "message", "action"] @@ -22537,7 +22537,7 @@ }, "steps": { "type": "array", - "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address", + "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address,sms_attendee,sms_number", "items": { "oneOf": [ { @@ -22545,6 +22545,12 @@ }, { "$ref": "#/components/schemas/WorkflowEmailAttendeeStepDto" + }, + { + "$ref": "#/components/schemas/WorkflowPhoneAttendeeStepDto" + }, + { + "$ref": "#/components/schemas/WorkflowPhoneNumberStepDto" } ] } @@ -23136,7 +23142,7 @@ }, "steps": { "type": "array", - "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address", + "description": "Steps to execute as part of the routing-form workflow, allowed steps are email_attendee,email_address,sms_attendee,sms_number", "items": { "oneOf": [ { @@ -23144,6 +23150,12 @@ }, { "$ref": "#/components/schemas/UpdateEmailAttendeeWorkflowStepDto" + }, + { + "$ref": "#/components/schemas/UpdatePhoneAttendeeWorkflowStepDto" + }, + { + "$ref": "#/components/schemas/UpdatePhoneNumberWorkflowStepDto" } ] } @@ -25381,6 +25393,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 passes will prevent double bookings by checking that someone else has not reserved ", + "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" } }, "required": ["start", "attendee"] @@ -25505,6 +25522,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 passes will prevent double bookings by checking that someone else has 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.", @@ -25633,6 +25655,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 passes will prevent double bookings by checking that someone else has 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/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 386b71e99305a5..b1504b741c142b 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -37,6 +37,7 @@ export type CreateBookingMeta = { hostname?: string; forcedSlug?: string; noEmail?: boolean; + reservedSlotUid?: string; } & PlatformParams; export type BookingHandlerInput = { diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index b6a5260ccd4e68..aea8fe574017d7 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -5,6 +5,7 @@ import { v5 as uuidv5 } from "uuid"; import dayjs from "@calcom/dayjs"; import type { CreateInstantBookingData, + CreateBookingMeta, InstantBookingCreateResult, } from "@calcom/features/bookings/lib/dto/types"; import getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema"; @@ -163,8 +164,10 @@ const triggerBrowserNotifications = async (args: { export async function handler( bookingData: CreateInstantBookingData, - deps: IInstantBookingCreateServiceDependencies + deps: IInstantBookingCreateServiceDependencies, + bookingMeta?: CreateBookingMeta ) { + console.log("asap bookingMeta - will remove by Lauris", bookingMeta); // TODO: In a followup PR, we aim to remove prisma dependency and instead inject the repositories as dependencies. const { prismaClient: prisma } = deps; let eventType = await getEventTypesFromDB(bookingData.eventTypeId); @@ -351,7 +354,10 @@ export async function handler( export class InstantBookingCreateService implements IBookingCreateService { constructor(private readonly deps: IInstantBookingCreateServiceDependencies) {} - async createBooking(input: { bookingData: CreateInstantBookingData }): Promise { - return handler(input.bookingData, this.deps); + async createBooking(input: { + bookingData: CreateInstantBookingData; + bookingMeta?: CreateBookingMeta; + }): Promise { + return handler(input.bookingData, this.deps, input.bookingMeta); } } 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 205520cb3af9ae..dea9014b7cb145 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 @@ -380,6 +380,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.", + example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb", + }) + @IsOptional() + @IsString() + reservedSlotUid?: string; } export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { From 7e8818f79159eaca824c75196ca2c7d484f9a9a3 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 27 Oct 2025 16:35:34 +0100 Subject: [PATCH 04/18] chore: pass reservedSlotUid from frontend to handler in request.body --- .../bookings/lib/bookingCreateBodySchema.ts | 1 + .../booking-to-mutation-input-mapper.tsx | 3 +++ .../atoms/hooks/bookings/useHandleBookEvent.ts | 13 +++++++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/features/bookings/lib/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index 086354f57b8ef5..3743dbc6846014 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -53,6 +53,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 42d3f44e9d6034..e71607a1287b1f 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); @@ -101,6 +103,7 @@ export const mapBookingToMutationInput = ({ _shouldServeCache, dub_id, verificationCode, + reservedSlotUid, }; }; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 8260e05cb86cfc..c6f36f31db9b70 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -4,17 +4,19 @@ import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime"; import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; +import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@calcom/features/bookings/lib"; import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ApiErrorResponse } from "@calcom/platform-types"; import type { RoutingFormSearchParams } from "@calcom/platform-types"; import { showToast } from "@calcom/ui/components/toast"; import { getUtmTrackingParameters } from "../../lib/getUtmTrackingParameters"; import type { UseCreateBookingInput } from "./useCreateBooking"; -type Callbacks = { onSuccess?: () => void; onError?: (err: any) => void }; +type Callbacks = { onSuccess?: () => void; onError?: (err: ApiErrorResponse | Error) => void }; type UseHandleBookingProps = { bookingForm: UseBookingFormReturnType["bookingForm"]; event?: { @@ -64,9 +66,11 @@ export const useHandleBookEvent = ({ const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId); const verificationCode = useBookerStoreContext((state) => state.verificationCode); - const handleError = (err: any) => { - const errorMessage = err?.message ? t(err.message) : t("can_you_try_again"); - showToast(errorMessage, "error"); + const [slotReservationId] = useSlotReservationId(); + + const handleError = (err: ApiErrorResponse | Error) => { + const errorMessage = err instanceof Error ? err.message : err.error?.message; + showToast(errorMessage ? t(errorMessage) : t("can_you_try_again"), "error"); }; const searchParams = useSearchParams(); @@ -115,6 +119,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, isDryRunProp: isBookingDryRun, verificationCode: verificationCode || undefined, + reservedSlotUid: slotReservationId || undefined, }; const tracking = getUtmTrackingParameters(searchParams); From 7aa2ada13dd4ff8a35aedcf6036e77f7b5025783 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 27 Oct 2025 17:01:30 +0100 Subject: [PATCH 05/18] refactor: replace useSlotReservationId with booker context property --- .../Booker/components/hooks/useSlots.ts | 27 ++++++++++--------- packages/features/bookings/Booker/store.ts | 9 +++++++ .../bookings/Booker/useSlotReservationId.ts | 15 ----------- .../hooks/bookings/useHandleBookEvent.ts | 5 ++-- packages/platform/atoms/hooks/useSlots.ts | 15 ++++++----- 5 files changed, 33 insertions(+), 38 deletions(-) delete mode 100644 packages/features/bookings/Booker/useSlotReservationId.ts 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 5f63454b4e1dee..bec5dbae0e1ffa 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -153,7 +153,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 @@ -178,6 +180,8 @@ export type BookerStore = { crmRecordId?: string | null; isPlatform?: boolean; allowUpdatingUrlParams?: boolean; + reservedSlotUid: string | null; + setReservedSlotUid: (reservedSlotUid: string | null) => void; }; /** @@ -438,6 +442,7 @@ export const createBookerStore = () => } }, formValues: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any setFormValues: (formValues: Record) => { set({ formValues }); }, @@ -445,6 +450,10 @@ export const createBookerStore = () => setOrg: (org: string | null | undefined) => { set({ org }); }, + reservedSlotUid: null, + setReservedSlotUid: (reservedSlotUid: string | null) => { + set({ reservedSlotUid }); + }, isPlatform: false, allowUpdatingUrlParams: true, })); 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/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index c6f36f31db9b70..a992a370db2a84 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -4,7 +4,6 @@ import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime"; import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; -import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@calcom/features/bookings/lib"; import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema"; import type { BookerEvent } from "@calcom/features/bookings/types"; @@ -66,7 +65,7 @@ export const useHandleBookEvent = ({ const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId); const verificationCode = useBookerStoreContext((state) => state.verificationCode); - const [slotReservationId] = useSlotReservationId(); + const reservedSlotUid = useBookerStoreContext((state) => state.reservedSlotUid); const handleError = (err: ApiErrorResponse | Error) => { const errorMessage = err instanceof Error ? err.message : err.error?.message; @@ -119,7 +118,7 @@ export const useHandleBookEvent = ({ routingFormSearchParams, isDryRunProp: isBookingDryRun, verificationCode: verificationCode || undefined, - reservedSlotUid: slotReservationId || 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 From 47330d31345c61529c7faf36150388a00e3b37a7 Mon Sep 17 00:00:00 2001 From: supalarry Date: Tue, 28 Oct 2025 17:53:52 +0100 Subject: [PATCH 06/18] feat: handle booking with reservedSlotUid --- .../lib/handleNewBooking/createBooking.ts | 54 ++++++++------- .../createBookingWithReservedSlot.ts | 43 ++++++++++++ .../createInstantBookingWithReservedSlot.ts | 43 ++++++++++++ .../lib/reservations/validateReservedSlot.ts | 65 +++++++++++++++++++ .../service/InstantBookingCreateService.ts | 13 +++- .../lib/service/RecurringBookingService.ts | 2 + .../lib/service/RegularBookingService.ts | 17 ++++- 7 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts create mode 100644 packages/features/bookings/lib/reservations/validateReservedSlot.ts diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index fff6b0f342d266..d72e9aca2e3cc8 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -20,7 +20,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 +72,22 @@ 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 }, + options?: { tx?: Prisma.TransactionClient } +) => { updateEventDetails(evt, originalRescheduledBooking); const associatedBookingForFormResponse = routingFormResponseId ? await getAssociatedBookingForFormResponse(routingFormResponseId) @@ -109,7 +111,8 @@ const _createBooking = async ({ bookingAndAssociatedData, originalRescheduledBooking, eventType.paymentAppData, - eventType.organizerUser + eventType.organizerUser, + options?.tx ); function shouldConnectBookingToFormResponse() { @@ -136,7 +139,8 @@ async function saveBooking( bookingAndAssociatedData: ReturnType, originalRescheduledBooking: OriginalRescheduledBooking, paymentAppData: PaymentAppData, - organizerUser: CreateBookingParams["eventType"]["organizerUser"] + organizerUser: CreateBookingParams["eventType"]["organizerUser"], + tx?: Prisma.TransactionClient ) { const { newBookingData, reroutingFormResponseUpdateData, originalBookingUpdateDataForCancellation } = bookingAndAssociatedData; @@ -172,18 +176,24 @@ 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 client.booking.update(originalBookingUpdateDataForCancellation); } - const booking = await tx.booking.create(createBookingObj); + const booking = await client.booking.create(createBookingObj); if (reroutingFormResponseUpdateData) { - await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData); + await client.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData); } return booking; - }); + }; + + if (tx) { + return run(tx); + } + + return prisma.$transaction(run); } 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..08c658ddbf3da8 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts @@ -0,0 +1,43 @@ +import dayjs from "@calcom/dayjs"; +import prisma from "@calcom/prisma"; + +import { ensureReservedSlotIsEarliest } from "../reservations/validateReservedSlot"; +import { createBooking } from "./createBooking"; +import type { CreateBookingParams } from "./createBooking"; + +type ReservedSlot = { + eventTypeId: number; + slotUtcStart: string | Date; + slotUtcEnd: string | Date; + reservedSlotUid: string; +}; + +export async function createBookingWithReservedSlot( + args: CreateBookingParams & { rescheduledBy: string | undefined }, + reservedSlot: ReservedSlot +) { + return prisma.$transaction(async (tx) => { + await ensureReservedSlotIsEarliest(tx, reservedSlot); + const booking = await createBooking(args, { tx }); + + const slotUtcStartDate = + typeof reservedSlot.slotUtcStart === "string" + ? new Date(dayjs(reservedSlot.slotUtcStart).utc().format()) + : reservedSlot.slotUtcStart; + const slotUtcEndDate = + typeof reservedSlot.slotUtcEnd === "string" + ? new Date(dayjs(reservedSlot.slotUtcEnd).utc().format()) + : reservedSlot.slotUtcEnd; + + await tx.selectedSlots.deleteMany({ + where: { + eventTypeId: reservedSlot.eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid: reservedSlot.reservedSlotUid, + }, + }); + + return booking; + }); +} diff --git a/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts b/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts new file mode 100644 index 00000000000000..91ee869674d084 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts @@ -0,0 +1,43 @@ +import dayjs from "@calcom/dayjs"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +import { ensureReservedSlotIsEarliest } from "../reservations/validateReservedSlot"; + +export type ReservedSlotMeta = { + eventTypeId: number; + slotUtcStart: string | Date; + slotUtcEnd: string | Date; + reservedSlotUid: string; +}; + +export async function createInstantBookingWithReservedSlot( + createArgs: Prisma.BookingCreateArgs, + reservedSlot: ReservedSlotMeta +) { + return prisma.$transaction(async (tx) => { + await ensureReservedSlotIsEarliest(tx, reservedSlot); + + const booking = await tx.booking.create(createArgs); + + const slotUtcStartDate = + typeof reservedSlot.slotUtcStart === "string" + ? new Date(dayjs(reservedSlot.slotUtcStart).utc().format()) + : reservedSlot.slotUtcStart; + const slotUtcEndDate = + typeof reservedSlot.slotUtcEnd === "string" + ? new Date(dayjs(reservedSlot.slotUtcEnd).utc().format()) + : reservedSlot.slotUtcEnd; + + await tx.selectedSlots.deleteMany({ + where: { + eventTypeId: reservedSlot.eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid: reservedSlot.reservedSlotUid, + }, + }); + + return booking; + }); +} diff --git a/packages/features/bookings/lib/reservations/validateReservedSlot.ts b/packages/features/bookings/lib/reservations/validateReservedSlot.ts new file mode 100644 index 00000000000000..13715fe3d916b8 --- /dev/null +++ b/packages/features/bookings/lib/reservations/validateReservedSlot.ts @@ -0,0 +1,65 @@ +import dayjs from "@calcom/dayjs"; +import { HttpError } from "@calcom/lib/http-error"; +import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +export type ReservationConsumption = { + eventTypeId: number; + slotUtcStartDate: Date; + slotUtcEndDate: Date; + reservedSlotUid: string; +}; + +export async function ensureReservedSlotIsEarliest( + prisma: PrismaClient | Prisma.TransactionClient, + params: { + eventTypeId: number; + slotUtcStart: string | Date; + slotUtcEnd: string | Date; + reservedSlotUid: string; + } +): Promise { + const { eventTypeId, reservedSlotUid } = params; + const slotUtcStartDate = + typeof params.slotUtcStart === "string" + ? new Date(dayjs(params.slotUtcStart).utc().format()) + : params.slotUtcStart; + const slotUtcEndDate = + typeof params.slotUtcEnd === "string" + ? new Date(dayjs(params.slotUtcEnd).utc().format()) + : params.slotUtcEnd; + + const now = new Date(); + + const reservedSlot = await prisma.selectedSlots.findFirst({ + where: { + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid: reservedSlotUid, + releaseAt: { gt: now }, + }, + select: { id: true }, + }); + + if (!reservedSlot) { + throw new HttpError({ statusCode: 410, message: "reservation_not_found_or_expired" }); + } + + const earliestActive = await prisma.selectedSlots.findFirst({ + where: { + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + releaseAt: { gt: now }, + }, + orderBy: [{ releaseAt: "asc" }, { id: "asc" }], + select: { uid: true }, + }); + + if (!earliestActive || earliestActive.uid !== reservedSlotUid) { + throw new HttpError({ statusCode: 409, message: "another_reservation_is_ahead" }); + } + + return { eventTypeId, slotUtcStartDate, slotUtcEndDate, reservedSlotUid }; +} diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index aea8fe574017d7..d57926e7ee5711 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -28,6 +28,7 @@ import { Prisma } from "@calcom/prisma/client"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { instantMeetingSubscriptionSchema as subscriptionSchema } from "../dto/schema"; +import { createInstantBookingWithReservedSlot } from "../handleNewBooking/createInstantBookingWithReservedSlot"; interface IInstantBookingCreateServiceDependencies { prismaClient: PrismaClient; @@ -276,7 +277,17 @@ export async function handler( data: newBookingData, }; - const newBooking = await prisma.booking.create(createBookingObj); + const newBooking = await (async () => { + if (bookingMeta?.reservedSlotUid) { + return createInstantBookingWithReservedSlot(createBookingObj, { + eventTypeId: reqBody.eventTypeId, + slotUtcStart: reqBody.start, + slotUtcEnd: reqBody.end, + reservedSlotUid: bookingMeta.reservedSlotUid!, + }); + } + return prisma.booking.create(createBookingObj); + })(); // Create Instant Meeting Token diff --git a/packages/features/bookings/lib/service/RecurringBookingService.ts b/packages/features/bookings/lib/service/RecurringBookingService.ts index 3a1cf759ba2c57..709aecb9fe68a1 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.ts @@ -60,6 +60,7 @@ export const handleNewRecurringBooking = async ( hostname: input.hostname || "", forcedSlug: input.forcedSlug as string | undefined, ...handleBookingMeta, + reservedSlotUid: input.reservedSlotUid, }, }); luckyUsers = firstBookingResult.luckyUsers; @@ -103,6 +104,7 @@ export const handleNewRecurringBooking = async ( hostname: input.hostname || "", forcedSlug: input.forcedSlug as string | undefined, ...handleBookingMeta, + reservedSlotUid: undefined, }, }); diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ce9d4162845836..c24f76f21f9df7 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -102,7 +102,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"; @@ -1691,7 +1693,7 @@ async function handler( try { if (!isDryRun) { - booking = await createBooking({ + const createArgs: CreateBookingParams = { uid, rescheduledBy: reqBody.rescheduledBy, routingFormResponseId: routingFormResponseId, @@ -1719,7 +1721,18 @@ async function handler( originalRescheduledBooking, creationSource: input.bookingData.creationSource, tracking: reqBody.tracking, - }); + }; + + if (input.reservedSlotUid && !eventType.seatsPerTimeSlot) { + booking = await createBookingWithReservedSlot(createArgs, { + eventTypeId, + slotUtcStart: reqBody.start, + slotUtcEnd: reqBody.end, + reservedSlotUid: input.reservedSlotUid, + }); + } else { + booking = await createBooking(createArgs); + } if (booking?.userId) { const usersRepository = new UsersRepository(); From 71b148e28f219200377612c4b5b2b95f4dd51a6f Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 30 Oct 2025 15:47:12 +0100 Subject: [PATCH 07/18] docs: booking types this is enabled for --- .../2024-04-15/inputs/create-booking.input.ts | 11 +++++++++++ docs/api-reference/v2/openapi.json | 6 +++--- .../2024-08-13/inputs/create-booking.input.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) 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 da7cc4c641a72b..40198dce5548ac 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; @@ -231,4 +232,14 @@ export class CreateBookingInput_2024_04_15 { @IsOptional() @ApiPropertyOptional() crmOwnerRecordType?: string; + + @ApiPropertyOptional({ + type: String, + description: + "Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.", + example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb", + }) + @IsOptional() + @IsString() + reservedSlotUid?: string; } diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 0174574af90f20..1f34ac32953a3b 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -25454,7 +25454,7 @@ }, "reservedSlotUid": { "type": "string", - "description": "Reserved slot uid for the booking. If passes will prevent double bookings by checking that someone else has not reserved ", + "description": "Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.", "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" } }, @@ -25582,7 +25582,7 @@ }, "reservedSlotUid": { "type": "string", - "description": "Reserved slot uid for the booking. If passes will prevent double bookings by checking that someone else has not reserved ", + "description": "Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.", "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" }, "instant": { @@ -25715,7 +25715,7 @@ }, "reservedSlotUid": { "type": "string", - "description": "Reserved slot uid for the booking. If passes will prevent double bookings by checking that someone else has not reserved ", + "description": "Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.", "example": "430a2525-08e4-456d-a6b7-95ec2b0d22fb" }, "recurrenceCount": { 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 0dfc05625e8a46..0d438a0bc198c3 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 @@ -384,7 +384,7 @@ export class CreateBookingInput_2024_08_13 { @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.", + "Reserved slot uid for the booking. If passed will prevent double bookings by checking that someone else has not reserved the same slot. If there is another reserved slot for the same time we will check if it is not expired and which one was reserved first. If the other reserved slot is expired we will allow the booking to proceed. If there are no reserved slots for the same time we will allow the booking to proceed. Right now only enabled for non-team (round robin, collective) bookings aka 1 on 1 bookings, instant bookings and recurring bookings.", example: "430a2525-08e4-456d-a6b7-95ec2b0d22fb", }) @IsOptional() From ecc4804844e9de45f67cf4fa68ef0fa113206904 Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 30 Oct 2025 16:20:30 +0100 Subject: [PATCH 08/18] refactor: disable this for team event types --- .../features/bookings/lib/service/RegularBookingService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index c1c6ea75ee0762..fe01392eaf3188 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1724,7 +1724,8 @@ async function handler( tracking: reqBody.tracking, }; - if (input.reservedSlotUid && !eventType.seatsPerTimeSlot) { + const isTeamEvent = eventType.schedulingType; + if (input.reservedSlotUid && !eventType.seatsPerTimeSlot && !isTeamEvent) { booking = await createBookingWithReservedSlot(createArgs, { eventTypeId, slotUtcStart: reqBody.start, From 9ff6196ccaa2e6c1cd9b1c0e1acd8729f44e6c94 Mon Sep 17 00:00:00 2001 From: supalarry Date: Fri, 31 Oct 2025 10:42:56 +0100 Subject: [PATCH 09/18] test: booker web, embed and atom has reservedSlotUid in body --- apps/web/playwright/booking-pages.e2e.ts | 70 ++++++++++++++++--- .../playwright/tests/embed-pages.e2e.ts | 51 +++++++++++++- .../base/tests/booker-atom/booker-atom.e2e.ts | 42 +++++++++++ 3 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 213d47db96777f..68d2ce3ca0c720 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 = { @@ -124,7 +124,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 +255,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 +280,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 +297,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 +312,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 +335,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 +419,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 +434,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 +708,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 +799,46 @@ 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 }) => { + 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.click('[data-testid="event-type-link"]'); + + await selectFirstAvailableTimeSlotNextMonth(page); + + 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); + } else { + throw new Error("reservedSlotUid is not set"); + } + }); +}); 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/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"); + } +}); From fb7d90cafa3db56cb8077c5eb61bd30bbec426dc Mon Sep 17 00:00:00 2001 From: supalarry Date: Fri, 31 Oct 2025 13:14:22 +0100 Subject: [PATCH 10/18] test: api handling reservedSlotUid --- .../controllers/bookings.controller.ts | 9 + .../reserved-slot-bookings.e2e-spec.ts | 242 ++++++++++++++++++ .../e2e/reserved-slot-bookings.e2e-spec.ts | 232 +++++++++++++++++ .../2024-08-13/services/errors.service.ts | 7 + .../selected-slot.repository.fixture.ts | 6 +- .../lib/reservations/validateReservedSlot.ts | 13 +- 6 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 apps/api/v2/src/ee/bookings/2024-04-15/controllers/reserved-slot-bookings.e2e-spec.ts create mode 100644 apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reserved-slot-bookings.e2e-spec.ts diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 3d0999df00558b..99419b88fbfde5 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 @@ -634,6 +634,15 @@ export class BookingsController_2024_04_15 { if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) { throw new HttpException(error.message, 400); } + if (error.message === "reserved_slot_not_first_in_line") { + const errorData = + "data" in error ? (error.data as { secondsUntilRelease: number }) : { secondsUntilRelease: 300 }; + const message = `Someone else reserved this slot before you. This slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; + throw new HttpException(message, 400); + } + if (error.message === "reservation_not_found_or_expired") { + throw new HttpException("The reserved slot was not found or has expired.", 410); + } throw new InternalServerErrorException(error?.message ?? errMsg); } 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..247160f9344b96 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/reserved-slot-bookings.e2e-spec.ts @@ -0,0 +1,242 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { DateTime } from "luxon"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { SelectedSlotRepositoryFixture } from "test/fixtures/repository/selected-slot.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants"; +import { BookingResponse } from "@calcom/platform-libraries"; +import { RESERVED_SLOT_UID_COOKIE_NAME } from "@calcom/platform-libraries/slots"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { User, SelectedSlots } from "@calcom/prisma/client"; + +describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { + describe("reservedSlotUid functionality", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let selectedSlotRepositoryFixture: SelectedSlotRepositoryFixture; + + const userEmail = `reserved-slot-bookings-user-${randomString()}@api.com`; + let user: User; + let eventTypeId: number; + const eventTypeSlug = `reserved-slot-bookings-event-type-${randomString()}`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + 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", () => { + it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + const reservedSlotUid = `reserved-slot-${randomString()}`; + const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_04_15 = { + start: startTime.toISOString(), + eventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .set("Cookie", `${RESERVED_SLOT_UID_COOKIE_NAME}=${reservedSlotUid}`) + .expect(201); + + const responseBody: ApiSuccessResponse = 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid, + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(201); + + const responseBody: ApiSuccessResponse = 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + await createReservedSlot(user.id, eventTypeId, firstReservedSlotUid, startTime, endTime); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await createReservedSlot(user.id, eventTypeId, secondReservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid: secondReservedSlotUid, // Try to book with the second (not first in line) + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(400); + + expect(response.body.message).toContain("Someone else reserved this slot before you"); + expect(response.body.message).toContain("will be freed up in"); + expect(response.body.message).toContain("seconds"); + + // Verify both slots still exist + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + // Clean up + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); 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..24dbffcc1a950b --- /dev/null +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/reserved-slot-bookings.e2e-spec.ts @@ -0,0 +1,232 @@ +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 } 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-bookings-user-${randomString()}@api.com`; + let user: User; + let eventTypeId: number; + const eventTypeSlug = `reserved-slot-bookings-event-type-${randomString()}`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + selectedSlotRepositoryFixture = new SelectedSlotRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + 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", () => { + it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + const reservedSlotUid = `reserved-slot-${randomString()}`; + const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); + const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + + 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(400); + + expect(response.body.message).toContain("Someone else reserved this slot before you"); + expect(response.body.message).toContain("will be freed up in"); + expect(response.body.message).toContain("seconds"); + + // Verify both slots still exist + const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); + const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); + expect(firstSlotStillExists).toBeTruthy(); + expect(secondSlotStillExists).toBeTruthy(); + + // Clean up + await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); + await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email); + await app.close(); + }); + }); +}); 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..27b8a4bc9da171 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 @@ -61,6 +61,13 @@ 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 slot before you. This slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; + throw new BadRequestException(message); + } else if (error.message === "reservation_not_found_or_expired") { + throw new BadRequestException("The reserved slot was not found or has expired."); } } throw error; 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/packages/features/bookings/lib/reservations/validateReservedSlot.ts b/packages/features/bookings/lib/reservations/validateReservedSlot.ts index 13715fe3d916b8..36171f8e1c3f9e 100644 --- a/packages/features/bookings/lib/reservations/validateReservedSlot.ts +++ b/packages/features/bookings/lib/reservations/validateReservedSlot.ts @@ -1,4 +1,5 @@ import dayjs from "@calcom/dayjs"; +import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -54,11 +55,19 @@ export async function ensureReservedSlotIsEarliest( releaseAt: { gt: now }, }, orderBy: [{ releaseAt: "asc" }, { id: "asc" }], - select: { uid: true }, + select: { uid: true, releaseAt: true }, }); if (!earliestActive || earliestActive.uid !== reservedSlotUid) { - throw new HttpError({ statusCode: 409, message: "another_reservation_is_ahead" }); + const defaultSecondsUntilRelease = parseInt(MINUTES_TO_BOOK) * 60; + const secondsUntilRelease = earliestActive + ? Math.max(0, Math.ceil((earliestActive.releaseAt.getTime() - now.getTime()) / 1000)) + : defaultSecondsUntilRelease; + throw new HttpError({ + statusCode: 409, + message: "reserved_slot_not_first_in_line", + data: { secondsUntilRelease } + }); } return { eventTypeId, slotUtcStartDate, slotUtcEndDate, reservedSlotUid }; From 1fc31bc7f32664806109ed5791908f12a36d6c28 Mon Sep 17 00:00:00 2001 From: supalarry Date: Fri, 31 Oct 2025 15:26:45 +0100 Subject: [PATCH 11/18] fix: e2e test booking times --- .../controllers/reserved-slot-bookings.e2e-spec.ts | 12 ++++++------ .../e2e/reserved-slot-bookings.e2e-spec.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) 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 index 247160f9344b96..ac3e1eac316939 100644 --- 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 @@ -113,8 +113,8 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { describe("POST /v2/bookings", () => { it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { const reservedSlotUid = `reserved-slot-${randomString()}`; - const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + 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); @@ -150,8 +150,8 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { it("should create booking with reservedSlotUid from request body and remove selected slot", async () => { const reservedSlotUid = `reserved-slot-${randomString()}`; - const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + const startTime = new Date("2040-05-21T11:30:00.000Z"); + const endTime = new Date("2040-05-21T12:30:00.000Z"); await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); @@ -188,8 +188,8 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + 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); 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 index 24dbffcc1a950b..0a3c46985b3c0b 100644 --- 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 @@ -112,8 +112,8 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { describe("POST /v2/bookings", () => { it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { const reservedSlotUid = `reserved-slot-${randomString()}`; - const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + 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); @@ -146,8 +146,8 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { it("should create booking with reservedSlotUid from request body and remove selected slot", async () => { const reservedSlotUid = `reserved-slot-${randomString()}`; - const startTime = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + const startTime = new Date("2040-05-21T11:30:00.000Z"); + const endTime = new Date("2040-05-21T12:30:00.000Z"); await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); @@ -181,8 +181,8 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { 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 = DateTime.now().plus({ days: 1 }).startOf("hour").toJSDate(); - const endTime = DateTime.fromJSDate(startTime).plus({ hours: 1 }).toJSDate(); + 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); From 2ecdd4dde319cccb2bf481573002e401ce35b161 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 12:51:26 +0100 Subject: [PATCH 12/18] refactor: centralize date, allow slot if non-existing/no slots reserved --- .../createBookingWithReservedSlot.ts | 18 ++---- .../createInstantBookingWithReservedSlot.ts | 4 +- .../lib/reservations/validateReservedSlot.ts | 60 ++++--------------- .../service/InstantBookingCreateService.ts | 6 +- .../lib/service/RegularBookingService.ts | 10 ++-- 5 files changed, 28 insertions(+), 70 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts b/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts index 08c658ddbf3da8..587858510f8ecb 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBookingWithReservedSlot.ts @@ -1,4 +1,3 @@ -import dayjs from "@calcom/dayjs"; import prisma from "@calcom/prisma"; import { ensureReservedSlotIsEarliest } from "../reservations/validateReservedSlot"; @@ -7,8 +6,8 @@ import type { CreateBookingParams } from "./createBooking"; type ReservedSlot = { eventTypeId: number; - slotUtcStart: string | Date; - slotUtcEnd: string | Date; + slotUtcStart: Date; + slotUtcEnd: Date; reservedSlotUid: string; }; @@ -20,20 +19,11 @@ export async function createBookingWithReservedSlot( await ensureReservedSlotIsEarliest(tx, reservedSlot); const booking = await createBooking(args, { tx }); - const slotUtcStartDate = - typeof reservedSlot.slotUtcStart === "string" - ? new Date(dayjs(reservedSlot.slotUtcStart).utc().format()) - : reservedSlot.slotUtcStart; - const slotUtcEndDate = - typeof reservedSlot.slotUtcEnd === "string" - ? new Date(dayjs(reservedSlot.slotUtcEnd).utc().format()) - : reservedSlot.slotUtcEnd; - await tx.selectedSlots.deleteMany({ where: { eventTypeId: reservedSlot.eventTypeId, - slotUtcStartDate, - slotUtcEndDate, + slotUtcStartDate: reservedSlot.slotUtcStart, + slotUtcEndDate: reservedSlot.slotUtcEnd, uid: reservedSlot.reservedSlotUid, }, }); diff --git a/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts b/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts index 91ee869674d084..67da79fef1428d 100644 --- a/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts +++ b/packages/features/bookings/lib/handleNewBooking/createInstantBookingWithReservedSlot.ts @@ -6,8 +6,8 @@ import { ensureReservedSlotIsEarliest } from "../reservations/validateReservedSl export type ReservedSlotMeta = { eventTypeId: number; - slotUtcStart: string | Date; - slotUtcEnd: string | Date; + slotUtcStart: Date; + slotUtcEnd: Date; reservedSlotUid: string; }; diff --git a/packages/features/bookings/lib/reservations/validateReservedSlot.ts b/packages/features/bookings/lib/reservations/validateReservedSlot.ts index 36171f8e1c3f9e..625d692581cd23 100644 --- a/packages/features/bookings/lib/reservations/validateReservedSlot.ts +++ b/packages/features/bookings/lib/reservations/validateReservedSlot.ts @@ -1,74 +1,38 @@ import dayjs from "@calcom/dayjs"; -import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; -export type ReservationConsumption = { - eventTypeId: number; - slotUtcStartDate: Date; - slotUtcEndDate: Date; - reservedSlotUid: string; -}; - export async function ensureReservedSlotIsEarliest( prisma: PrismaClient | Prisma.TransactionClient, params: { eventTypeId: number; - slotUtcStart: string | Date; - slotUtcEnd: string | Date; + slotUtcStart: Date; + slotUtcEnd: Date; reservedSlotUid: string; } -): Promise { +) { const { eventTypeId, reservedSlotUid } = params; - const slotUtcStartDate = - typeof params.slotUtcStart === "string" - ? new Date(dayjs(params.slotUtcStart).utc().format()) - : params.slotUtcStart; - const slotUtcEndDate = - typeof params.slotUtcEnd === "string" - ? new Date(dayjs(params.slotUtcEnd).utc().format()) - : params.slotUtcEnd; - - const now = new Date(); - - const reservedSlot = await prisma.selectedSlots.findFirst({ - where: { - eventTypeId, - slotUtcStartDate, - slotUtcEndDate, - uid: reservedSlotUid, - releaseAt: { gt: now }, - }, - select: { id: true }, - }); - if (!reservedSlot) { - throw new HttpError({ statusCode: 410, message: "reservation_not_found_or_expired" }); - } + const now = dayjs.utc().toDate(); const earliestActive = await prisma.selectedSlots.findFirst({ where: { eventTypeId, - slotUtcStartDate, - slotUtcEndDate, + slotUtcStartDate: params.slotUtcStart, + slotUtcEndDate: params.slotUtcEnd, releaseAt: { gt: now }, }, orderBy: [{ releaseAt: "asc" }, { id: "asc" }], select: { uid: true, releaseAt: true }, }); - if (!earliestActive || earliestActive.uid !== reservedSlotUid) { - const defaultSecondsUntilRelease = parseInt(MINUTES_TO_BOOK) * 60; - const secondsUntilRelease = earliestActive - ? Math.max(0, Math.ceil((earliestActive.releaseAt.getTime() - now.getTime()) / 1000)) - : defaultSecondsUntilRelease; - throw new HttpError({ - statusCode: 409, + if (earliestActive && earliestActive.uid !== reservedSlotUid) { + const secondsUntilRelease = dayjs(earliestActive.releaseAt).diff(now, "second"); + throw new HttpError({ + statusCode: 409, message: "reserved_slot_not_first_in_line", - data: { secondsUntilRelease } - }); + data: { secondsUntilRelease }, + }); } - - return { eventTypeId, slotUtcStartDate, slotUtcEndDate, reservedSlotUid }; } diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index d57926e7ee5711..1b4a909cbfdcd2 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -196,6 +196,8 @@ export async function handler( const translator = short(); const seed = `${reqBody.email}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`; const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL)); + const bookingStartUtc = new Date(dayjs(reqBody.start).utc().format()); + const bookingEndUtc = new Date(dayjs(reqBody.end).utc().format()); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); const attendeeTimezone = reqBody.timeZone; @@ -281,8 +283,8 @@ export async function handler( if (bookingMeta?.reservedSlotUid) { return createInstantBookingWithReservedSlot(createBookingObj, { eventTypeId: reqBody.eventTypeId, - slotUtcStart: reqBody.start, - slotUtcEnd: reqBody.end, + slotUtcStart: bookingStartUtc, + slotUtcEnd: bookingEndUtc, reservedSlotUid: bookingMeta.reservedSlotUid!, }); } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ae2226d3539eba..f0b5a876ab7f48 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -530,6 +530,8 @@ async function handler( eventType, }); + const bookingStartUtc = new Date(dayjs(reqBody.start).utc().format()); + const bookingEndUtc = new Date(dayjs(reqBody.end).utc().format()); const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails }); @@ -634,7 +636,7 @@ async function handler( eventTypeId, bookerEmail, bookerPhoneNumber, - startTime: new Date(dayjs(reqBody.start).utc().format()), + startTime: bookingStartUtc, filterForUnconfirmed: !isConfirmedByDefault, }); @@ -794,7 +796,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: { @@ -1715,8 +1717,8 @@ async function handler( if (input.reservedSlotUid && !eventType.seatsPerTimeSlot && !isTeamEvent) { booking = await createBookingWithReservedSlot(createArgs, { eventTypeId, - slotUtcStart: reqBody.start, - slotUtcEnd: reqBody.end, + slotUtcStart: bookingStartUtc, + slotUtcEnd: bookingEndUtc, reservedSlotUid: input.reservedSlotUid, }); } else { From 8b4634502194593fe3e7d75605ee359b94084e63 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 12:54:03 +0100 Subject: [PATCH 13/18] fix: bookings controller passing reservedSlotUid --- .../v2/src/ee/bookings/2024-08-13/services/input.service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 e1a74c64fdf7e9..a4e3c7787c2d43 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 @@ -120,20 +120,18 @@ 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, - reservedSlotUid, }; } else { - Object.assign(newRequest, { userId }); + Object.assign(newRequest, { userId, reservedSlotUid }); newRequest.body = { ...bodyTransformed, noEmail: false, creationSource: CreationSource.API_V2, - reservedSlotUid, }; } From 30732ba22600afde204a2193be497bfb85396f72 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 12:54:44 +0100 Subject: [PATCH 14/18] refactor: error messages --- .../2024-04-15/controllers/bookings.controller.ts | 8 ++------ .../controllers/reserved-slot-bookings.e2e-spec.ts | 9 ++++++--- .../controllers/e2e/reserved-slot-bookings.e2e-spec.ts | 9 ++++++--- .../ee/bookings/2024-08-13/services/errors.service.ts | 4 +--- apps/web/public/static/locales/en/common.json | 1 + .../Booker/components/BookEventForm/BookEventForm.tsx | 7 ++++++- 6 files changed, 22 insertions(+), 16 deletions(-) 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 99419b88fbfde5..a441ce6f4e92b1 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 @@ -481,8 +481,7 @@ export class BookingsController_2024_04_15 { const isEventTypeOwner = eventType.userId === userId; const isHost = eventType.hosts.some((host) => host.userId === userId); - const isTeamAdminOrOwner = - eventType.team?.members.some((member) => member.userId === userId) ?? false; + const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false; let isOrgAdminOrOwner = false; if (eventType.team?.parentId) { @@ -637,12 +636,9 @@ export class BookingsController_2024_04_15 { if (error.message === "reserved_slot_not_first_in_line") { const errorData = "data" in error ? (error.data as { secondsUntilRelease: number }) : { secondsUntilRelease: 300 }; - const message = `Someone else reserved this slot before you. This slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; + const message = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; throw new HttpException(message, 400); } - if (error.message === "reservation_not_found_or_expired") { - throw new HttpException("The reserved slot was not found or has expired.", 410); - } throw new InternalServerErrorException(error?.message ?? errMsg); } 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 index ac3e1eac316939..a6ca53c78ee4fd 100644 --- 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 @@ -217,9 +217,12 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) .expect(400); - expect(response.body.message).toContain("Someone else reserved this slot before you"); - expect(response.body.message).toContain("will be freed up in"); - expect(response.body.message).toContain("seconds"); + const message: string = response.body.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); // Verify both slots still exist const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); 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 index 0a3c46985b3c0b..394f2e16cdfddc 100644 --- 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 @@ -207,9 +207,12 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(400); - expect(response.body.message).toContain("Someone else reserved this slot before you"); - expect(response.body.message).toContain("will be freed up in"); - expect(response.body.message).toContain("seconds"); + const message: string = response.body.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); // Verify both slots still exist const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); 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 27b8a4bc9da171..d352c032e87914 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 @@ -64,10 +64,8 @@ export class ErrorsBookingsService_2024_08_13 { } 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 slot before you. This slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; + const message = `Someone else reserved this booking time slot before you. This time slot will be freed up in ${errorData.secondsUntilRelease} seconds.`; throw new BadRequestException(message); - } else if (error.message === "reservation_not_found_or_expired") { - throw new BadRequestException("The reserved slot was not found or has expired."); } } throw error; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fc9b3fd00182f5..e117dd6ed7111d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3776,6 +3776,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}}s 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/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")} From 19fbb295fc238a0d29585ed718b9f328eb73fd69 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 13:11:58 +0100 Subject: [PATCH 15/18] refactor: web booker error message --- apps/web/public/static/locales/en/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e117dd6ed7111d..0cc80b84b8e89e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3776,7 +3776,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}}s or pick a different time.", + "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.", From 83c1531f2fa85d1d98bee712fcee0a2ec137b05a Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 13:34:21 +0100 Subject: [PATCH 16/18] test: recurring events --- .../reserved-slot-bookings.e2e-spec.ts | 154 +++++++++++++++++- .../e2e/reserved-slot-bookings.e2e-spec.ts | 147 ++++++++++++++++- 2 files changed, 294 insertions(+), 7 deletions(-) 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 index a6ca53c78ee4fd..968754f4d3aeac 100644 --- 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 @@ -111,7 +111,8 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { } describe("POST /v2/bookings", () => { - it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + 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"); @@ -224,16 +225,163 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { 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); - // Verify both slots still exist const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); expect(firstSlotStillExists).toBeTruthy(); expect(secondSlotStillExists).toBeTruthy(); - // Clean up await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); }); + }); + + describe("recurring event type", () => { + let recurringEventTypeId: number; + const recurringEventTypeSlug = `recurring-reserved-slot-bookings-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 startTime = new Date("2040-05-21T09:30:00.000Z"); + const endTime = new Date("2040-05-21T10:30:00.000Z"); + + await createReservedSlot(user.id, recurringEventTypeId, reservedSlotUid, startTime, endTime); + + const bookingData: CreateBookingInput_2024_04_15 = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + 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(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 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_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + 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(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: CreateBookingInput_2024_04_15 & { reservedSlotUid: string } = { + start: startTime.toISOString(), + eventTypeId: recurringEventTypeId, + timeZone: "Europe/Rome", + language: "en", + metadata: {}, + hashedLink: null, + responses: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + }, + reservedSlotUid: secondReservedSlotUid, // Try to book with the second (not first in line) + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(bookingData) + .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) + .expect(400); + + const message: string = response.body.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 () => { 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 index 394f2e16cdfddc..741bc0508555ae 100644 --- 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 @@ -20,7 +20,7 @@ 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 } from "@calcom/platform-types"; +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", () => { @@ -110,7 +110,8 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { } describe("POST /v2/bookings", () => { - it("should create booking with reservedSlotUid from cookie and remove selected slot", async () => { + 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"); @@ -214,16 +215,154 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { 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); - // Verify both slots still exist const firstSlotStillExists = await selectedSlotRepositoryFixture.getByUid(firstReservedSlotUid); const secondSlotStillExists = await selectedSlotRepositoryFixture.getByUid(secondReservedSlotUid); expect(firstSlotStillExists).toBeTruthy(); expect(secondSlotStillExists).toBeTruthy(); - // Clean up await selectedSlotRepositoryFixture.deleteByUId(firstReservedSlotUid); await selectedSlotRepositoryFixture.deleteByUId(secondReservedSlotUid); }); + }); + + describe("recurring event type", () => { + let recurringEventTypeId: number; + const recurringEventTypeSlug = `recurring-reserved-slot-bookings-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-21T09:30:00.000Z"); + const endTime = new Date("2040-05-21T10: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(400); + + const message: string = response.body.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 () => { From f4c211cea83231d4c028048e649c67ccf3fb3133 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 15:29:41 +0100 Subject: [PATCH 17/18] fix: tests --- .../reserved-slot-bookings.e2e-spec.ts | 4 +- .../e2e/reserved-slot-bookings.e2e-spec.ts | 216 +++++++++--------- 2 files changed, 112 insertions(+), 108 deletions(-) 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 index 968754f4d3aeac..deb96ab651eaa0 100644 --- 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 @@ -218,7 +218,7 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) .expect(400); - const message: string = response.body.message; + const message: string = response.body.error.message; const match = message.match(/(\d+) seconds\.$/); expect(match).not.toBeNull(); const secondsFromMessage = parseInt(match![1], 10); @@ -366,7 +366,7 @@ describe("Reserved Slot Bookings Endpoints 2024-04-15", () => { .set(CAL_API_VERSION_HEADER, VERSION_2024_04_15) .expect(400); - const message: string = response.body.message; + const message: string = response.body.error.message; const match = message.match(/(\d+) seconds\.$/); expect(match).not.toBeNull(); const secondsFromMessage = parseInt(match![1], 10); 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 index 741bc0508555ae..ed85c8d47dd293 100644 --- 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 @@ -20,7 +20,11 @@ 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 { + 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", () => { @@ -112,117 +116,117 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { 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(); - }); + 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); - 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"); + const bookingData: CreateBookingInput_2024_08_13 = { + start: startTime.toISOString(), + eventTypeId, + attendee: { + name: "Test Attendee", + email: `reserved-slot-test-${randomString()}@example.com`, + timeZone: "Europe/Rome", + }, + }; - await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); + 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 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(); - }); + 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(); - 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"); + // Verify the selected slot was removed + const remainingSlot = await selectedSlotRepositoryFixture.getByUid(reservedSlotUid); + expect(remainingSlot).toBeNull(); + }); - await createReservedSlot(user.id, eventTypeId, firstReservedSlotUid, startTime, endTime); + 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 new Promise((resolve) => setTimeout(resolve, 100)); + await createReservedSlot(user.id, eventTypeId, reservedSlotUid, startTime, endTime); - 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, + }; - 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(400); - - const message: string = response.body.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); - }); + 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(400); + + 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); + }); }); describe("recurring event type", () => { @@ -347,8 +351,8 @@ describe("Reserved Slot Bookings Endpoints 2024-08-13", () => { .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) .expect(400); - const message: string = response.body.message; - const match = message.match(/(\d+) seconds\.$/); + 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.`; From 6d19c363f960c79e9aa13ed74943ffba2c5a6365 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 3 Nov 2025 17:27:20 +0100 Subject: [PATCH 18/18] fix: merging main --- packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index 897a1d7ab3ea51..7f1c9c25a28fad 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -8,7 +8,6 @@ import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@ import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { ApiErrorResponse } from "@calcom/platform-types"; import type { RoutingFormSearchParams } from "@calcom/platform-types"; import { showToast } from "@calcom/ui/components/toast"; @@ -65,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");