feat: TOUTOC in handleSeats (#27389)

* feat: TOUTOC in handleSeats

* fix type check

* refactor: replace Prisma include with select in integration tests

Address Cubic AI review feedback (confidence 9/10) to use select instead
of include in Prisma queries to limit returned fields and avoid pulling
full related records.

Co-Authored-By: unknown <>

* fix seat count

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-01-30 13:38:31 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent cf2bf9bb8d
commit be4bb3fcde
2 changed files with 677 additions and 64 deletions
@@ -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);
});
});
@@ -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<string, string>;
};
/**
* 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,