Compare commits

...

46 Commits

Author SHA1 Message Date
Joe Au-Yeung 8c081fd6b1
Merge branch 'main' into refactor/handle-seats 2024-01-12 12:36:03 -05:00
Erik 6e30d9ad65
Merge branch 'main' into refactor/handle-seats 2024-01-12 08:50:39 -03:00
Joe Au-Yeung 3ea89ecc83
Merge branch 'main' into refactor/handle-seats 2024-01-10 09:48:43 -05:00
Joe Au-Yeung 818b367fb1
Merge branch 'main' into refactor/handle-seats 2024-01-05 11:07:30 -05:00
Joe Au-Yeung 25c89f2a8d Merge branch 'main' into refactor/handle-seats 2024-01-05 11:02:47 -05:00
Joe Au-Yeung 64f3a2df45
Merge branch 'main' into refactor/handle-seats 2023-12-13 14:55:21 -05:00
Joe Au-Yeung afa8d6c5c3
Merge branch 'main' into refactor/handle-seats 2023-12-13 13:20:26 -05:00
Joe Au-Yeung e48efcac47 Type fix 2023-12-13 10:50:59 -05:00
Joe Au-Yeung 0bb475b173 Merge branch 'main' into refactor/handle-seats 2023-12-13 10:41:40 -05:00
Joe Au-Yeung 1b3612dfdb Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-28 09:40:18 -05:00
Joe Au-Yeung 64f465d6ef
Merge branch 'main' into refactor/create-booking 2023-11-28 09:34:46 -05:00
Joe Au-Yeung 6ca8ab2877 Merge branch 'main' into refactor/create-booking 2023-11-27 13:16:46 -05:00
Morgan 97e998f107
Merge branch 'main' into refactor/create-booking 2023-11-27 09:58:11 +02:00
Joe Au-Yeung f7630f50db
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-24 13:05:57 -05:00
Joe Au-Yeung 343e59bd6c
Merge branch 'main' into refactor/create-booking 2023-11-24 13:05:44 -05:00
Joe Au-Yeung b9dd07b7ef Change function params from req 2023-11-24 13:02:47 -05:00
Joe Au-Yeung 7a9fca4f9c
Merge branch 'main' into refactor/create-booking 2023-11-24 11:26:19 -05:00
Joe Au-Yeung 1d96a3b8f8
Merge branch 'main' into refactor/create-booking 2023-11-21 11:09:20 -05:00
Joe Au-Yeung 9175760cd8
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-20 12:35:18 -05:00
Joe Au-Yeung 49a7617265
Merge branch 'main' into refactor/create-booking 2023-11-20 12:32:17 -05:00
Joe Au-Yeung d375e78ea2
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-17 12:42:01 -05:00
Joe Au-Yeung 8e21da36df
Merge branch 'main' into refactor/create-booking 2023-11-17 12:41:28 -05:00
Joe Au-Yeung 76905a7407
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-15 11:09:24 -05:00
Joe Au-Yeung 6a9bb9a156
Merge branch 'main' into refactor/create-booking 2023-11-15 10:20:57 -05:00
Joe Au-Yeung d80f8c0477 Typescript refactor 2023-11-13 15:38:06 -05:00
Joe Au-Yeung ffd67c5eb1
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-11-13 15:26:00 -05:00
Joe Au-Yeung bc8b1e7f45
Merge branch 'main' into refactor/create-booking 2023-11-13 14:41:45 -05:00
Joe Au-Yeung 94c988101b
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-10-30 12:12:49 -04:00
Joe Au-Yeung 776519e262
Merge branch 'main' into refactor/create-booking 2023-10-30 12:12:17 -04:00
Joe Au-Yeung 9c93891d27
Merge branch 'refactor/create-booking' into refactor/handle-seats 2023-10-26 09:54:03 -04:00
Joe Au-Yeung 4cf5e30059
Merge branch 'main' into refactor/create-booking 2023-10-26 09:53:21 -04:00
Joe Au-Yeung 593ac641b1
Merge branch 'main' into refactor/create-booking 2023-10-24 10:50:02 -04:00
Joe Au-Yeung 21b86b1e4e Set parameters for `handleSeats` 2023-10-23 17:35:21 -04:00
Joe Au-Yeung 0c54667fad Move `deleteMeeting` and `getCalendar` 2023-10-23 16:27:39 -04:00
Joe Au-Yeung d4e7ae0858
Merge branch 'main' into refactor/create-booking 2023-10-23 16:23:55 -04:00
Joe Au-Yeung b29acc01b8 Create ReqAppsStatus type 2023-10-23 15:31:32 -04:00
Joe Au-Yeung d768b6bab8 Abstract `handleAppStatus` from handler 2023-10-23 15:22:31 -04:00
Joe Au-Yeung 34e5b5d917 Abstract createLoggerWithEventDetails 2023-10-20 14:48:57 -04:00
Joe Au-Yeung d8f9855b7b Abstract addVideoCallDataToEvt 2023-10-20 14:41:48 -04:00
Joe Au-Yeung ba99a3c8da Create OrganizerUser type 2023-10-20 13:53:59 -04:00
Joe Au-Yeung 63cd63de7e Create Invitee type 2023-10-20 13:38:26 -04:00
Joe Au-Yeung ad76c74b91
Merge branch 'main' into refactor/create-booking 2023-10-19 10:29:32 -04:00
Joe Au-Yeung f02119e47f Abstract handleSeats 2023-10-18 10:24:30 -04:00
Joe Au-Yeung 010fd62c8e
Merge branch 'main' into refactor/create-booking 2023-10-18 09:56:51 -04:00
Joe Au-Yeung 1e1ceb9bf6 Type fix 2023-10-18 09:52:36 -04:00
Joe Au-Yeung 251146a61c Refactor createBooking 2023-10-17 21:53:04 -04:00
2 changed files with 964 additions and 119 deletions

