refactor: Refactor seats logic (#12905)

* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

* Move handleSeats

* Abstract lastAttendeeDeleteBooking

* Create function for rescheduling seated events

* Fix imports on reschedule seats function

* Fix imports

* Import handleSeats function

* Fix rescheduleUid type

* Refactor owner reschedule to new time slot

* Refactor combine two booking times together

* Reschedule as an attendee

* Refactor createNewSeat

* Remove old handleSeats

* Remove lastAttendeeDeleteBooking from handleNewBooking

* Test for new attendee right params are passed

* Unit test params for reschedule

* Typo fix

* Clean up

* Create new seat test

* Test when attendee already signs up for booking

* Type fix

* Test reschedule move attendee to existing booking

* On reschedule create new booking

* Test on last attendee cancel booking

* Owner reschedule to new time slot

* Owner rescheduling, merge two bookings together

* Test: when merging more than available seats, then fail

* Test: fail when event is full

* Remove duplicate E2E tests

* Clean up

* Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent`

* Refactor `calcAppsStatus`

* Assign `evt` to resutl of `addVideoCallDataToEvent`

* Use prisma.transaction when moving attendees

* Clean create seat call

* Use ErrorCode enum

* Use attendeeRescheduledSeatedBooking function

* Await function

* Prevent double triggering of workflows

* Use inviteeToAdd in createNewSeat

* Remove unused error code

* Remove old handleSeats file

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Erik <erik@erosemberg.com>
This commit is contained in:
Joe Au-Yeung
2024-01-15 17:04:13 -05:00
committed by GitHub
co-authored by Morgan Peer Richelsen Erik
parent 5c6e104ff5
commit a0f1ceba19
16 changed files with 2917 additions and 1788 deletions
+3 -294
View File
@@ -1,5 +1,4 @@
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { v4 as uuidv4 } from "uuid";
import { randomString } from "@calcom/lib/random";
@@ -8,7 +7,6 @@ import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
createNewSeatedEventType,
selectFirstAvailableTimeSlotNextMonth,
createUserWithSeatedEventAndAttendees,
@@ -29,75 +27,8 @@ test.describe("Booking with Seats", () => {
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
});
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
const slug = "my-2-seated-event";
const user = await users.create({
name: "Seated event user",
eventTypes: [
{
title: "My 2-seated event",
slug,
length: 60,
seatsPerTimeSlot: 2,
seatsShowAttendees: true,
},
],
});
await page.goto(`/${user.username}/${slug}`);
let bookingUrl = "";
await test.step("Attendee #1 can book a seated event time slot", async () => {
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #2 can book the same seated event time slot", async () => {
await page.goto(`/${user.username}/${slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.waitForURL(/bookingUid/);
bookingUrl = page.url();
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("Attendee #3 cannot click on the same seated event time slot", async () => {
await page.goto(`/${user.username}/${slug}`);
await page.click('[data-testid="incrementMonth"]');
// TODO: Find out why the first day is always booked on tests
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible();
});
await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => {
await page.goto(bookingUrl);
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
});
await test.step("User owner should have only 1 booking with 3 attendees", async () => {
// Make sure user owner has only 1 booking with 3 attendees
const bookings = await prisma.booking.findMany({
where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id },
select: {
id: true,
attendees: {
select: {
id: true,
},
},
},
});
expect(bookings).toHaveLength(1);
expect(bookings[0].attendees).toHaveLength(2);
});
});
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
test(`Prevent attendees from cancel when having invalid URL params`, async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
@@ -120,30 +51,6 @@ test.describe("Booking with Seats", () => {
data: bookingSeats,
});
await test.step("Attendee #1 should be able to cancel their booking", async () => {
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[0].referenceUid}`);
await page.locator('[data-testid="cancel"]').click();
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
await expect(page).toHaveURL(/\/booking\/.*/);
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
// Old booking should still exist, with one less attendee
const updatedBooking = await prisma.booking.findFirst({
where: { id: bookingSeats[0].bookingId },
include: { attendees: true },
});
const attendeeIds = updatedBooking?.attendees.map(({ id }) => id);
expect(attendeeIds).toHaveLength(2);
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
});
await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => {
await page.goto(`/booking/${booking.uid}`);
@@ -156,29 +63,6 @@ test.describe("Booking with Seats", () => {
// expect cancel button to don't be in the page
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
});
await test.step("All attendees cancelling should delete the booking for the user", async () => {
// The remaining 2 attendees cancel
for (let i = 1; i < bookingSeats.length; i++) {
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[i].referenceUid}`);
await page.locator('[data-testid="cancel"]').click();
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
await page.locator('[data-testid="confirm_cancel"]').click();
await expect(page).toHaveURL(/\/booking\/.*/);
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
await expect(cancelledHeadline).toBeVisible();
}
// Should expect old booking to be cancelled
const updatedBooking = await prisma.booking.findFirst({
where: { id: bookingSeats[0].bookingId },
});
expect(updatedBooking).not.toBeNull();
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
});
});
test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => {
@@ -224,181 +108,6 @@ test.describe("Booking with Seats", () => {
});
test.describe("Reschedule for booking with seats", () => {
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: `second+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
{ name: "John Third", email: `third+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
email: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
});
await page.goto(`/reschedule/${references[2].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
// expect input to be filled with attendee number 3 data
const thirdAttendeeElement = await page.locator("input[name=name]");
const attendeeName = await thirdAttendeeElement.inputValue();
expect(attendeeName).toBe("John Third");
await page.locator('[data-testid="confirm-reschedule-button"]').click();
// should wait for URL but that path starts with booking/
await page.waitForURL(/\/booking\/.*/);
await expect(page).toHaveURL(/\/booking\/.*/);
// Should expect new booking to be created for John Third
const newBooking = await prisma.booking.findFirst({
where: {
attendees: {
some: { email: bookingAttendees[2].email },
},
},
include: { seatsReferences: true, attendees: true },
});
expect(newBooking?.status).toBe(BookingStatus.PENDING);
expect(newBooking?.attendees.length).toBe(1);
expect(newBooking?.attendees[0].name).toBe("John Third");
expect(newBooking?.seatsReferences.length).toBe(1);
// Should expect old booking to be accepted with two attendees
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
expect(oldBooking?.attendees.length).toBe(2);
expect(oldBooking?.seatsReferences.length).toBe(2);
});
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
page,
users,
bookings,
}) => {
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
});
await page.goto(`/reschedule/${references[0].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await page.waitForURL(/\/booking\/.*/);
await page.goto(`/reschedule/${references[1].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
// Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled
await page.waitForURL(/\/booking\/.*/);
// Should expect old booking to be cancelled
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: {
seatsReferences: true,
attendees: true,
eventType: {
include: { users: true, hosts: true },
},
},
});
expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);
});
test("Should cancel with seats and have no attendees and cancelled", async ({ page, users, bookings }) => {
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await user.apiLogin();
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
},
});
const bookingSeats = [
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
];
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
// Now we cancel the booking as the organizer
await page.goto(`/booking/${booking.uid}?cancel=true`);
await page.locator('[data-testid="confirm_cancel"]').click();
await expect(page).toHaveURL(/\/booking\/.*/);
// Should expect old booking to be cancelled
const updatedBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
include: { seatsReferences: true, attendees: true },
});
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
});
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
page,
users,
@@ -457,7 +166,7 @@ test.describe("Reschedule for booking with seats", () => {
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
});
test("Should cancel with seats but event should be still accesible and with one less attendee/seat", async ({
test("Should cancel with seats but event should be still accessible and with one less attendee/seat", async ({
page,
users,
bookings,
@@ -2194,6 +2194,11 @@
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
"booking_not_found_error": "Could not find booking",
"booking_seats_full_error": "Booking seats are full",
"missing_payment_credential_error": "Missing payment credentials",
"missing_payment_app_id_error": "Missing payment app id",
"not_enough_available_seats_error": "Booking does not have enough available seats",
"user_redirect_title": "{{username}} is currently away for a brief period of time.",
"user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
"out_of_office": "Out of office",
@@ -129,6 +129,8 @@ export type InputEventType = {
durationLimits?: IntervalLimit;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
type WhiteListedBookingProps = {
id?: number;
uid?: string;
@@ -138,11 +140,15 @@ type WhiteListedBookingProps = {
endTime: string;
title?: string;
status: BookingStatus;
attendees?: { email: string }[];
attendees?: {
email: string;
bookingSeat?: AttendeeBookingSeatInput | null;
}[];
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
credentialId?: number | null;
})[];
bookingSeat?: Prisma.BookingSeatCreateInput[];
};
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
@@ -335,7 +341,7 @@ async function addBookings(bookings: InputBooking[]) {
);
}
return {
uid: uuidv4(),
uid: booking.uid || uuidv4(),
workflowReminders: [],
references: [],
title: "Test Booking Title",
@@ -362,10 +368,23 @@ async function addBookings(bookings: InputBooking[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
createMany: {
data: booking.attendees,
data: booking.attendees.map((attendee) => {
if (attendee.bookingSeat) {
const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee;
return {
...attendeeWithoutBookingSeat,
bookingSeat: {
create: { ...bookingSeat, bookingId: booking.id },
},
};
} else {
return attendee;
}
}),
},
};
}
return bookingCreate;
})
);
@@ -1398,13 +1417,18 @@ export function getMockBookingReference(
};
}
export function getMockBookingAttendee(attendee: Omit<Attendee, "bookingId">) {
export function getMockBookingAttendee(
attendee: Omit<Attendee, "bookingId"> & {
bookingSeat?: AttendeeBookingSeatInput;
}
) {
return {
id: attendee.id,
timeZone: attendee.timeZone,
name: attendee.name,
email: attendee.email,
locale: attendee.locale,
bookingSeat: attendee.bookingSeat || null,
};
}
@@ -1,4 +1,4 @@
import type { App, Attendee, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
import type { App, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
import { Prisma } from "@prisma/client";
import async from "async";
import type { IncomingMessage } from "http";
@@ -12,7 +12,6 @@ import type { Logger } from "tslog";
import { v5 as uuidv5 } from "uuid";
import z from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { LocationObject } from "@calcom/app-store/locations";
import {
@@ -25,19 +24,16 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import {
sendAttendeeRequestEmail,
sendOrganizerRequestEmail,
sendRescheduledEmails,
sendRescheduledSeatEmail,
sendRoundRobinCancelledEmails,
sendRoundRobinRescheduledEmails,
sendRoundRobinScheduledEmails,
sendScheduledEmails,
sendScheduledSeatsEmails,
} from "@calcom/emails";
import getICalUID from "@calcom/emails/lib/getICalUID";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
@@ -99,7 +95,8 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import getBookingDataSchema from "./getBookingDataSchema";
import type { BookingSeat } from "./handleSeats";
import handleSeats from "./handleSeats/handleSeats";
import type { BookingSeat } from "./handleSeats/types";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@@ -861,7 +858,7 @@ export function getCustomInputsResponses(
*
* @returns updated evt with video call data
*/
export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
export const addVideoCallDataToEvent = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
@@ -892,7 +889,7 @@ export function handleAppsStatus(
reqAppsStatus: ReqAppsStatus
) {
// Taking care of apps status
let resultStatus: AppsStatus[] = results.map((app) => ({
const resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
@@ -920,8 +917,7 @@ export function handleAppsStatus(
}
return prev;
}, {} as { [key: string]: AppsStatus });
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
return Object.values(calcAppsStatus);
}
function getICalSequence(originalRescheduledBooking: BookingType | null) {
@@ -1202,7 +1198,7 @@ async function handler(
let originalRescheduledBooking: BookingType = null;
//this gets the orginal rescheduled booking
//this gets the original rescheduled booking
if (rescheduleUid) {
// rescheduleUid can be bookingUid and bookingSeatUid
bookingSeat = await prisma.bookingSeat.findUnique({
@@ -1467,57 +1463,6 @@ async function handler(
evt.destinationCalendar?.push(...teamDestinationCalendars);
}
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>,
filteredAttendees: Partial<Attendee>[],
originalBookingEvt?: CalendarEvent
) => {
let deletedReferences = false;
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
const integrationsToDelete = [];
for (const reference of originalRescheduledBooking.references) {
if (reference.credentialId) {
const credential = await prisma.credential.findUnique({
where: {
id: reference.credentialId,
},
select: credentialForCalendarServiceSelect,
});
if (credential) {
if (reference.type.includes("_video")) {
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
}
if (reference.type.includes("_calendar") && originalBookingEvt) {
const calendar = await getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
);
}
}
}
}
}
await Promise.all(integrationsToDelete).then(async () => {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
});
deletedReferences = true;
}
return deletedReferences;
};
// data needed for triggering webhooks
const eventTypeInfo: EventTypeInfo = {
eventTitle: eventType.title,
@@ -1557,640 +1502,39 @@ async function handler(
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
teamId,
};
const handleSeats = async () => {
let resultBooking:
| (Partial<Booking> & {
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null = null;
const booking = await prisma.booking.findFirst({
where: {
OR: [
{
uid: rescheduleUid || reqBody.bookingUid,
},
{
eventTypeId: eventType.id,
startTime: evt.startTime,
},
],
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
id: true,
attendees: { include: { bookingSeat: true } },
userId: true,
references: true,
startTime: true,
user: true,
status: true,
smsReminderNumber: true,
endTime: true,
scheduledJobs: true,
},
});
if (!booking) {
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
}
// See if attendee is already signed up for timeslot
if (
booking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
dayjs.utc(booking.startTime).format() === evt.startTime
) {
throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking });
}
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
if (rescheduleUid) {
// See if the new date has a booking already
const newTimeSlotBooking = await prisma.booking.findFirst({
where: {
startTime: evt.startTime,
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
uid: true,
attendees: {
include: {
bookingSeat: true,
},
},
},
});
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (!originalRescheduledBooking) {
// typescript isn't smart enough;
throw new Error("Internal Error.");
}
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
(filteredAttendees, attendee) => {
if (attendee.email === bookerEmail) {
return filteredAttendees; // skip current booker, as we know the language already.
}
filteredAttendees.push({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
});
return filteredAttendees;
},
[] as Person[]
);
// If original booking has video reference we need to add the videoCallData to the new evt
const videoReference = originalRescheduledBooking.references.find((reference) =>
reference.type.includes("_video")
);
const originalBookingEvt = {
...evt,
title: originalRescheduledBooking.title,
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
attendees: updatedBookingAttendees,
// If the location is a video integration then include the videoCallData
...(videoReference && {
videoCallData: {
type: videoReference.type,
id: videoReference.meetingId,
password: videoReference.meetingPassword,
url: videoReference.meetingUrl,
},
}),
};
if (!bookingSeat) {
// if no bookingSeat is given and the userId != owner, 401.
// TODO: Next step; Evaluate ownership, what about teams?
if (booking.user?.id !== req.userId) {
throw new HttpError({ statusCode: 401 });
}
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
};
});
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
// If there is no booking during the new time slot then update the current booking to the new date
if (!newTimeSlotBooking) {
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
where: {
id: booking.id,
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
evt = addVideoCallDataToEvt(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
// @NOTE: This code is duplicated and should be moved to a function
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
loggerWithEventDetails.error(
`Booking ${organizerUser.name} failed`,
JSON.stringify({ error, results })
);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
const foundBooking = await findBookingQuery(newBooking.id);
resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus };
} else {
// Merge two bookings together
const attendeesToMove = [],
attendeesToDelete = [];
for (const attendee of booking.attendees) {
// If the attendee already exists on the new booking then delete the attendee record of the old booking
if (
newTimeSlotBooking.attendees.some(
(newBookingAttendee) => newBookingAttendee.email === attendee.email
)
) {
attendeesToDelete.push(attendee.id);
// If the attendee does not exist on the new booking then move that attendee record to the new booking
} else {
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
}
}
// Confirm that the new event will have enough available seats
if (
!eventType.seatsPerTimeSlot ||
attendeesToMove.length +
newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
eventType.seatsPerTimeSlot
) {
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
}
const moveAttendeeCalls = [];
for (const attendeeToMove of attendeesToMove) {
moveAttendeeCalls.push(
prisma.attendee.update({
where: {
id: attendeeToMove.id,
},
data: {
bookingId: newTimeSlotBooking.id,
bookingSeat: {
upsert: {
create: {
referenceUid: uuid(),
bookingId: newTimeSlotBooking.id,
},
update: {
bookingId: newTimeSlotBooking.id,
},
},
},
},
})
);
}
await Promise.all([
...moveAttendeeCalls,
// Delete any attendees that are already a part of that new time slot booking
prisma.attendee.deleteMany({
where: {
id: {
in: attendeesToDelete,
},
},
}),
]);
const updatedNewBooking = await prisma.booking.findUnique({
where: {
id: newTimeSlotBooking.id,
},
include: {
attendees: true,
references: true,
},
});
if (!updatedNewBooking) {
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
}
// Update the evt object with the new attendees
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
const evtAttendee = {
...attendee,
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
};
return evtAttendee;
});
evt.attendees = updatedBookingAttendees;
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(
copyEvent,
rescheduleUid,
newTimeSlotBooking.id
);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
if (noEmail !== true && isConfirmedByDefault) {
// TODO send reschedule emails to attendees of the old booking
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
// Update the old booking with the cancelled status
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking };
}
}
// seatAttendee is null when the organizer is rescheduling.
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
if (seatAttendee) {
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
}
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
return null;
}
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await Promise.all([
await prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
await prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
}
} else {
// 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 = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) {
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
}
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
const attendeeUniqueId = uuid();
await prisma.booking.update({
where: {
uid: reqBody.bookingUid,
},
include: {
attendees: true,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
bookingSeat: {
create: {
referenceUid: attendeeUniqueId,
data: {
description: additionalNotes,
},
booking: {
connect: {
id: booking.id,
},
},
},
},
},
},
...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
},
});
evt.attendeeSeatId = attendeeUniqueId;
const newSeat = booking.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 = booking?.uid ?? null;
}
const copyEvent = cloneDeep(evt);
copyEvent.uid = booking.uid;
if (noEmail !== true) {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
if (eventType.workflows) {
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 sendScheduledSeatsEmails(
copyEvent,
invitee[0],
newSeat,
!!eventType.seatsShowAttendees,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled
);
}
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
const foundBooking = await findBookingQuery(booking.id);
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
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: "Missing payment credentials" });
}
if (!eventTypePaymentAppCredential?.appId) {
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
}
const payment = await handlePayment(
evt,
eventType,
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking,
fullName,
bookerEmail
);
resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid;
resultBooking["id"] = payment?.id;
} else {
resultBooking = { ...foundBooking };
}
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
}
// Here we should handle every after action that needs to be done after booking creation
// Obtain event metadata that includes videoCallUrl
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: rescheduleUid ? false : evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
const webhookData = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
bookingId: booking?.id,
rescheduleId: originalRescheduledBooking?.id || undefined,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBody.metadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
return resultBooking;
};
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot && (reqBody.bookingUid || rescheduleUid)) {
const newBooking = await handleSeats();
const newBooking = await handleSeats({
rescheduleUid,
reqBookingUid: reqBody.bookingUid,
eventType,
evt,
invitee,
allCredentials,
organizerUser,
originalRescheduledBooking,
bookerEmail,
tAttendees,
bookingSeat,
reqUserId: req.userId,
rescheduleReason,
reqBodyUser: reqBody.user,
noEmail,
isConfirmedByDefault,
additionalNotes,
reqAppsStatus,
attendeeLanguage,
paymentAppData,
fullName,
smsReminderNumber,
eventTypeInfo,
uid,
eventTypeId,
reqBodyMetadata: reqBody.metadata,
subscriberOptions,
eventTrigger,
});
if (newBooking) {
req.statusCode = 201;
return newBooking;
@@ -2321,7 +1665,7 @@ async function handler(
);
}
addVideoCallDataToEvt(originalRescheduledBooking.references, evt);
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
//update original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot) {
@@ -1,796 +0,0 @@
import type { Prisma, Attendee } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import type short from "short-uuid";
import { uuid } from "short-uuid";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import EventManager from "@calcom/core/EventManager";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import type { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import { HttpError } from "@calcom/lib/http-error";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import {
refreshCredentials,
addVideoCallDataToEvt,
createLoggerWithEventDetails,
handleAppsStatus,
findBookingQuery,
} from "./handleNewBooking";
import type {
Booking,
Invitee,
NewBookingEventType,
getAllCredentials,
OrganizerUser,
OriginalRescheduledBooking,
RescheduleReason,
NoEmail,
IsConfirmedByDefault,
AdditionalNotes,
ReqAppsStatus,
PaymentAppData,
IEventTypePaymentCredentialType,
SmsReminderNumber,
EventTypeId,
ReqBodyMetadata,
} from "./handleNewBooking";
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
originalRescheduledBooking: OriginalRescheduledBooking,
filteredAttendees: Partial<Attendee>[],
originalBookingEvt?: CalendarEvent
) => {
let deletedReferences = false;
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
const integrationsToDelete = [];
for (const reference of originalRescheduledBooking.references) {
if (reference.credentialId) {
const credential = await prisma.credential.findUnique({
where: {
id: reference.credentialId,
},
select: credentialForCalendarServiceSelect,
});
if (credential) {
if (reference.type.includes("_video")) {
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
}
if (reference.type.includes("_calendar") && originalBookingEvt) {
const calendar = await getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
);
}
}
}
}
}
await Promise.all(integrationsToDelete).then(async () => {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
});
deletedReferences = true;
}
return deletedReferences;
};
const handleSeats = async ({
rescheduleUid,
reqBookingUid,
eventType,
evt,
invitee,
allCredentials,
organizerUser,
originalRescheduledBooking,
bookerEmail,
tAttendees,
bookingSeat,
reqUserId,
rescheduleReason,
reqBodyUser,
noEmail,
isConfirmedByDefault,
additionalNotes,
reqAppsStatus,
attendeeLanguage,
paymentAppData,
fullName,
smsReminderNumber,
eventTypeInfo,
uid,
eventTypeId,
reqBodyMetadata,
subscriberOptions,
eventTrigger,
}: {
rescheduleUid: string;
reqBookingUid: string;
eventType: NewBookingEventType;
evt: CalendarEvent;
invitee: Invitee;
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
organizerUser: OrganizerUser;
originalRescheduledBooking: OriginalRescheduledBooking;
bookerEmail: string;
tAttendees: TFunction;
bookingSeat: BookingSeat;
reqUserId: number | undefined;
rescheduleReason: RescheduleReason;
reqBodyUser: string | string[] | undefined;
noEmail: NoEmail;
isConfirmedByDefault: IsConfirmedByDefault;
additionalNotes: AdditionalNotes;
reqAppsStatus: ReqAppsStatus;
attendeeLanguage: string | null;
paymentAppData: PaymentAppData;
fullName: ReturnType<typeof getFullName>;
smsReminderNumber: SmsReminderNumber;
eventTypeInfo: EventTypeInfo;
uid: short.SUUID;
eventTypeId: EventTypeId;
reqBodyMetadata: ReqBodyMetadata;
subscriberOptions: GetSubscriberOptions;
eventTrigger: WebhookTriggerEvents;
}) => {
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
let resultBooking:
| (Partial<Booking> & {
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null = null;
const booking = await prisma.booking.findFirst({
where: {
OR: [
{
uid: rescheduleUid || reqBookingUid,
},
{
eventTypeId: eventType.id,
startTime: evt.startTime,
},
],
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
id: true,
attendees: { include: { bookingSeat: true } },
userId: true,
references: true,
startTime: true,
user: true,
status: true,
smsReminderNumber: true,
endTime: true,
scheduledJobs: true,
},
});
if (!booking) {
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
}
// See if attendee is already signed up for timeslot
if (
booking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
dayjs.utc(booking.startTime).format() === evt.startTime
) {
throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." });
}
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
if (rescheduleUid) {
// See if the new date has a booking already
const newTimeSlotBooking = await prisma.booking.findFirst({
where: {
startTime: evt.startTime,
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
uid: true,
attendees: {
include: {
bookingSeat: true,
},
},
},
});
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (!originalRescheduledBooking) {
// typescript isn't smart enough;
throw new Error("Internal Error.");
}
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
(filteredAttendees, attendee) => {
if (attendee.email === bookerEmail) {
return filteredAttendees; // skip current booker, as we know the language already.
}
filteredAttendees.push({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
});
return filteredAttendees;
},
[] as Person[]
);
// If original booking has video reference we need to add the videoCallData to the new evt
const videoReference = originalRescheduledBooking.references.find((reference) =>
reference.type.includes("_video")
);
const originalBookingEvt = {
...evt,
title: originalRescheduledBooking.title,
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
attendees: updatedBookingAttendees,
// If the location is a video integration then include the videoCallData
...(videoReference && {
videoCallData: {
type: videoReference.type,
id: videoReference.meetingId,
password: videoReference.meetingPassword,
url: videoReference.meetingUrl,
},
}),
};
if (!bookingSeat) {
// if no bookingSeat is given and the userId != owner, 401.
// TODO: Next step; Evaluate ownership, what about teams?
if (booking.user?.id !== reqUserId) {
throw new HttpError({ statusCode: 401 });
}
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
};
});
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
// If there is no booking during the new time slot then update the current booking to the new date
if (!newTimeSlotBooking) {
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
where: {
id: booking.id,
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
evt = addVideoCallDataToEvt(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
// @NOTE: This code is duplicated and should be moved to a function
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
loggerWithEventDetails.error(
`Booking ${organizerUser.name} failed`,
JSON.stringify({ error, results })
);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
const foundBooking = await findBookingQuery(newBooking.id);
resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus };
} else {
// Merge two bookings together
const attendeesToMove = [],
attendeesToDelete = [];
for (const attendee of booking.attendees) {
// If the attendee already exists on the new booking then delete the attendee record of the old booking
if (
newTimeSlotBooking.attendees.some(
(newBookingAttendee) => newBookingAttendee.email === attendee.email
)
) {
attendeesToDelete.push(attendee.id);
// If the attendee does not exist on the new booking then move that attendee record to the new booking
} else {
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
}
}
// Confirm that the new event will have enough available seats
if (
!eventType.seatsPerTimeSlot ||
attendeesToMove.length +
newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
eventType.seatsPerTimeSlot
) {
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
}
const moveAttendeeCalls = [];
for (const attendeeToMove of attendeesToMove) {
moveAttendeeCalls.push(
prisma.attendee.update({
where: {
id: attendeeToMove.id,
},
data: {
bookingId: newTimeSlotBooking.id,
bookingSeat: {
upsert: {
create: {
referenceUid: uuid(),
bookingId: newTimeSlotBooking.id,
},
update: {
bookingId: newTimeSlotBooking.id,
},
},
},
},
})
);
}
await Promise.all([
...moveAttendeeCalls,
// Delete any attendees that are already a part of that new time slot booking
prisma.attendee.deleteMany({
where: {
id: {
in: attendeesToDelete,
},
},
}),
]);
const updatedNewBooking = await prisma.booking.findUnique({
where: {
id: newTimeSlotBooking.id,
},
include: {
attendees: true,
references: true,
},
});
if (!updatedNewBooking) {
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
}
// Update the evt object with the new attendees
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
const evtAttendee = {
...attendee,
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
};
return evtAttendee;
});
evt.attendees = updatedBookingAttendees;
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
if (noEmail !== true && isConfirmedByDefault) {
// TODO send reschedule emails to attendees of the old booking
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
// Update the old booking with the cancelled status
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking };
}
}
// seatAttendee is null when the organizer is rescheduling.
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
if (seatAttendee) {
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
}
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
return null;
}
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await Promise.all([
await prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
await prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
}
} else {
// 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 = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) {
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
}
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
const attendeeUniqueId = uuid();
await prisma.booking.update({
where: {
uid: reqBookingUid,
},
include: {
attendees: true,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
bookingSeat: {
create: {
referenceUid: attendeeUniqueId,
data: {
description: additionalNotes,
},
booking: {
connect: {
id: booking.id,
},
},
},
},
},
},
...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
},
});
evt.attendeeSeatId = attendeeUniqueId;
const newSeat = booking.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 = booking?.uid ?? null;
}
const copyEvent = cloneDeep(evt);
copyEvent.uid = booking.uid;
if (noEmail !== true) {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
if (eventType.workflows) {
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 sendScheduledSeatsEmails(
copyEvent,
invitee[0],
newSeat,
!!eventType.seatsShowAttendees,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled
);
}
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
const foundBooking = await findBookingQuery(booking.id);
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
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: "Missing payment credentials" });
}
if (!eventTypePaymentAppCredential?.appId) {
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
}
const payment = await handlePayment(
evt,
eventType,
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking,
fullName,
bookerEmail
);
resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid;
resultBooking["id"] = payment?.id;
} else {
resultBooking = { ...foundBooking };
}
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
}
// Here we should handle every after action that needs to be done after booking creation
// Obtain event metadata that includes videoCallUrl
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
const webhookData = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBodyMetadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
return resultBooking;
};
export default handleSeats;
@@ -0,0 +1,204 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import { uuid } from "short-uuid";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledSeatsEmails } from "@calcom/emails";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking";
import { refreshCredentials, findBookingQuery } from "../../handleNewBooking";
import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types";
const createNewSeat = async (
rescheduleSeatedBookingObject: NewSeatedBookingObject,
seatedBooking: SeatedBooking
) => {
const {
tAttendees,
attendeeLanguage,
invitee,
eventType,
reqBookingUid,
additionalNotes,
noEmail,
paymentAppData,
allCredentials,
organizerUser,
fullName,
bookerEmail,
} = 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" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) {
throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull });
}
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 attendeeUniqueId = uuid();
const inviteeToAdd = invitee[0];
await prisma.booking.update({
where: {
uid: reqBookingUid,
},
include: {
attendees: true,
},
data: {
attendees: {
create: {
email: inviteeToAdd.email,
name: inviteeToAdd.name,
timeZone: inviteeToAdd.timeZone,
locale: inviteeToAdd.language.locale,
bookingSeat: {
create: {
referenceUid: attendeeUniqueId,
data: {
description: additionalNotes,
},
booking: {
connect: {
id: seatedBooking.id,
},
},
},
},
},
},
...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
},
});
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;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
if (eventType.workflows) {
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 sendScheduledSeatsEmails(
copyEvent,
inviteeToAdd,
newSeat,
!!eventType.seatsShowAttendees,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled
);
}
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
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,
eventType,
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
seatedBooking,
fullName,
bookerEmail
);
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;
@@ -0,0 +1,134 @@
// eslint-disable-next-line no-restricted-imports
import dayjs from "@calcom/dayjs";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { createLoggerWithEventDetails } from "../handleNewBooking";
import createNewSeat from "./create/createNewSeat";
import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking";
import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types";
const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
const {
eventType,
reqBodyUser,
rescheduleUid,
reqBookingUid,
invitee,
bookerEmail,
smsReminderNumber,
eventTypeInfo,
uid,
originalRescheduledBooking,
reqBodyMetadata,
eventTypeId,
subscriberOptions,
eventTrigger,
} = newSeatedBookingObject;
const { evt } = newSeatedBookingObject;
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
let resultBooking: HandleSeatsResultBooking = null;
const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({
where: {
OR: [
{
uid: rescheduleUid || reqBookingUid,
},
{
eventTypeId: eventType.id,
startTime: evt.startTime,
},
],
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
id: true,
attendees: { include: { bookingSeat: true } },
userId: true,
references: true,
startTime: true,
user: true,
status: true,
smsReminderNumber: true,
endTime: true,
scheduledJobs: true,
},
});
if (!seatedBooking) {
throw new HttpError({ statusCode: 404, message: ErrorCode.BookingNotFound });
}
// See if attendee is already signed up for timeslot
if (
seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
dayjs.utc(seatedBooking.startTime).format() === evt.startTime
) {
throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking });
}
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
if (rescheduleUid) {
resultBooking = await rescheduleSeatedBooking(
// Assert that the rescheduleUid is defined
{ ...newSeatedBookingObject, rescheduleUid },
seatedBooking,
resultBooking,
loggerWithEventDetails
);
} else {
resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking);
}
// If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking
if (resultBooking) {
// Obtain event metadata that includes videoCallUrl
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
const webhookData = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
bookingId: seatedBooking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBodyMetadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: seatedBooking?.smsReminderNumber || undefined,
};
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
}
return resultBooking;
};
export default handleSeats;
@@ -0,0 +1,64 @@
import type { Attendee } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { deleteMeeting } from "@calcom/core/videoClient";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { OriginalRescheduledBooking } from "../../handleNewBooking";
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
originalRescheduledBooking: OriginalRescheduledBooking,
filteredAttendees: Partial<Attendee>[] | undefined,
originalBookingEvt?: CalendarEvent
) => {
let deletedReferences = false;
if ((!filteredAttendees || filteredAttendees.length === 0) && originalRescheduledBooking) {
const integrationsToDelete = [];
for (const reference of originalRescheduledBooking.references) {
if (reference.credentialId) {
const credential = await prisma.credential.findUnique({
where: {
id: reference.credentialId,
},
select: credentialForCalendarServiceSelect,
});
if (credential) {
if (reference.type.includes("_video")) {
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
}
if (reference.type.includes("_calendar") && originalBookingEvt) {
const calendar = await getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
);
}
}
}
}
}
await Promise.all(integrationsToDelete).then(async () => {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
});
deletedReferences = true;
}
return deletedReferences;
};
export default lastAttendeeDeleteBooking;
@@ -0,0 +1,102 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledSeatEmail } from "@calcom/emails";
import prisma from "@calcom/prisma";
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
import { findBookingQuery } from "../../../handleNewBooking";
import lastAttendeeDeleteBooking from "../../lib/lastAttendeeDeleteBooking";
import type { RescheduleSeatedBookingObject, SeatAttendee, NewTimeSlotBooking } from "../../types";
const attendeeRescheduleSeatedBooking = async (
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
seatAttendee: SeatAttendee,
newTimeSlotBooking: NewTimeSlotBooking | null,
originalBookingEvt: CalendarEvent,
eventManager: EventManager
) => {
const { tAttendees, bookingSeat, bookerEmail, rescheduleUid, evt } = rescheduleSeatedBookingObject;
let { originalRescheduledBooking } = rescheduleSeatedBookingObject;
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
}
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
return null;
}
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await prisma.$transaction([
prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
return { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
};
export default attendeeRescheduleSeatedBooking;
@@ -0,0 +1,160 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import { uuid } from "short-uuid";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledEmails } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking";
import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types";
const combineTwoSeatedBookings = async (
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
seatedBooking: SeatedBooking,
newTimeSlotBooking: NewTimeSlotBooking,
eventManager: EventManager,
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
) => {
const {
eventType,
tAttendees,
attendeeLanguage,
rescheduleUid,
noEmail,
isConfirmedByDefault,
additionalNotes,
rescheduleReason,
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
// Merge two bookings together
const attendeesToMove = [],
attendeesToDelete = [];
for (const attendee of seatedBooking.attendees) {
// If the attendee already exists on the new booking then delete the attendee record of the old booking
if (
newTimeSlotBooking.attendees.some((newBookingAttendee) => newBookingAttendee.email === attendee.email)
) {
attendeesToDelete.push(attendee.id);
// If the attendee does not exist on the new booking then move that attendee record to the new booking
} else {
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
}
}
// Confirm that the new event will have enough available seats
if (
!eventType.seatsPerTimeSlot ||
attendeesToMove.length + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
eventType.seatsPerTimeSlot
) {
throw new HttpError({ statusCode: 409, message: ErrorCode.NotEnoughAvailableSeats });
}
const moveAttendeeCalls = [];
for (const attendeeToMove of attendeesToMove) {
moveAttendeeCalls.push(
prisma.attendee.update({
where: {
id: attendeeToMove.id,
},
data: {
bookingId: newTimeSlotBooking.id,
bookingSeat: {
upsert: {
create: {
referenceUid: uuid(),
bookingId: newTimeSlotBooking.id,
},
update: {
bookingId: newTimeSlotBooking.id,
},
},
},
},
})
);
}
await prisma.$transaction([
...moveAttendeeCalls,
// Delete any attendees that are already a part of that new time slot booking
prisma.attendee.deleteMany({
where: {
id: {
in: attendeesToDelete,
},
},
}),
]);
const updatedNewBooking = await prisma.booking.findUnique({
where: {
id: newTimeSlotBooking.id,
},
include: {
attendees: true,
references: true,
},
});
if (!updatedNewBooking) {
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
}
// Update the evt object with the new attendees
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
const evtAttendee = {
...attendee,
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
};
return evtAttendee;
});
evt.attendees = updatedBookingAttendees;
evt = addVideoCallDataToEvent(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
if (noEmail !== true && isConfirmedByDefault) {
// TODO send reschedule emails to attendees of the old booking
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
// Update the old booking with the cancelled status
await prisma.booking.update({
where: {
id: seatedBooking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
return { ...foundBooking };
};
export default combineTwoSeatedBookings;
@@ -0,0 +1,101 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type EventManager from "@calcom/core/EventManager";
import { sendRescheduledEmails } from "@calcom/emails";
import prisma from "@calcom/prisma";
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
import { addVideoCallDataToEvent, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking";
import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking";
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
const moveSeatedBookingToNewTimeSlot = async (
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
seatedBooking: SeatedBooking,
eventManager: EventManager,
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
) => {
const {
rescheduleReason,
rescheduleUid,
eventType,
organizerUser,
reqAppsStatus,
noEmail,
isConfirmedByDefault,
additionalNotes,
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
where: {
id: seatedBooking.id,
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
evt = addVideoCallDataToEvent(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
// @NOTE: This code is duplicated and should be moved to a function
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, JSON.stringify({ error, results }));
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
const foundBooking = await findBookingQuery(newBooking.id);
return { ...foundBooking, appsStatus: newBooking.appsStatus };
};
export default moveSeatedBookingToNewTimeSlot;
@@ -0,0 +1,54 @@
// eslint-disable-next-line no-restricted-imports
import type EventManager from "@calcom/core/EventManager";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
import type {
NewTimeSlotBooking,
SeatedBooking,
RescheduleSeatedBookingObject,
HandleSeatsResultBooking,
} from "../../types";
import combineTwoSeatedBookings from "./combineTwoSeatedBookings";
import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot";
const ownerRescheduleSeatedBooking = async (
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
newTimeSlotBooking: NewTimeSlotBooking | null,
seatedBooking: SeatedBooking,
resultBooking: HandleSeatsResultBooking | null,
eventManager: EventManager,
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
) => {
const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject;
const { evt } = rescheduleSeatedBookingObject;
evt.attendees = originalRescheduledBooking?.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
};
});
// If there is no booking during the new time slot then update the current booking to the new date
if (!newTimeSlotBooking) {
resultBooking = await moveSeatedBookingToNewTimeSlot(
rescheduleSeatedBookingObject,
seatedBooking,
eventManager,
loggerWithEventDetails
);
} else {
// If a booking already exists during the new time slot then merge the two bookings together
resultBooking = await combineTwoSeatedBookings(
rescheduleSeatedBookingObject,
seatedBooking,
newTimeSlotBooking,
eventManager,
loggerWithEventDetails
);
}
return resultBooking;
};
export default ownerRescheduleSeatedBooking;
@@ -0,0 +1,140 @@
// eslint-disable-next-line no-restricted-imports
import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Person } from "@calcom/types/Calendar";
import { refreshCredentials } from "../../handleNewBooking";
import type { createLoggerWithEventDetails } from "../../handleNewBooking";
import type {
HandleSeatsResultBooking,
SeatedBooking,
RescheduleSeatedBookingObject,
SeatAttendee,
} from "../types";
import attendeeRescheduleSeatedBooking from "./attendee/attendeeRescheduleSeatedBooking";
import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking";
const rescheduleSeatedBooking = async (
// If this function is being called then rescheduleUid is defined
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
seatedBooking: SeatedBooking,
resultBooking: HandleSeatsResultBooking | null,
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
) => {
const { evt, eventType, allCredentials, organizerUser, bookerEmail, tAttendees, bookingSeat, reqUserId } =
rescheduleSeatedBookingObject;
const { originalRescheduledBooking } = rescheduleSeatedBookingObject;
// See if the new date has a booking already
const newTimeSlotBooking = await prisma.booking.findFirst({
where: {
startTime: dayjs(evt.startTime).toDate(),
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
uid: true,
attendees: {
include: {
bookingSeat: true,
},
},
},
});
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (!originalRescheduledBooking) {
// typescript isn't smart enough;
throw new Error("Internal Error.");
}
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
(filteredAttendees, attendee) => {
if (attendee.email === bookerEmail) {
return filteredAttendees; // skip current booker, as we know the language already.
}
filteredAttendees.push({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
});
return filteredAttendees;
},
[] as Person[]
);
// If original booking has video reference we need to add the videoCallData to the new evt
const videoReference = originalRescheduledBooking.references.find((reference) =>
reference.type.includes("_video")
);
const originalBookingEvt = {
...evt,
title: originalRescheduledBooking.title,
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
attendees: updatedBookingAttendees,
// If the location is a video integration then include the videoCallData
...(videoReference && {
videoCallData: {
type: videoReference.type,
id: videoReference.meetingId,
password: videoReference.meetingPassword,
url: videoReference.meetingUrl,
},
}),
};
if (!bookingSeat) {
// if no bookingSeat is given and the userId != owner, 401.
// TODO: Next step; Evaluate ownership, what about teams?
if (seatedBooking.user?.id !== reqUserId) {
throw new HttpError({ statusCode: 401 });
}
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
};
});
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
resultBooking = await ownerRescheduleSeatedBooking(
rescheduleSeatedBookingObject,
newTimeSlotBooking,
seatedBooking,
resultBooking,
eventManager,
loggerWithEventDetails
);
}
// seatAttendee is null when the organizer is rescheduling.
const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null;
if (seatAttendee) {
resultBooking = await attendeeRescheduleSeatedBooking(
rescheduleSeatedBookingObject,
seatAttendee,
newTimeSlotBooking,
originalBookingEvt,
eventManager
);
}
return resultBooking;
};
export default rescheduleSeatedBooking;
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
import type { Prisma } from "@prisma/client";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking";
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
export type NewSeatedBookingObject = {
rescheduleUid: string | undefined;
reqBookingUid: string | undefined;
eventType: NewBookingEventType;
evt: CalendarEvent;
invitee: Invitee;
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
organizerUser: OrganizerUser;
originalRescheduledBooking: OriginalRescheduledBooking;
bookerEmail: string;
tAttendees: TFunction;
bookingSeat: BookingSeat;
reqUserId: number | undefined;
rescheduleReason: RescheduleReason;
reqBodyUser: string | string[] | undefined;
noEmail: NoEmail;
isConfirmedByDefault: IsConfirmedByDefault;
additionalNotes: AdditionalNotes;
reqAppsStatus: ReqAppsStatus;
attendeeLanguage: string | null;
paymentAppData: PaymentAppData;
fullName: ReturnType<typeof getFullName>;
smsReminderNumber: SmsReminderNumber;
eventTypeInfo: EventTypeInfo;
uid: short.SUUID;
eventTypeId: EventTypeId;
reqBodyMetadata: ReqBodyMetadata;
subscriberOptions: GetSubscriberOptions;
eventTrigger: WebhookTriggerEvents;
};
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
export type SeatedBooking = Prisma.BookingGetPayload<{
select: {
uid: true;
id: true;
attendees: { include: { bookingSeat: true } };
userId: true;
references: true;
startTime: true;
user: true;
status: true;
smsReminderNumber: true;
endTime: true;
scheduledJobs: true;
};
}>;
export type HandleSeatsResultBooking =
| (Partial<Booking> & {
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null;
export type NewTimeSlotBooking = Prisma.BookingGetPayload<{
select: {
id: true;
uid: true;
attendees: {
include: {
bookingSeat: true;
};
};
};
}>;
export type SeatAttendee = Partial<Person>;
+5
View File
@@ -6,6 +6,11 @@ export enum ErrorCode {
AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error",
HostsUnavailableForBooking = "hosts_unavailable_for_booking",
EventTypeNotFound = "event_type_not_found_error",
BookingNotFound = "booking_not_found_error",
BookingSeatsFull = "booking_seats_full_error",
MissingPaymentCredential = "missing_payment_credential_error",
MissingPaymentAppId = "missing_payment_app_id_error",
NotEnoughAvailableSeats = "not_enough_available_seats_error",
AvailabilityNotFoundInSchedule = "availability_not_found_in_schedule_error",
CancelledBookingsCannotBeRescheduled = "cancelled_bookings_cannot_be_rescheduled",
}