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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
cf2bf9bb8d
commit
be4bb3fcde
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user