diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.integration-test.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.integration-test.ts new file mode 100644 index 0000000000..4dd2b7f65f --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.integration-test.ts @@ -0,0 +1,501 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { ErrorCode } from "@calcom/lib/errorCodes"; + +import { addSeatToBooking } from "./createNewSeat"; + +// Track resources to clean up +const createdBookingIds: number[] = []; +let testUserId: number; +let testEventTypeId: number; + +async function clearTestData() { + if (createdBookingIds.length > 0) { + await prisma.bookingSeat.deleteMany({ + where: { bookingId: { in: createdBookingIds } }, + }); + await prisma.attendee.deleteMany({ + where: { bookingId: { in: createdBookingIds } }, + }); + await prisma.booking.deleteMany({ + where: { id: { in: createdBookingIds } }, + }); + createdBookingIds.length = 0; + } +} + +describe("createNewSeat Race Condition Prevention (Integration)", () => { + beforeAll(async () => { + // Use existing seed user + const testUser = await prisma.user.findFirstOrThrow({ + where: { email: "member0-acme@example.com" }, + }); + testUserId = testUser.id; + + // Find or create a test event type with seats + let eventType = await prisma.eventType.findFirst({ + where: { + userId: testUserId, + seatsPerTimeSlot: { not: null }, + }, + }); + + if (!eventType) { + eventType = await prisma.eventType.create({ + data: { + title: "Seated Test Event", + slug: `seated-test-event-${Date.now()}`, + length: 30, + userId: testUserId, + seatsPerTimeSlot: 2, + seatsShowAttendees: false, + }, + }); + } + testEventTypeId = eventType.id; + }); + + afterEach(async () => { + await clearTestData(); + }); + + it("should prevent overbooking when concurrent requests race for the last seat", async () => { + const bookingUid = `race-test-${Date.now()}`; + const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(startTime.getTime() + 30 * 60 * 1000); + const seatsPerTimeSlot = 2; + + // Create a booking first + const booking = await prisma.booking.create({ + data: { + uid: bookingUid, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + title: "Race Condition Test Booking", + }, + }); + + createdBookingIds.push(booking.id); + + // Create 1 existing seat (out of 2) + const attendee = await prisma.attendee.create({ + data: { + email: "existing-seat@test.com", + name: "Existing Seat", + timeZone: "UTC", + locale: "en", + bookingId: booking.id, + }, + }); + + await prisma.bookingSeat.create({ + data: { + referenceUid: `existing-seat-${Date.now()}`, + data: {}, + bookingId: booking.id, + attendeeId: attendee.id, + }, + }); + + // Fire 2 concurrent requests for the last seat using the actual addSeatToBooking function + const results = await Promise.allSettled([ + addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "racer1@test.com", + name: "Racer 1", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }), + addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "racer2@test.com", + name: "Racer 2", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }), + ]); + + // Count successes and failures + const successes = results.filter((r) => r.status === "fulfilled"); + const failures = results.filter( + (r) => r.status === "rejected" && (r.reason as Error).message === ErrorCode.BookingSeatsFull + ); + + // Exactly 1 should succeed, 1 should fail with BookingSeatsFull + expect(successes.length).toBe(1); + expect(failures.length).toBe(1); + + // Verify final seat count is exactly 2 (1 existing + 1 new) + const finalBooking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + attendees: { + select: { + id: true, + bookingSeat: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length; + expect(finalSeatCount).toBe(2); + }); + + it("should allow both bookings when enough seats are available", async () => { + const bookingUid = `multi-seat-test-${Date.now()}`; + const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(startTime.getTime() + 30 * 60 * 1000); + const seatsPerTimeSlot = 3; // 3 seats available + + // Create a booking with no seats taken yet + const booking = await prisma.booking.create({ + data: { + uid: bookingUid, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + title: "Multi Seat Test Booking", + }, + }); + + createdBookingIds.push(booking.id); + + // Fire 2 concurrent requests when there are 3 seats available + const results = await Promise.allSettled([ + addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "booker1@test.com", + name: "Booker 1", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }), + addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "booker2@test.com", + name: "Booker 2", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }), + ]); + + // Both should succeed + const successes = results.filter((r) => r.status === "fulfilled"); + expect(successes.length).toBe(2); + + // Verify final seat count is 2 + const finalBooking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + attendees: { + select: { + id: true, + bookingSeat: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length; + expect(finalSeatCount).toBe(2); + }); + + it("should reject booking when event is already full", async () => { + const bookingUid = `full-event-test-${Date.now()}`; + const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(startTime.getTime() + 30 * 60 * 1000); + const seatsPerTimeSlot = 2; + + // Create a booking first + const booking = await prisma.booking.create({ + data: { + uid: bookingUid, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + title: "Full Event Test Booking", + }, + }); + + createdBookingIds.push(booking.id); + + // Create 2 seats (filling all available seats) + const attendee1 = await prisma.attendee.create({ + data: { + email: "seat1@test.com", + name: "Seat 1", + timeZone: "UTC", + locale: "en", + bookingId: booking.id, + }, + }); + + await prisma.bookingSeat.create({ + data: { + referenceUid: `seat1-${Date.now()}`, + data: {}, + bookingId: booking.id, + attendeeId: attendee1.id, + }, + }); + + const attendee2 = await prisma.attendee.create({ + data: { + email: "seat2@test.com", + name: "Seat 2", + timeZone: "UTC", + locale: "en", + bookingId: booking.id, + }, + }); + + await prisma.bookingSeat.create({ + data: { + referenceUid: `seat2-${Date.now()}`, + data: {}, + bookingId: booking.id, + attendeeId: attendee2.id, + }, + }); + + // Try to add another seat - should fail + await expect( + addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "latecomer@test.com", + name: "Late Comer", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }) + ).rejects.toThrow(ErrorCode.BookingSeatsFull); + + // Verify seat count is still 2 + const finalBooking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + attendees: { + select: { + id: true, + bookingSeat: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length; + expect(finalSeatCount).toBe(2); + }); + + it("should allow booking when seatsPerTimeSlot is 0 (falsy)", async () => { + // This tests the fix for the bug where null/undefined seatsPerTimeSlot + // would fallback to 0 and incorrectly reject all bookings because + // 0 <= currentSeatCount was always true + const bookingUid = `zero-seats-test-${Date.now()}`; + const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(startTime.getTime() + 30 * 60 * 1000); + const seatsPerTimeSlot = 0; // Simulates null/undefined fallback + + // Create a booking + const booking = await prisma.booking.create({ + data: { + uid: bookingUid, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + title: "Zero Seats Test Booking", + }, + }); + + createdBookingIds.push(booking.id); + + // Add a seat with seatsPerTimeSlot = 0 - should succeed + const result = await addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "booker@test.com", + name: "Booker", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }); + + expect(result).toBeDefined(); + expect(result?.referenceUid).toBeDefined(); + + // Verify the seat was created + const finalBooking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + attendees: { + select: { + id: true, + bookingSeat: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length; + expect(finalSeatCount).toBe(1); + }); + + it("should allow multiple bookings when seatsPerTimeSlot is 0 (no limit)", async () => { + const bookingUid = `zero-seats-multi-test-${Date.now()}`; + const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(startTime.getTime() + 30 * 60 * 1000); + const seatsPerTimeSlot = 0; // No limit enforced + + // Create a booking + const booking = await prisma.booking.create({ + data: { + uid: bookingUid, + userId: testUserId, + eventTypeId: testEventTypeId, + status: BookingStatus.ACCEPTED, + startTime, + endTime, + title: "Zero Seats Multi Test Booking", + }, + }); + + createdBookingIds.push(booking.id); + + // Create existing seats + const attendee1 = await prisma.attendee.create({ + data: { + email: "existing1@test.com", + name: "Existing 1", + timeZone: "UTC", + locale: "en", + bookingId: booking.id, + }, + }); + + await prisma.bookingSeat.create({ + data: { + referenceUid: `existing1-${Date.now()}`, + data: {}, + bookingId: booking.id, + attendeeId: attendee1.id, + }, + }); + + const attendee2 = await prisma.attendee.create({ + data: { + email: "existing2@test.com", + name: "Existing 2", + timeZone: "UTC", + locale: "en", + bookingId: booking.id, + }, + }); + + await prisma.bookingSeat.create({ + data: { + referenceUid: `existing2-${Date.now()}`, + data: {}, + bookingId: booking.id, + attendeeId: attendee2.id, + }, + }); + + // Add another seat with seatsPerTimeSlot = 0 - should succeed even with existing seats + const result = await addSeatToBooking({ + bookingUid, + bookingId: booking.id, + bookingStatus: BookingStatus.ACCEPTED, + seatsPerTimeSlot, + attendee: { + email: "new-booker@test.com", + name: "New Booker", + timeZone: "UTC", + locale: "en", + }, + seatData: {}, + }); + + expect(result).toBeDefined(); + expect(result?.referenceUid).toBeDefined(); + + // Verify total seat count is 3 (2 existing + 1 new) + const finalBooking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + attendees: { + select: { + id: true, + bookingSeat: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length; + expect(finalSeatCount).toBe(3); + }); +}); diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index ea6d724925..638469a7c5 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -1,4 +1,3 @@ - import { cloneDeep } from "lodash"; import { uuid } from "short-uuid"; @@ -14,12 +13,120 @@ import EventManager from "@calcom/features/bookings/lib/EventManager"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; +import type { Prisma, PrismaClient } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; import { findBookingQuery } from "../../handleNewBooking/findBookingQuery"; import type { IEventTypePaymentCredentialType } from "../../handleNewBooking/types"; -import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types"; +import type { + SeatedBooking, + NewSeatedBookingObject, + HandleSeatsResultBooking, +} from "../types"; + +export type AddSeatInput = { + bookingUid: string; + bookingId: number; + bookingStatus: BookingStatus; + seatsPerTimeSlot: number; + attendee: { + email: string; + phoneNumber?: string; + name: string; + timeZone: string; + locale: string; + }; + seatData: { + description?: string; + responses?: Prisma.InputJsonValue | null; + }; + metadata?: Record; +}; + +/** + * Atomically adds a seat to a booking with race condition protection. + * Uses a transaction with a fresh read to prevent TOCTOU race conditions + * where concurrent requests could exceed the seat limit. + */ +export async function addSeatToBooking( + input: AddSeatInput, + prismaClient: PrismaClient = prisma +) { + const referenceUid = uuid(); + + return prismaClient.$transaction(async (tx) => { + // Lock the booking row with FOR UPDATE to prevent concurrent modifications + // This ensures only one transaction can read and modify seat count at a time + await tx.$queryRaw`SELECT id FROM "Booking" WHERE uid = ${input.bookingUid} FOR UPDATE`; + + // Fresh read inside transaction to get the current seat count + const freshBooking = await tx.booking.findUnique({ + where: { uid: input.bookingUid }, + select: { + attendees: { + select: { + bookingSeat: { + select: { id: true }, + }, + }, + }, + }, + }); + + if (!freshBooking) { + throw new HttpError({ statusCode: 404, message: "Booking not found" }); + } + + // Check seat availability with fresh data + // Only enforce the limit when seatsPerTimeSlot > 0 (matching original behavior + // where falsy seatsPerTimeSlot would skip this check entirely) + const currentSeatCount = freshBooking.attendees.filter( + (attendee) => !!attendee.bookingSeat + ).length; + if (input.seatsPerTimeSlot > 0 && input.seatsPerTimeSlot <= currentSeatCount) { + throw new HttpError({ + statusCode: 409, + message: ErrorCode.BookingSeatsFull, + }); + } + + // Create the attendee and seat atomically within the transaction + await tx.booking.update({ + where: { uid: input.bookingUid }, + data: { + attendees: { + create: { + email: input.attendee.email, + phoneNumber: input.attendee.phoneNumber, + name: input.attendee.name, + timeZone: input.attendee.timeZone, + locale: input.attendee.locale, + bookingSeat: { + create: { + referenceUid, + data: { + description: input.seatData.description, + responses: input.seatData.responses, + }, + metadata: input.metadata, + booking: { + connect: { id: input.bookingId }, + }, + }, + }, + }, + }, + ...(input.bookingStatus === BookingStatus.CANCELLED && { + status: BookingStatus.ACCEPTED, + }), + }, + }); + + return await tx.bookingSeat.findUnique({ + where: { referenceUid }, + }); + }); +} const createNewSeat = async ( rescheduleSeatedBookingObject: NewSeatedBookingObject, @@ -46,17 +153,15 @@ const createNewSeat = async ( let resultBooking: HandleSeatsResultBooking; // Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language const bookingAttendees = seatedBooking.attendees.map((attendee) => { - return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } }; + return { + ...attendee, + language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, + }; }); - if ( - eventType.seatsPerTimeSlot && - eventType.seatsPerTimeSlot <= seatedBooking.attendees.filter((attendee) => !!attendee.bookingSeat).length - ) { - throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull }); - } - - const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video")); + const videoCallReference = seatedBooking.references.find((reference) => + reference.type.includes("_video") + ); if (videoCallReference) { evt.videoCallData = { @@ -67,50 +172,33 @@ const createNewSeat = async ( }; } - const attendeeUniqueId = uuid(); - const inviteeToAdd = invitee[0]; - await prisma.booking.update({ - where: { - uid: seatedBooking.uid, + // Use addSeatToBooking which handles the race condition protection via transaction + const newBookingSeat = await addSeatToBooking({ + bookingUid: seatedBooking.uid, + bookingId: seatedBooking.id, + bookingStatus: seatedBooking.status, + seatsPerTimeSlot: eventType.seatsPerTimeSlot ?? 0, + attendee: { + email: inviteeToAdd.email, + phoneNumber: inviteeToAdd.phoneNumber, + name: inviteeToAdd.name, + timeZone: inviteeToAdd.timeZone, + locale: inviteeToAdd.language.locale, }, - data: { - attendees: { - create: { - email: inviteeToAdd.email, - phoneNumber: inviteeToAdd.phoneNumber, - name: inviteeToAdd.name, - timeZone: inviteeToAdd.timeZone, - locale: inviteeToAdd.language.locale, - bookingSeat: { - create: { - referenceUid: attendeeUniqueId, - data: { - description: additionalNotes, - responses, - }, - metadata, - booking: { - connect: { - id: seatedBooking.id, - }, - }, - }, - }, - }, - }, - ...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), + seatData: { + description: additionalNotes, + responses, }, + metadata, }); - const newBookingSeat = await prisma.bookingSeat.findUnique({ - where: { - referenceUid: attendeeUniqueId, - }, - }); - - const attendeeWithSeat = { ...inviteeToAdd, bookingSeat: newBookingSeat ?? null }; + const attendeeUniqueId = newBookingSeat?.referenceUid ?? ""; + const attendeeWithSeat = { + ...inviteeToAdd, + bookingSeat: newBookingSeat ?? null, + }; evt = { ...evt, attendees: [...bookingAttendees, attendeeWithSeat] }; evt.attendeeSeatId = attendeeUniqueId; @@ -130,16 +218,20 @@ const createNewSeat = async ( let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; - isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false; + isHostConfirmationEmailsDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.host || false; isAttendeeConfirmationEmailDisabled = - eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; + eventType.metadata?.disableStandardEmails?.confirmation?.attendee || + false; if (isHostConfirmationEmailsDisabled) { - isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + isHostConfirmationEmailsDisabled = + allowDisablingHostConfirmationEmails(workflows); } if (isAttendeeConfirmationEmailDisabled) { - isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + isAttendeeConfirmationEmailDisabled = + allowDisablingAttendeeConfirmationEmails(workflows); } await sendScheduledSeatsEmailsAndSMS( copyEvent, @@ -152,16 +244,27 @@ const createNewSeat = async ( ); } const credentials = await refreshCredentials(allCredentials); - const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); - const eventManager = new EventManager({ ...organizerUser, credentials }, apps); + const apps = eventTypeAppMetadataOptionalSchema.parse( + eventType?.metadata?.apps + ); + const eventManager = new EventManager( + { ...organizerUser, credentials }, + apps + ); await eventManager.updateCalendarAttendees(evt, seatedBooking); const foundBooking = await findBookingQuery(seatedBooking.id); - if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) { + if ( + !Number.isNaN(paymentAppData.price) && + paymentAppData.price > 0 && + !!seatedBooking + ) { const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { - ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), + ...(paymentAppData.credentialId + ? { id: paymentAppData.credentialId } + : { userId: organizerUser.id }), app: { categories: { hasSome: ["payment"], @@ -180,15 +283,23 @@ const createNewSeat = async ( }, }); - const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => { - return credential.appId === paymentAppData.appId; - }); + const eventTypePaymentAppCredential = credentialPaymentAppCategories.find( + (credential) => { + return credential.appId === paymentAppData.appId; + } + ); if (!eventTypePaymentAppCredential) { - throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentCredential }); + throw new HttpError({ + statusCode: 400, + message: ErrorCode.MissingPaymentCredential, + }); } if (!eventTypePaymentAppCredential?.appId) { - throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentAppId }); + throw new HttpError({ + statusCode: 400, + message: ErrorCode.MissingPaymentAppId, + }); } const payment = await handlePayment({ @@ -202,7 +313,8 @@ const createNewSeat = async ( } : {}, }, - paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, + paymentAppCredentials: + eventTypePaymentAppCredential as IEventTypePaymentCredentialType, booking: seatedBooking, bookerName: fullName, bookerEmail,