View File

@ -6,6 +6,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { NextApiRequest } from "next";
import type { TFunction } from "next-i18next";
import short, { uuid } from "short-uuid";
import type { Logger } from "tslog";
import { v5 as uuidv5 } from "uuid";
@ -98,6 +99,7 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import getBookingDataSchema from "./getBookingDataSchema";
import type { BookingSeat } from "./handleSeats";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@ -105,7 +107,7 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type NewBookingEventType =
| Awaited<ReturnType<typeof getDefaultEvent>>
| Awaited<ReturnType<typeof getEventTypesFromDB>>;
@ -113,8 +115,36 @@ export type NewBookingEventType =
// Work with Typescript to require reqBody.end
type ReqBodyWithoutEnd = z.infer<ReturnType<typeof getBookingDataSchema>>;
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
export type Invitee = {
email: string;
name: string;
firstName: string;
lastName: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
}[];
export type OrganizerUser = Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
export type OriginalRescheduledBooking = Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
interface IEventTypePaymentCredentialType {
type AwaitedBookingData = Awaited<ReturnType<typeof getBookingData>>;
export type RescheduleReason = AwaitedBookingData["rescheduleReason"];
export type NoEmail = AwaitedBookingData["noEmail"];
export type AdditionalNotes = AwaitedBookingData["notes"];
export type ReqAppsStatus = AwaitedBookingData["appsStatus"];
export type SmsReminderNumber = AwaitedBookingData["smsReminderNumber"];
export type EventTypeId = AwaitedBookingData["eventTypeId"];
export type ReqBodyMetadata = ReqBodyWithEnd["metadata"];
export type IsConfirmedByDefault = ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
export type PaymentAppData = ReturnType<typeof getPaymentAppData>;
export interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
app: {
categories: App["categories"];
@ -148,7 +178,9 @@ async function refreshCredential(credential: CredentialPayload): Promise<Credent
*
* @param credentials
*/
async function refreshCredentials(credentials: Array<CredentialPayload>): Promise<Array<CredentialPayload>> {
export async function refreshCredentials(
credentials: Array<CredentialPayload>
): Promise<Array<CredentialPayload>> {
return await async.mapLimit(credentials, 5, refreshCredential);
}
@ -156,7 +188,7 @@ async function refreshCredentials(credentials: Array<CredentialPayload>): Promis
* Gets credentials from the user, team, and org if applicable
*
*/
const getAllCredentials = async (
export const getAllCredentials = async (
user: User & { credentials: CredentialPayload[] },
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>
) => {
@ -634,18 +666,18 @@ async function createBooking({
paymentAppData,
changedOrganizer,
}: {
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
originalRescheduledBooking: OriginalRescheduledBooking;
evt: CalendarEvent;
eventType: NewBookingEventType;
eventTypeId: Awaited<ReturnType<typeof getBookingData>>["eventTypeId"];
eventTypeSlug: Awaited<ReturnType<typeof getBookingData>>["eventTypeSlug"];
eventTypeId: EventTypeId;
eventTypeSlug: AwaitedBookingData["eventTypeSlug"];
reqBodyUser: ReqBodyWithEnd["user"];
reqBodyMetadata: ReqBodyWithEnd["metadata"];
reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"];
uid: short.SUUID;
responses: ReqBodyWithEnd["responses"] | null;
isConfirmedByDefault: ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
smsReminderNumber: Awaited<ReturnType<typeof getBookingData>>["smsReminderNumber"];
isConfirmedByDefault: IsConfirmedByDefault;
smsReminderNumber: AwaitedBookingData["smsReminderNumber"];
organizerUser: Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
@ -822,6 +854,76 @@ export function getCustomInputsResponses(
return customInputsResponses;
}
/** Updates the evt object with video call data found from booking references
*
* @param bookingReferences
* @param evt
*
* @returns updated evt with video call data
*/
export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
return evt;
};
export const createLoggerWithEventDetails = (
eventTypeId: number,
reqBodyUser: string | string[] | undefined,
eventTypeSlug: string | undefined
) => {
return logger.getSubLogger({
prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`],
});
};
export function handleAppsStatus(
results: EventResult<AdditionalInformation>[],
booking: (Booking & { appsStatus?: AppsStatus[] }) | null,
reqAppsStatus: ReqAppsStatus
) {
// Taking care of apps status
let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
failures: !app.success ? 1 : 0,
errors: app.calError ? [app.calError] : [],
warnings: app.calWarnings,
}));
if (reqAppsStatus === undefined) {
if (booking !== null) {
booking.appsStatus = resultStatus;
}
return resultStatus;
}
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
// proceeding to prepare the info for the event
const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => {
if (prev[curr.type]) {
prev[curr.type].success += curr.success;
prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors);
prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []);
} else {
prev[curr.type] = curr;
}
return prev;
}, {} as { [key: string]: AppsStatus });
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
}
function getICalSequence(originalRescheduledBooking: BookingType | null) {
// If new booking set the sequence to 0
if (!originalRescheduledBooking) {
@ -837,6 +939,52 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
return originalRescheduledBooking.iCalSequence + 1;
}
export const findBookingQuery = async (bookingId: number) => {
const foundBooking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
uid: true,
location: true,
startTime: true,
endTime: true,
title: true,
description: true,
status: true,
responses: true,
user: {
select: {
name: true,
email: true,
timeZone: true,
username: true,
},
},
eventType: {
select: {
title: true,
description: true,
currency: true,
length: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
price: true,
},
},
},
});
// This should never happen but it's just typescript safe
if (!foundBooking) {
throw new Error("Internal Error. Couldn't find booking");
}
// Don't leak any sensitive data
return foundBooking;
};
async function handler(
req: NextApiRequest & { userId?: number | undefined },
{
@ -884,9 +1032,7 @@ async function handler(
eventType,
});
const loggerWithEventDetails = logger.getSubLogger({
prefix: ["book:user", `${eventTypeId}:${reqBody.user}/${eventTypeSlug}`],
});
const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug);
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
logger.settings.minLevel = 0;
@ -1052,7 +1198,7 @@ async function handler(
}
let rescheduleUid = reqBody.rescheduleUid;
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
let bookingSeat: BookingSeat = null;
let originalRescheduledBooking: BookingType = null;
@ -1176,7 +1322,7 @@ async function handler(
}
}
const invitee = [
const invitee: Invitee = [
{
email: bookerEmail,
name: fullName,
@ -1201,7 +1347,7 @@ async function handler(
language: { translate: tGuests, locale: "en" },
});
return guestArray;
}, [] as typeof invitee);
}, [] as Invitee);
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
@ -1314,20 +1460,6 @@ async function handler(
evt.destinationCalendar?.push(...teamDestinationCalendars);
}
/* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
};
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
@ -1574,7 +1706,7 @@ async function handler(
},
});
addVideoCallDataToEvt(newBooking.references);
evt = addVideoCallDataToEvt(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
@ -1611,7 +1743,7 @@ async function handler(
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking);
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
@ -1719,7 +1851,7 @@ async function handler(
evt.attendees = updatedBookingAttendees;
addVideoCallDataToEvt(updatedNewBooking.references);
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
@ -2167,43 +2299,6 @@ async function handler(
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
function handleAppsStatus(
results: EventResult<AdditionalInformation>[],
booking: (Booking & { appsStatus?: AppsStatus[] }) | null
) {
// Taking care of apps status
let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
failures: !app.success ? 1 : 0,
errors: app.calError ? [app.calError] : [],
warnings: app.calWarnings,
}));
if (reqAppsStatus === undefined) {
if (booking !== null) {
booking.appsStatus = resultStatus;
}
return resultStatus;
}
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
// proceeding to prepare the info for the event
const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => {
if (prev[curr.type]) {
prev[curr.type].success += curr.success;
prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors);
prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []);
} else {
prev[curr.type] = curr;
}
return prev;
}, {} as { [key: string]: AppsStatus });
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
}
let videoCallUrl;
//this is the actual rescheduling logic
@ -2219,7 +2314,7 @@ async function handler(
);
}
addVideoCallDataToEvt(originalRescheduledBooking.references);
addVideoCallDataToEvt(originalRescheduledBooking.references, evt);
//update original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot) {
@ -2346,7 +2441,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
videoCallUrl =
metadata.hangoutLink ||
results[0].createdEvent?.url ||
@ -2361,7 +2456,7 @@ async function handler(
: calendarResult?.updatedEvent?.iCalUID || undefined;
}
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
// If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient
if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) {
@ -2516,7 +2611,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
@ -2911,49 +3006,3 @@ function handleCustomInputs(
}
});
}
const findBookingQuery = async (bookingId: number) => {
const foundBooking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
uid: true,
location: true,
startTime: true,
endTime: true,
title: true,
description: true,
status: true,
responses: true,
user: {
select: {
name: true,
email: true,
timeZone: true,
username: true,
},
},
eventType: {
select: {
title: true,
description: true,
currency: true,
length: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
price: true,
},
},
},
});
// This should never happen but it's just typescript safe
if (!foundBooking) {
throw new Error("Internal Error. Couldn't find booking");
}
// Don't leak any sensitive data
return foundBooking;
};

View File

@ -0,0 +1,796 @@
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;