mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs) Format small subdirectories in packages/features: di, flags, holidays, oauth, settings, users, assignment-reason, selectedCalendar, hashedLink, host, form, form-builder, availability, data-table, pbac, schedules, troubleshooter, eventtypes, calendar-subscription, and root-level files. Also includes straggler apps/web BookEventForm.tsx. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 2 - medium subdirs) Format medium subdirectories in packages/features: auth, credentials, calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone, tasker, and webhooks. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 3 - bookings + insights) Format bookings and insights subdirectories in packages/features. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 4 - ee) Format packages/features/ee subdirectory covering billing, workflows, organizations, teams, managed-event-types, round-robin, dsync, integration-attribute-sync, and payments. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1) Format booking-audit di, actions, common, dto, repository, and types subdirectories in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2) Format booking-audit service subdirectory in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
309 lines
9.8 KiB
TypeScript
309 lines
9.8 KiB
TypeScript
import { cloneDeep } from "lodash";
|
|
import { uuid } from "short-uuid";
|
|
|
|
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
|
|
import { sendScheduledSeatsEmailsAndSMS } from "@calcom/emails/email-manager";
|
|
import { refreshCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/refreshCredentials";
|
|
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
|
|
import {
|
|
allowDisablingAttendeeConfirmationEmails,
|
|
allowDisablingHostConfirmationEmails,
|
|
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
|
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, 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";
|
|
|
|
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,
|
|
seatedBooking: SeatedBooking,
|
|
metadata?: Record<string, string>
|
|
) => {
|
|
const {
|
|
tAttendees,
|
|
attendeeLanguage,
|
|
invitee,
|
|
eventType,
|
|
additionalNotes,
|
|
noEmail,
|
|
paymentAppData,
|
|
allCredentials,
|
|
organizerUser,
|
|
fullName,
|
|
bookerEmail,
|
|
responses,
|
|
workflows,
|
|
bookerPhoneNumber,
|
|
} = rescheduleSeatedBookingObject;
|
|
let { evt } = rescheduleSeatedBookingObject;
|
|
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" },
|
|
};
|
|
});
|
|
|
|
const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video"));
|
|
|
|
if (videoCallReference) {
|
|
evt.videoCallData = {
|
|
type: videoCallReference.type,
|
|
id: videoCallReference.meetingId,
|
|
password: videoCallReference?.meetingPassword,
|
|
url: videoCallReference.meetingUrl,
|
|
};
|
|
}
|
|
|
|
const inviteeToAdd = invitee[0];
|
|
|
|
// 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,
|
|
},
|
|
seatData: {
|
|
description: additionalNotes,
|
|
responses,
|
|
},
|
|
metadata,
|
|
});
|
|
|
|
const attendeeUniqueId = newBookingSeat?.referenceUid ?? "";
|
|
const attendeeWithSeat = {
|
|
...inviteeToAdd,
|
|
bookingSeat: newBookingSeat ?? null,
|
|
};
|
|
|
|
evt = { ...evt, attendees: [...bookingAttendees, attendeeWithSeat] };
|
|
evt.attendeeSeatId = attendeeUniqueId;
|
|
const newSeat = seatedBooking.attendees.length !== 0;
|
|
|
|
/**
|
|
* Remember objects are passed into functions as references
|
|
* so if you modify it in a inner function it will be modified in the outer function
|
|
* deep cloning evt to avoid this
|
|
*/
|
|
if (!evt?.uid) {
|
|
evt.uid = seatedBooking?.uid ?? null;
|
|
}
|
|
const copyEvent = cloneDeep(evt);
|
|
copyEvent.uid = seatedBooking.uid;
|
|
if (noEmail !== true) {
|
|
let isHostConfirmationEmailsDisabled = false;
|
|
let isAttendeeConfirmationEmailDisabled = false;
|
|
|
|
isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
|
|
isAttendeeConfirmationEmailDisabled =
|
|
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
|
|
|
|
if (isHostConfirmationEmailsDisabled) {
|
|
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
|
|
}
|
|
|
|
if (isAttendeeConfirmationEmailDisabled) {
|
|
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
|
|
}
|
|
await sendScheduledSeatsEmailsAndSMS(
|
|
copyEvent,
|
|
inviteeToAdd,
|
|
newSeat,
|
|
!!eventType.seatsShowAttendees,
|
|
isHostConfirmationEmailsDisabled,
|
|
isAttendeeConfirmationEmailDisabled,
|
|
eventType.metadata
|
|
);
|
|
}
|
|
const credentials = await refreshCredentials(allCredentials);
|
|
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) {
|
|
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
|
where: {
|
|
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
|
|
app: {
|
|
categories: {
|
|
hasSome: ["payment"],
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
key: true,
|
|
appId: true,
|
|
app: {
|
|
select: {
|
|
categories: true,
|
|
dirName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
|
return credential.appId === paymentAppData.appId;
|
|
});
|
|
|
|
if (!eventTypePaymentAppCredential) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: ErrorCode.MissingPaymentCredential,
|
|
});
|
|
}
|
|
if (!eventTypePaymentAppCredential?.appId) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: ErrorCode.MissingPaymentAppId,
|
|
});
|
|
}
|
|
|
|
const payment = await handlePayment({
|
|
evt,
|
|
selectedEventType: {
|
|
...eventType,
|
|
metadata: eventType.metadata
|
|
? {
|
|
...eventType.metadata,
|
|
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
|
}
|
|
: {},
|
|
},
|
|
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
|
booking: seatedBooking,
|
|
bookerName: fullName,
|
|
bookerEmail,
|
|
bookerPhoneNumber,
|
|
});
|
|
|
|
resultBooking = { ...foundBooking };
|
|
resultBooking["message"] = "Payment required";
|
|
resultBooking["paymentUid"] = payment?.uid;
|
|
resultBooking["id"] = payment?.id;
|
|
} else {
|
|
resultBooking = { ...foundBooking };
|
|
}
|
|
|
|
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
|
|
|
|
return resultBooking;
|
|
};
|
|
|
|
export default createNewSeat;
|