From 251146a61c498dfb5bbb4b9249e4a689f89e0284 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 17 Oct 2023 21:53:04 -0400 Subject: [PATCH 01/65] Refactor createBooking --- .../bookings/lib/getBookingDataSchema.ts | 81 +++ .../features/bookings/lib/handleNewBooking.ts | 492 +++++++++--------- 2 files changed, 322 insertions(+), 251 deletions(-) create mode 100644 packages/features/bookings/lib/getBookingDataSchema.ts diff --git a/packages/features/bookings/lib/getBookingDataSchema.ts b/packages/features/bookings/lib/getBookingDataSchema.ts new file mode 100644 index 00000000000000..f57078b5bfbe64 --- /dev/null +++ b/packages/features/bookings/lib/getBookingDataSchema.ts @@ -0,0 +1,81 @@ +import type { NextApiRequest } from "next"; +import { z } from "zod"; + +import { + bookingCreateSchemaLegacyPropsForApi, + bookingCreateBodySchemaForApi, + extendedBookingCreateBody, +} from "@calcom/prisma/zod-utils"; + +import getBookingResponsesSchema from "./getBookingResponsesSchema"; +import type { getEventTypesFromDB } from "./handleNewBooking"; + +const getBookingDataSchema = ( + req: NextApiRequest, + isNotAnApiCall: boolean, + eventType: Awaited> +) => { + const responsesSchema = getBookingResponsesSchema({ + eventType: { + bookingFields: eventType.bookingFields, + }, + view: req.body.rescheduleUid ? "reschedule" : "booking", + }); + const bookingDataSchema = isNotAnApiCall + ? extendedBookingCreateBody.merge( + z.object({ + responses: responsesSchema, + }) + ) + : bookingCreateBodySchemaForApi + .merge( + z.object({ + responses: responsesSchema.optional(), + }) + ) + .superRefine((val, ctx) => { + if (val.responses && val.customInputs) { + ctx.addIssue({ + code: "custom", + message: + "Don't use both customInputs and responses. `customInputs` is only there for legacy support.", + }); + return; + } + const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape); + + if (val.responses) { + const unwantedProps: string[] = []; + legacyProps.forEach((legacyProp) => { + if (typeof val[legacyProp as keyof typeof val] !== "undefined") { + console.error( + `Deprecated: Unexpected falsy value for: ${unwantedProps.join( + "," + )}. They can't be used with \`responses\`. This will become a 400 error in the future.` + ); + } + if (val[legacyProp as keyof typeof val]) { + unwantedProps.push(legacyProp); + } + }); + if (unwantedProps.length) { + ctx.addIssue({ + code: "custom", + message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``, + }); + return; + } + } else if (val.customInputs) { + const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val); + if (!success) { + ctx.addIssue({ + code: "custom", + message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`, + }); + } + } + }); + return bookingDataSchema; +}; + +export default getBookingDataSchema; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 83886bab351805..fca4d4abf30c3d 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -74,11 +74,9 @@ import type { BookingReference } from "@calcom/prisma/client"; import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { - bookingCreateBodySchemaForApi, bookingCreateSchemaLegacyPropsForApi, customInputSchema, EventTypeMetaDataSchema, - extendedBookingCreateBody, userMetadata as userMetadataSchema, } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -93,7 +91,7 @@ import type { CredentialPayload } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; -import getBookingResponsesSchema from "./getBookingResponsesSchema"; +import getBookingDataSchema from "./getBookingDataSchema"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -101,6 +99,14 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; type BookingType = Prisma.PromiseReturnType; +type Booking = Prisma.PromiseReturnType; +export type NewBookingEventType = + | Awaited> + | Awaited>; + +// Work with Typescript to require reqBody.end +type ReqBodyWithoutEnd = z.infer>; +type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -241,7 +247,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, return false; } -const getEventTypesFromDB = async (eventTypeId: number) => { +export const getEventTypesFromDB = async (eventTypeId: number) => { const eventType = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId, @@ -359,6 +365,50 @@ type IsFixedAwareUser = User & { organization: { slug: string }; }; +const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string[], req: NextApiRequest) => { + try { + if (!eventType.id) { + if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { + throw new Error("dynamicUserList is not properly defined or empty."); + } + + const users = await prisma.user.findMany({ + where: { + username: { in: dynamicUserList }, + organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""), + }, + select: { + ...userSelect.select, + credentials: { + select: credentialForCalendarServiceSelect, + }, + metadata: true, + }, + }); + + return users; + } else { + const hosts = eventType.hosts || []; + + if (!Array.isArray(hosts)) { + throw new Error("eventType.hosts is not properly defined."); + } + + const users = hosts.map(({ user, isFixed }) => ({ + ...user, + isFixed, + })); + + return users.length ? users : eventType.users; + } + } catch (error) { + if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { + throw new HttpError({ statusCode: 400, message: error.message }); + } + throw new HttpError({ statusCode: 500, message: "Unable to load users" }); + } +}; + async function ensureAvailableUsers( eventType: Awaited> & { users: IsFixedAwareUser[]; @@ -381,6 +431,7 @@ async function ensureAvailableUsers( /** Let's start checking for availability */ for (const user of eventType.users) { + console.log("🚀 ~ file: handleNewBooking.ts:434 ~ user:", user); const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { userId: user.id, @@ -482,73 +533,10 @@ async function getBookingData({ isNotAnApiCall: boolean; eventType: Awaited>; }) { - const responsesSchema = getBookingResponsesSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, - view: req.body.rescheduleUid ? "reschedule" : "booking", - }); - const bookingDataSchema = isNotAnApiCall - ? extendedBookingCreateBody.merge( - z.object({ - responses: responsesSchema, - }) - ) - : bookingCreateBodySchemaForApi - .merge( - z.object({ - responses: responsesSchema.optional(), - }) - ) - .superRefine((val, ctx) => { - if (val.responses && val.customInputs) { - ctx.addIssue({ - code: "custom", - message: - "Don't use both customInputs and responses. `customInputs` is only there for legacy support.", - }); - return; - } - const legacyProps = Object.keys(bookingCreateSchemaLegacyPropsForApi.shape); - - if (val.responses) { - const unwantedProps: string[] = []; - legacyProps.forEach((legacyProp) => { - if (typeof val[legacyProp as keyof typeof val] !== "undefined") { - console.error( - `Deprecated: Unexpected falsy value for: ${unwantedProps.join( - "," - )}. They can't be used with \`responses\`. This will become a 400 error in the future.` - ); - } - if (val[legacyProp as keyof typeof val]) { - unwantedProps.push(legacyProp); - } - }); - if (unwantedProps.length) { - ctx.addIssue({ - code: "custom", - message: `Legacy Props: ${unwantedProps.join(",")}. They can't be used with \`responses\``, - }); - return; - } - } else if (val.customInputs) { - const { success } = bookingCreateSchemaLegacyPropsForApi.safeParse(val); - if (!success) { - ctx.addIssue({ - code: "custom", - message: `With \`customInputs\` you must specify legacy props ${legacyProps.join(",")}`, - }); - } - } - }); + const bookingDataSchema = getBookingDataSchema(req, isNotAnApiCall, eventType); const reqBody = await bookingDataSchema.parseAsync(req.body); - // Work with Typescript to require reqBody.end - type ReqBodyWithoutEnd = z.infer; - type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; - const reqBodyWithEnd = (reqBody: ReqBodyWithoutEnd): reqBody is ReqBodyWithEnd => { // Use the event length to auto-set the event end time. if (!Object.prototype.hasOwnProperty.call(reqBody, "end")) { @@ -602,6 +590,174 @@ async function getBookingData({ } } +async function createBooking({ + originalRescheduledBooking, + evt, + eventTypeId, + eventTypeSlug, + reqBody, + uid, + responses, + isConfirmedByDefault, + smsReminderNumber, + organizerUser, + rescheduleReason, + eventType, + bookerEmail, + paymentAppData, +}: { + originalRescheduledBooking: Awaited>; + evt: CalendarEvent; + eventType: NewBookingEventType; + eventTypeId: Awaited>["eventTypeId"]; + eventTypeSlug: Awaited>["eventTypeSlug"]; + reqBody: ReqBodyWithEnd; + uid: short.SUUID; + responses: ReqBodyWithEnd["responses"] | null; + isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; + smsReminderNumber: Awaited>["smsReminderNumber"]; + organizerUser: Awaited>[number] & { + isFixed?: boolean; + metadata?: Prisma.JsonValue; + }; + rescheduleReason: Awaited>["rescheduleReason"]; + bookerEmail: Awaited>["email"]; + paymentAppData: ReturnType; +}) { + if (originalRescheduledBooking) { + evt.title = originalRescheduledBooking?.title || evt.title; + evt.description = originalRescheduledBooking?.description || evt.description; + evt.location = originalRescheduledBooking?.location || evt.location; + } + + const eventTypeRel = !eventTypeId + ? {} + : { + connect: { + id: eventTypeId, + }, + }; + + const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; + const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; + + const attendeesData = evt.attendees.map((attendee) => { + //if attendee is team member, it should fetch their locale not booker's locale + //perhaps make email fetch request to see if his locale is stored, else + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + locale: attendee.language.locale, + }; + }); + + if (evt.team?.members) { + attendeesData.push( + ...evt.team.members.map((member) => ({ + email: member.email, + name: member.name, + timeZone: member.timeZone, + locale: member.language.locale, + })) + ); + } + + const newBookingData: Prisma.BookingCreateInput = { + uid, + responses: responses === null ? Prisma.JsonNull : responses, + title: evt.title, + startTime: dayjs.utc(evt.startTime).toDate(), + endTime: dayjs.utc(evt.endTime).toDate(), + description: evt.additionalNotes, + customInputs: isPrismaObjOrUndefined(evt.customInputs), + status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING, + location: evt.location, + eventType: eventTypeRel, + smsReminderNumber, + metadata: reqBody.metadata, + attendees: { + createMany: { + data: attendeesData, + }, + }, + dynamicEventSlugRef, + dynamicGroupSlugRef, + user: { + connect: { + id: organizerUser.id, + }, + }, + destinationCalendar: + evt.destinationCalendar && evt.destinationCalendar.length > 0 + ? { + connect: { id: evt.destinationCalendar[0].id }, + } + : undefined, + }; + + if (reqBody.recurringEventId) { + newBookingData.recurringEventId = reqBody.recurringEventId; + } + if (originalRescheduledBooking) { + newBookingData.metadata = { + ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), + }; + newBookingData["paid"] = originalRescheduledBooking.paid; + newBookingData["fromReschedule"] = originalRescheduledBooking.uid; + if (originalRescheduledBooking.uid) { + newBookingData.cancellationReason = rescheduleReason; + } + if (newBookingData.attendees?.createMany?.data) { + // Reschedule logic with booking with seats + if (eventType?.seatsPerTimeSlot && bookerEmail) { + newBookingData.attendees.createMany.data = attendeesData.filter( + (attendee) => attendee.email === bookerEmail + ); + } + } + if (originalRescheduledBooking.recurringEventId) { + newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; + } + } + const createBookingObj = { + include: { + user: { + select: { email: true, name: true, timeZone: true, username: true }, + }, + attendees: true, + payment: true, + references: true, + }, + data: newBookingData, + }; + + if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) { + const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success); + + if (bookingPayment) { + createBookingObj.data.payment = { + connect: { id: bookingPayment.id }, + }; + } + } + + if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) { + /* Validate if there is any payment app credential for this user */ + await prisma.credential.findFirstOrThrow({ + where: { + appId: paymentAppData.appId, + ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), + }, + select: { + id: true, + }, + }); + } + + return prisma.booking.create(createBookingObj); +} + function getCustomInputsResponses( reqBody: { responses?: Record; @@ -760,54 +916,11 @@ async function handler( throw new HttpError({ statusCode: 400, message: error.message }); } - const loadUsers = async () => { - try { - if (!eventTypeId) { - if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { - throw new Error("dynamicUserList is not properly defined or empty."); - } - - const users = await prisma.user.findMany({ - where: { - username: { in: dynamicUserList }, - organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""), - }, - select: { - ...userSelect.select, - credentials: { - select: credentialForCalendarServiceSelect, - }, - metadata: true, - }, - }); - - return users; - } else { - const hosts = eventType.hosts || []; - - if (!Array.isArray(hosts)) { - throw new Error("eventType.hosts is not properly defined."); - } - - const users = hosts.map(({ user, isFixed }) => ({ - ...user, - isFixed, - })); - - return users.length ? users : eventType.users; - } - } catch (error) { - if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) { - throw new HttpError({ statusCode: 400, message: error.message }); - } - throw new HttpError({ statusCode: 500, message: "Unable to load users" }); - } - }; // loadUsers allows type inferring let users: (Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; - })[] = await loadUsers(); + })[] = await loadUsers(eventType, dynamicUserList, req); const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); if (!isDynamicAllowed && !eventTypeId) { @@ -1890,147 +2003,9 @@ async function handler( evt.recurringEvent = eventType.recurringEvent; } - async function createBooking() { - if (originalRescheduledBooking) { - evt.title = originalRescheduledBooking?.title || evt.title; - evt.description = originalRescheduledBooking?.description || evt.description; - evt.location = originalRescheduledBooking?.location || evt.location; - } - - const eventTypeRel = !eventTypeId - ? {} - : { - connect: { - id: eventTypeId, - }, - }; - - const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; - const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; - - const attendeesData = evt.attendees.map((attendee) => { - //if attendee is team member, it should fetch their locale not booker's locale - //perhaps make email fetch request to see if his locale is stored, else - return { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - locale: attendee.language.locale, - }; - }); - - if (evt.team?.members) { - attendeesData.push( - ...evt.team.members.map((member) => ({ - email: member.email, - name: member.name, - timeZone: member.timeZone, - locale: member.language.locale, - })) - ); - } - - const newBookingData: Prisma.BookingCreateInput = { - uid, - responses: responses === null ? Prisma.JsonNull : responses, - title: evt.title, - startTime: dayjs.utc(evt.startTime).toDate(), - endTime: dayjs.utc(evt.endTime).toDate(), - description: evt.additionalNotes, - customInputs: isPrismaObjOrUndefined(evt.customInputs), - status: isConfirmedByDefault ? BookingStatus.ACCEPTED : BookingStatus.PENDING, - location: evt.location, - eventType: eventTypeRel, - smsReminderNumber, - metadata: reqBody.metadata, - attendees: { - createMany: { - data: attendeesData, - }, - }, - dynamicEventSlugRef, - dynamicGroupSlugRef, - user: { - connect: { - id: organizerUser.id, - }, - }, - destinationCalendar: - evt.destinationCalendar && evt.destinationCalendar.length > 0 - ? { - connect: { id: evt.destinationCalendar[0].id }, - } - : undefined, - }; - - if (reqBody.recurringEventId) { - newBookingData.recurringEventId = reqBody.recurringEventId; - } - if (originalRescheduledBooking) { - newBookingData.metadata = { - ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), - }; - newBookingData["paid"] = originalRescheduledBooking.paid; - newBookingData["fromReschedule"] = originalRescheduledBooking.uid; - if (originalRescheduledBooking.uid) { - newBookingData.cancellationReason = rescheduleReason; - } - if (newBookingData.attendees?.createMany?.data) { - // Reschedule logic with booking with seats - if (eventType?.seatsPerTimeSlot && bookerEmail) { - newBookingData.attendees.createMany.data = attendeesData.filter( - (attendee) => attendee.email === bookerEmail - ); - } - } - if (originalRescheduledBooking.recurringEventId) { - newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; - } - } - const createBookingObj = { - include: { - user: { - select: { email: true, name: true, timeZone: true, username: true }, - }, - attendees: true, - payment: true, - references: true, - }, - data: newBookingData, - }; - - if (originalRescheduledBooking?.paid && originalRescheduledBooking?.payment) { - const bookingPayment = originalRescheduledBooking?.payment?.find((payment) => payment.success); - - if (bookingPayment) { - createBookingObj.data.payment = { - connect: { id: bookingPayment.id }, - }; - } - } - - if (typeof paymentAppData.price === "number" && paymentAppData.price > 0) { - /* Validate if there is any payment app credential for this user */ - await prisma.credential.findFirstOrThrow({ - where: { - appId: paymentAppData.appId, - ...(paymentAppData.credentialId - ? { id: paymentAppData.credentialId } - : { userId: organizerUser.id }), - }, - select: { - id: true, - }, - }); - } - - return prisma.booking.create(createBookingObj); - } - let results: EventResult[] = []; let referencesToCreate: PartialReference[] = []; - type Booking = Prisma.PromiseReturnType; let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null; loggerWithEventDetails.debug( "Going to create booking in DB now", @@ -2044,7 +2019,22 @@ async function handler( ); try { - booking = await createBooking(); + booking = await createBooking({ + originalRescheduledBooking, + evt, + eventTypeId, + eventTypeSlug, + reqBody, + uid, + responses, + isConfirmedByDefault, + smsReminderNumber, + organizerUser, + rescheduleReason, + eventType, + bookerEmail, + paymentAppData, + }); // @NOTE: Add specific try catch for all subsequent async calls to avoid error // Sync Services From 1e1ceb9bf6a65b379daaf9bca961ad996b8ffa4f Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 18 Oct 2023 09:52:36 -0400 Subject: [PATCH 02/65] Type fix --- .../features/bookings/lib/handleNewBooking.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index fca4d4abf30c3d..28028528f00ff7 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -431,7 +431,6 @@ async function ensureAvailableUsers( /** Let's start checking for availability */ for (const user of eventType.users) { - console.log("🚀 ~ file: handleNewBooking.ts:434 ~ user:", user); const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { userId: user.id, @@ -595,7 +594,9 @@ async function createBooking({ evt, eventTypeId, eventTypeSlug, - reqBody, + reqBodyUser, + reqBodyMetadata, + reqBodyRecurringEventId, uid, responses, isConfirmedByDefault, @@ -611,7 +612,9 @@ async function createBooking({ eventType: NewBookingEventType; eventTypeId: Awaited>["eventTypeId"]; eventTypeSlug: Awaited>["eventTypeSlug"]; - reqBody: ReqBodyWithEnd; + reqBodyUser: ReqBodyWithEnd["user"]; + reqBodyMetadata: ReqBodyWithEnd["metadata"]; + reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; @@ -639,7 +642,7 @@ async function createBooking({ }; const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; - const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; + const dynamicGroupSlugRef = !eventTypeId ? (reqBodyUser as string).toLowerCase() : null; const attendeesData = evt.attendees.map((attendee) => { //if attendee is team member, it should fetch their locale not booker's locale @@ -675,7 +678,7 @@ async function createBooking({ location: evt.location, eventType: eventTypeRel, smsReminderNumber, - metadata: reqBody.metadata, + metadata: reqBodyMetadata, attendees: { createMany: { data: attendeesData, @@ -696,8 +699,8 @@ async function createBooking({ : undefined, }; - if (reqBody.recurringEventId) { - newBookingData.recurringEventId = reqBody.recurringEventId; + if (reqBodyRecurringEventId) { + newBookingData.recurringEventId = reqBodyRecurringEventId; } if (originalRescheduledBooking) { newBookingData.metadata = { @@ -2024,7 +2027,9 @@ async function handler( evt, eventTypeId, eventTypeSlug, - reqBody, + reqBodyUser: reqBody.user, + reqBodyMetadata: reqBody.metadata, + reqBodyRecurringEventId: reqBody.recurringEventId, uid, responses, isConfirmedByDefault, From f02119e47f7bb9848a329294b37ac496e9cfd3ed Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 18 Oct 2023 10:12:04 -0400 Subject: [PATCH 03/65] Abstract handleSeats --- .../features/bookings/lib/handleNewBooking.ts | 2 +- packages/features/bookings/lib/handleSeats.ts | 630 ++++++++++++++++++ 2 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 packages/features/bookings/lib/handleSeats.ts diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 28028528f00ff7..b92894c04cc303 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -99,7 +99,7 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; type BookingType = Prisma.PromiseReturnType; -type Booking = Prisma.PromiseReturnType; +export type Booking = Prisma.PromiseReturnType; export type NewBookingEventType = | Awaited> | Awaited>; diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts new file mode 100644 index 00000000000000..b6929150a3ab76 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats.ts @@ -0,0 +1,630 @@ +import type { AppsStatus } from "@calcom/types/Calendar"; + +import type { Booking } from "./handleNewBooking"; + +const handleSeats = async () => { + let resultBooking: + | (Partial & { + 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: "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 !== 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, + }, + }); + + addVideoCallDataToEvt(newBooking.references); + + 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); + } + } + } + + 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; + + addVideoCallDataToEvt(updatedNewBooking.references); + + 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 | 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: 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, ...reqBody.metadata }, + eventTypeId, + status: "ACCEPTED", + smsReminderNumber: booking?.smsReminderNumber || undefined, + }; + + await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); + + return resultBooking; +}; From 63cd63de7ed052e5059f1d71a139b1f669b0bd84 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 20 Oct 2023 13:38:26 -0400 Subject: [PATCH 04/65] Create Invitee type --- .../features/bookings/lib/handleNewBooking.ts | 16 +++++++++-- packages/features/bookings/lib/handleSeats.ts | 28 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index b92894c04cc303..38dbaed05512f5 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -5,6 +5,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 { v5 as uuidv5 } from "uuid"; import z from "zod"; @@ -107,6 +108,17 @@ export type NewBookingEventType = // Work with Typescript to require reqBody.end type ReqBodyWithoutEnd = z.infer>; type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string }; +export type Invitee = { + email: string; + name: string; + firstName: string; + lastName: string; + timeZone: string; + language: { + translate: TFunction; + locale: string; + }; +}[]; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -1128,7 +1140,7 @@ async function handler( } } - const invitee = [ + const invitee: Invitee = [ { email: bookerEmail, name: fullName, @@ -1153,7 +1165,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)); diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index b6929150a3ab76..5abb7521734f35 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -1,8 +1,24 @@ -import type { AppsStatus } from "@calcom/types/Calendar"; - -import type { Booking } from "./handleNewBooking"; - -const handleSeats = async () => { +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 { AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; + +import type { Booking, Invitee, NewBookingEventType } from "./handleNewBooking"; + +const handleSeats = async ({ + rescheduleUid, + reqBookingUid, + eventType, + evt, + invitee, +}: { + rescheduleUid: string; + reqBookingUid: string; + eventType: NewBookingEventType; + evt: CalendarEvent; + invitee: Invitee; +}) => { let resultBooking: | (Partial & { appsStatus?: AppsStatus[]; @@ -17,7 +33,7 @@ const handleSeats = async () => { where: { OR: [ { - uid: rescheduleUid || reqBody.bookingUid, + uid: rescheduleUid || reqBookingUid, }, { eventTypeId: eventType.id, From ba99a3c8da93dc0373daefeced27ea3d6e23ff3a Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 20 Oct 2023 13:53:59 -0400 Subject: [PATCH 05/65] Create OrganizerUser type --- .../features/bookings/lib/handleNewBooking.ts | 18 +++++++++++------- packages/features/bookings/lib/handleSeats.ts | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 38dbaed05512f5..6e819bd03058bf 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -119,6 +119,11 @@ export type Invitee = { locale: string; }; }[]; +export type OrganizerUser = Awaited>[number] & { + isFixed?: boolean; + metadata?: Prisma.JsonValue; +}; +export type OriginalRescheduledBooking = Awaited>; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -154,7 +159,9 @@ async function refreshCredential(credential: CredentialPayload): Promise): Promise> { +export async function refreshCredentials( + credentials: Array +): Promise> { return await async.mapLimit(credentials, 5, refreshCredential); } @@ -162,7 +169,7 @@ async function refreshCredentials(credentials: Array): Promis * Gets credentials from the user, team, and org if applicable * */ -const getAllCredentials = async ( +export const getAllCredentials = async ( user: User & { credentials: CredentialPayload[] }, eventType: Awaited> ) => { @@ -619,7 +626,7 @@ async function createBooking({ bookerEmail, paymentAppData, }: { - originalRescheduledBooking: Awaited>; + originalRescheduledBooking: OriginalRescheduledBooking; evt: CalendarEvent; eventType: NewBookingEventType; eventTypeId: Awaited>["eventTypeId"]; @@ -631,10 +638,7 @@ async function createBooking({ responses: ReqBodyWithEnd["responses"] | null; isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; smsReminderNumber: Awaited>["smsReminderNumber"]; - organizerUser: Awaited>[number] & { - isFixed?: boolean; - metadata?: Prisma.JsonValue; - }; + organizerUser: OrganizerUser; rescheduleReason: Awaited>["rescheduleReason"]; bookerEmail: Awaited>["email"]; paymentAppData: ReturnType; diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index 5abb7521734f35..e93a64ff530266 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -1,10 +1,19 @@ +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 { AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; -import type { Booking, Invitee, NewBookingEventType } from "./handleNewBooking"; +import { refreshCredentials } from "./handleNewBooking"; +import type { + Booking, + Invitee, + NewBookingEventType, + getAllCredentials, + OrganizerUser, + OriginalRescheduledBooking, +} from "./handleNewBooking"; const handleSeats = async ({ rescheduleUid, @@ -12,12 +21,18 @@ const handleSeats = async ({ eventType, evt, invitee, + allCredentials, + organizerUser, + originalRescheduledBooking, }: { rescheduleUid: string; reqBookingUid: string; eventType: NewBookingEventType; evt: CalendarEvent; invitee: Invitee; + allCredentials: Awaited>; + organizerUser: OrganizerUser; + originalRescheduledBooking: OriginalRescheduledBooking; }) => { let resultBooking: | (Partial & { From d8f9855b7b9e8fc23aa3f79844ea20d5546e6947 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 20 Oct 2023 14:41:48 -0400 Subject: [PATCH 06/65] Abstract addVideoCallDataToEvt --- .../features/bookings/lib/handleNewBooking.ts | 48 +++++++++++-------- packages/features/bookings/lib/handleSeats.ts | 28 +++++++++-- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 6e819bd03058bf..bde24be16161c8 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -93,6 +93,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"] }); @@ -124,6 +125,7 @@ export type OrganizerUser = Awaited>[number] & { metadata?: Prisma.JsonValue; }; export type OriginalRescheduledBooking = Awaited>; +export type RescheduleReason = Awaited>["rescheduleReason"]; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -639,7 +641,7 @@ async function createBooking({ isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; smsReminderNumber: Awaited>["smsReminderNumber"]; organizerUser: OrganizerUser; - rescheduleReason: Awaited>["rescheduleReason"]; + rescheduleReason: RescheduleReason; bookerEmail: Awaited>["email"]; paymentAppData: ReturnType; }) { @@ -808,6 +810,28 @@ 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; +}; + async function handler( req: NextApiRequest & { userId?: number | undefined }, { @@ -1021,7 +1045,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; @@ -1272,20 +1296,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 ( @@ -1526,7 +1536,7 @@ async function handler( }, }); - addVideoCallDataToEvt(newBooking.references); + evt = addVideoCallDataToEvt(newBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -1671,7 +1681,7 @@ async function handler( evt.attendees = updatedBookingAttendees; - addVideoCallDataToEvt(updatedNewBooking.references); + evt = addVideoCallDataToEvt(updatedNewBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -2163,7 +2173,7 @@ async function handler( } // Use EventManager to conditionally use all needed integrations. - addVideoCallDataToEvt(originalRescheduledBooking.references); + evt = addVideoCallDataToEvt(originalRescheduledBooking.references, evt); const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid); //update original rescheduled booking (no seats event) diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index e93a64ff530266..18ba35c811ec26 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -1,11 +1,14 @@ +import type { Prisma } from "@prisma/client"; +import type { TFunction } from "next-i18next"; + 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 { AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; +import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; -import { refreshCredentials } from "./handleNewBooking"; +import { refreshCredentials, addVideoCallDataToEvt } from "./handleNewBooking"; import type { Booking, Invitee, @@ -13,8 +16,11 @@ import type { getAllCredentials, OrganizerUser, OriginalRescheduledBooking, + RescheduleReason, } from "./handleNewBooking"; +export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; + const handleSeats = async ({ rescheduleUid, reqBookingUid, @@ -24,6 +30,11 @@ const handleSeats = async ({ allCredentials, organizerUser, originalRescheduledBooking, + bookerEmail, + tAttendees, + bookingSeat, + reqUserId, + rescheduleReason, }: { rescheduleUid: string; reqBookingUid: string; @@ -33,6 +44,11 @@ const handleSeats = async ({ allCredentials: Awaited>; organizerUser: OrganizerUser; originalRescheduledBooking: OriginalRescheduledBooking; + bookerEmail: string; + tAttendees: TFunction; + bookingSeat: BookingSeat; + reqUserId: number | undefined; + rescheduleReason: RescheduleReason; }) => { let resultBooking: | (Partial & { @@ -153,7 +169,7 @@ const handleSeats = async ({ 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) { + if (booking.user?.id !== reqUserId) { throw new HttpError({ statusCode: 401 }); } @@ -189,7 +205,7 @@ const handleSeats = async ({ }, }); - addVideoCallDataToEvt(newBooking.references); + evt = addVideoCallDataToEvt(newBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -334,7 +350,7 @@ const handleSeats = async ({ evt.attendees = updatedBookingAttendees; - addVideoCallDataToEvt(updatedNewBooking.references); + evt = addVideoCallDataToEvt(updatedNewBooking.references, evt); const copyEvent = cloneDeep(evt); @@ -659,3 +675,5 @@ const handleSeats = async ({ return resultBooking; }; + +export default handleSeats; From 34e5b5d9178871a713453d96dd821c2fb15162f2 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 20 Oct 2023 14:48:57 -0400 Subject: [PATCH 07/65] Abstract createLoggerWithEventDetails --- packages/features/bookings/lib/handleNewBooking.ts | 14 +++++++++++--- packages/features/bookings/lib/handleSeats.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index bde24be16161c8..7715ecf2869013 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -832,6 +832,16 @@ export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt return evt; }; +export const createLoggerWithEventDetails = ( + eventTypeId: number, + reqBodyUser: string | string[] | undefined, + eventTypeSlug: string | undefined +) => { + return logger.getSubLogger({ + prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`], + }); +}; + async function handler( req: NextApiRequest & { userId?: number | undefined }, { @@ -884,9 +894,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; diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index 18ba35c811ec26..be3b018f97c4c2 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -1,4 +1,6 @@ import type { Prisma } from "@prisma/client"; +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; import EventManager from "@calcom/core/EventManager"; @@ -8,7 +10,7 @@ import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; -import { refreshCredentials, addVideoCallDataToEvt } from "./handleNewBooking"; +import { refreshCredentials, addVideoCallDataToEvt, createLoggerWithEventDetails } from "./handleNewBooking"; import type { Booking, Invitee, @@ -35,6 +37,7 @@ const handleSeats = async ({ bookingSeat, reqUserId, rescheduleReason, + reqBodyUser, }: { rescheduleUid: string; reqBookingUid: string; @@ -49,6 +52,7 @@ const handleSeats = async ({ bookingSeat: BookingSeat; reqUserId: number | undefined; rescheduleReason: RescheduleReason; + reqBodyUser: string | string[] | undefined; }) => { let resultBooking: | (Partial & { @@ -222,6 +226,12 @@ const handleSeats = async ({ evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined; + const loggerWithEventDetails = createLoggerWithEventDetails( + eventType.id, + reqBodyUser, + eventType.slug + ); + if (results.length > 0 && results.some((res) => !res.success)) { const error = { errorCode: "BookingReschedulingMeetingFailed", From d768b6bab8ccdb32e51da65bd1b5964152d388f0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 23 Oct 2023 15:22:31 -0400 Subject: [PATCH 08/65] Abstract `handleAppStatus` from handler --- .../features/bookings/lib/handleNewBooking.ts | 78 ++++++++++--------- packages/features/bookings/lib/handleSeats.ts | 13 +++- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7715ecf2869013..7e555722635541 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -126,6 +126,8 @@ export type OrganizerUser = Awaited>[number] & { }; export type OriginalRescheduledBooking = Awaited>; export type RescheduleReason = Awaited>["rescheduleReason"]; +export type NoEmail = Awaited>["noEmail"]; +export type IsConfirmedByDefault = ReturnType["isConfirmedByDefault"]; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -638,7 +640,7 @@ async function createBooking({ reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; - isConfirmedByDefault: ReturnType["isConfirmedByDefault"]; + isConfirmedByDefault: IsConfirmedByDefault; smsReminderNumber: Awaited>["smsReminderNumber"]; organizerUser: OrganizerUser; rescheduleReason: RescheduleReason; @@ -842,6 +844,43 @@ export const createLoggerWithEventDetails = ( }); }; +export function handleAppsStatus( + results: EventResult[], + 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; +} + async function handler( req: NextApiRequest & { userId?: number | undefined }, { @@ -2129,43 +2168,6 @@ async function handler( const credentials = await refreshCredentials(allCredentials); const eventManager = new EventManager({ ...organizerUser, credentials }); - function handleAppsStatus( - results: EventResult[], - 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; if (originalRescheduledBooking?.uid) { diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index be3b018f97c4c2..84f4126e2b881b 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -10,7 +10,12 @@ import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; -import { refreshCredentials, addVideoCallDataToEvt, createLoggerWithEventDetails } from "./handleNewBooking"; +import { + refreshCredentials, + addVideoCallDataToEvt, + createLoggerWithEventDetails, + handleAppsStatus, +} from "./handleNewBooking"; import type { Booking, Invitee, @@ -19,6 +24,8 @@ import type { OrganizerUser, OriginalRescheduledBooking, RescheduleReason, + NoEmail, + IsConfirmedByDefault, } from "./handleNewBooking"; export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; @@ -38,6 +45,8 @@ const handleSeats = async ({ reqUserId, rescheduleReason, reqBodyUser, + noEmail, + isConfirmedByDefault, }: { rescheduleUid: string; reqBookingUid: string; @@ -53,6 +62,8 @@ const handleSeats = async ({ reqUserId: number | undefined; rescheduleReason: RescheduleReason; reqBodyUser: string | string[] | undefined; + noEmail: NoEmail; + isConfirmedByDefault: IsConfirmedByDefault; }) => { let resultBooking: | (Partial & { From b29acc01b8367ba9487e2cd740fd428b0d2bb415 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 23 Oct 2023 15:30:51 -0400 Subject: [PATCH 09/65] Create ReqAppsStatus type --- packages/features/bookings/lib/handleNewBooking.ts | 11 +++++++---- packages/features/bookings/lib/handleSeats.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7e555722635541..51317661241a46 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -128,6 +128,8 @@ export type OriginalRescheduledBooking = Awaited>["rescheduleReason"]; export type NoEmail = Awaited>["noEmail"]; export type IsConfirmedByDefault = ReturnType["isConfirmedByDefault"]; +export type AdditionalNotes = Awaited>["notes"]; +export type ReqAppsStatus = Awaited>["appsStatus"]; interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -846,7 +848,8 @@ export const createLoggerWithEventDetails = ( export function handleAppsStatus( results: EventResult[], - booking: (Booking & { appsStatus?: AppsStatus[] }) | null + booking: (Booking & { appsStatus?: AppsStatus[] }) | null, + reqAppsStatus: ReqAppsStatus ) { // Taking care of apps status let resultStatus: AppsStatus[] = results.map((app) => ({ @@ -1620,7 +1623,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); } } } @@ -2229,7 +2232,7 @@ async function handler( }); videoCallUrl = _videoCallUrl; - 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) { @@ -2325,7 +2328,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; } diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index 84f4126e2b881b..8c43023f72bcd0 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -5,6 +5,7 @@ import type { TFunction } from "next-i18next"; import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; +import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -26,6 +27,8 @@ import type { RescheduleReason, NoEmail, IsConfirmedByDefault, + AdditionalNotes, + ReqAppsStatus, } from "./handleNewBooking"; export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; @@ -47,6 +50,8 @@ const handleSeats = async ({ reqBodyUser, noEmail, isConfirmedByDefault, + additionalNotes, + reqAppsStatus, }: { rescheduleUid: string; reqBookingUid: string; @@ -64,6 +69,8 @@ const handleSeats = async ({ reqBodyUser: string | string[] | undefined; noEmail: NoEmail; isConfirmedByDefault: IsConfirmedByDefault; + additionalNotes: AdditionalNotes; + reqAppsStatus: ReqAppsStatus; }) => { let resultBooking: | (Partial & { @@ -263,7 +270,7 @@ const handleSeats = async ({ metadata.hangoutLink = updatedEvent.hangoutLink; metadata.conferenceData = updatedEvent.conferenceData; metadata.entryPoints = updatedEvent.entryPoints; - evt.appsStatus = handleAppsStatus(results, newBooking); + evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus); } } } From 0c54667fadeddb5dcef39df1315563c5b5719bc2 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 23 Oct 2023 16:27:39 -0400 Subject: [PATCH 10/65] Move `deleteMeeting` and `getCalendar` --- .../features/bookings/lib/handleNewBooking.ts | 2 +- packages/features/bookings/lib/handleSeats.ts | 68 +++++++++++++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 51317661241a46..b6013fa443da63 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -2690,7 +2690,7 @@ function handleCustomInputs( }); } -const findBookingQuery = async (bookingId: number) => { +export const findBookingQuery = async (bookingId: number) => { const foundBooking = await prisma.booking.findUnique({ where: { id: bookingId, diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index 8c43023f72bcd0..c4b43df899a93d 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -1,14 +1,18 @@ -import type { Prisma } from "@prisma/client"; +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 { 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 { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; import { @@ -16,6 +20,7 @@ import { addVideoCallDataToEvt, createLoggerWithEventDetails, handleAppsStatus, + findBookingQuery, } from "./handleNewBooking"; import type { Booking, @@ -33,6 +38,57 @@ import type { 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[], + 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, @@ -52,6 +108,7 @@ const handleSeats = async ({ isConfirmedByDefault, additionalNotes, reqAppsStatus, + attendeeLanguage, }: { rescheduleUid: string; reqBookingUid: string; @@ -71,7 +128,10 @@ const handleSeats = async ({ isConfirmedByDefault: IsConfirmedByDefault; additionalNotes: AdditionalNotes; reqAppsStatus: ReqAppsStatus; + attendeeLanguage: string | null; }) => { + const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); + let resultBooking: | (Partial & { appsStatus?: AppsStatus[]; @@ -244,12 +304,6 @@ const handleSeats = async ({ evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined; - const loggerWithEventDetails = createLoggerWithEventDetails( - eventType.id, - reqBodyUser, - eventType.slug - ); - if (results.length > 0 && results.some((res) => !res.success)) { const error = { errorCode: "BookingReschedulingMeetingFailed", From 21b86b1e4e8a0f1a0ba8fc3db22e68e4e1b000a4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 23 Oct 2023 17:35:21 -0400 Subject: [PATCH 11/65] Set parameters for `handleSeats` --- .../features/bookings/lib/handleNewBooking.ts | 10 +++-- packages/features/bookings/lib/handleSeats.ts | 39 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index b6013fa443da63..6ac88fa185d9db 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -130,8 +130,12 @@ export type NoEmail = Awaited>["noEmail"]; export type IsConfirmedByDefault = ReturnType["isConfirmedByDefault"]; export type AdditionalNotes = Awaited>["notes"]; export type ReqAppsStatus = Awaited>["appsStatus"]; +export type PaymentAppData = ReturnType; +export type SmsReminderNumber = Awaited>["smsReminderNumber"]; +export type EventTypeId = Awaited>["eventTypeId"]; +export type ReqBodyMetadata = ReqBodyWithEnd["metadata"]; -interface IEventTypePaymentCredentialType { +export interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; app: { categories: App["categories"]; @@ -635,10 +639,10 @@ async function createBooking({ originalRescheduledBooking: OriginalRescheduledBooking; evt: CalendarEvent; eventType: NewBookingEventType; - eventTypeId: Awaited>["eventTypeId"]; + eventTypeId: EventTypeId; eventTypeSlug: Awaited>["eventTypeSlug"]; reqBodyUser: ReqBodyWithEnd["user"]; - reqBodyMetadata: ReqBodyWithEnd["metadata"]; + reqBodyMetadata: ReqBodyMetadata; reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts index c4b43df899a93d..9f993ebd658183 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats.ts @@ -2,6 +2,7 @@ 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"; @@ -9,12 +10,23 @@ 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, @@ -34,6 +46,11 @@ import type { IsConfirmedByDefault, AdditionalNotes, ReqAppsStatus, + PaymentAppData, + IEventTypePaymentCredentialType, + SmsReminderNumber, + EventTypeId, + ReqBodyMetadata, } from "./handleNewBooking"; export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; @@ -109,6 +126,15 @@ const handleSeats = async ({ additionalNotes, reqAppsStatus, attendeeLanguage, + paymentAppData, + fullName, + smsReminderNumber, + eventTypeInfo, + uid, + eventTypeId, + reqBodyMetadata, + subscriberOptions, + eventTrigger, }: { rescheduleUid: string; reqBookingUid: string; @@ -129,6 +155,15 @@ const handleSeats = async ({ additionalNotes: AdditionalNotes; reqAppsStatus: ReqAppsStatus; attendeeLanguage: string | null; + paymentAppData: PaymentAppData; + fullName: ReturnType; + smsReminderNumber: SmsReminderNumber; + eventTypeInfo: EventTypeInfo; + uid: short.SUUID; + eventTypeId: EventTypeId; + reqBodyMetadata: ReqBodyMetadata; + subscriberOptions: GetSubscriberOptions; + eventTrigger: WebhookTriggerEvents; }) => { const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); @@ -580,7 +615,7 @@ const handleSeats = async ({ await prisma.booking.update({ where: { - uid: reqBody.bookingUid, + uid: reqBookingUid, }, include: { attendees: true, @@ -747,7 +782,7 @@ const handleSeats = async ({ rescheduleEndTime: originalRescheduledBooking?.endTime ? dayjs(originalRescheduledBooking?.endTime).utc().format() : undefined, - metadata: { ...metadata, ...reqBody.metadata }, + metadata: { ...metadata, ...reqBodyMetadata }, eventTypeId, status: "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, From d80f8c04776a30b3492dbfb0f846519e45f97e4b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 13 Nov 2023 15:37:11 -0500 Subject: [PATCH 12/65] Typescript refactor --- .../features/bookings/lib/handleNewBooking.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index db83168989b0c1..2d4ce687480ad6 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -123,15 +123,18 @@ export type OrganizerUser = Awaited>[number] & { metadata?: Prisma.JsonValue; }; export type OriginalRescheduledBooking = Awaited>; -export type RescheduleReason = Awaited>["rescheduleReason"]; -export type NoEmail = Awaited>["noEmail"]; + +type AwaitedBookingData = Awaited>; +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["isConfirmedByDefault"]; -export type AdditionalNotes = Awaited>["notes"]; -export type ReqAppsStatus = Awaited>["appsStatus"]; export type PaymentAppData = ReturnType; -export type SmsReminderNumber = Awaited>["smsReminderNumber"]; -export type EventTypeId = Awaited>["eventTypeId"]; -export type ReqBodyMetadata = ReqBodyWithEnd["metadata"]; export interface IEventTypePaymentCredentialType { appId: EventTypeAppsList; @@ -620,17 +623,17 @@ async function createBooking({ evt: CalendarEvent; eventType: NewBookingEventType; eventTypeId: EventTypeId; - eventTypeSlug: Awaited>["eventTypeSlug"]; + eventTypeSlug: AwaitedBookingData["eventTypeSlug"]; reqBodyUser: ReqBodyWithEnd["user"]; reqBodyMetadata: ReqBodyMetadata; reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"]; uid: short.SUUID; responses: ReqBodyWithEnd["responses"] | null; isConfirmedByDefault: IsConfirmedByDefault; - smsReminderNumber: Awaited>["smsReminderNumber"]; + smsReminderNumber: AwaitedBookingData["smsReminderNumber"]; organizerUser: OrganizerUser; rescheduleReason: RescheduleReason; - bookerEmail: Awaited>["email"]; + bookerEmail: AwaitedBookingData["email"]; paymentAppData: ReturnType; }) { if (originalRescheduledBooking) { From b9dd07b7efbd265086b8de91c52875c9bf1f39d5 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 24 Nov 2023 13:02:47 -0500 Subject: [PATCH 13/65] Change function params from req --- .../features/bookings/lib/getBookingDataSchema.ts | 5 ++--- packages/features/bookings/lib/handleNewBooking.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/features/bookings/lib/getBookingDataSchema.ts b/packages/features/bookings/lib/getBookingDataSchema.ts index f57078b5bfbe64..cef2a72dc673f7 100644 --- a/packages/features/bookings/lib/getBookingDataSchema.ts +++ b/packages/features/bookings/lib/getBookingDataSchema.ts @@ -1,4 +1,3 @@ -import type { NextApiRequest } from "next"; import { z } from "zod"; import { @@ -11,7 +10,7 @@ import getBookingResponsesSchema from "./getBookingResponsesSchema"; import type { getEventTypesFromDB } from "./handleNewBooking"; const getBookingDataSchema = ( - req: NextApiRequest, + rescheduleUid: string | undefined, isNotAnApiCall: boolean, eventType: Awaited> ) => { @@ -19,7 +18,7 @@ const getBookingDataSchema = ( eventType: { bookingFields: eventType.bookingFields, }, - view: req.body.rescheduleUid ? "reschedule" : "booking", + view: rescheduleUid ? "reschedule" : "booking", }); const bookingDataSchema = isNotAnApiCall ? extendedBookingCreateBody.merge( diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index c9db30b6b32dbf..198954e96695d0 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -366,7 +366,11 @@ type IsFixedAwareUser = User & { organization: { slug: string }; }; -const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string[], req: NextApiRequest) => { +const loadUsers = async ( + eventType: NewBookingEventType, + dynamicUserList: string[], + reqHeadersHost: string | undefined +) => { try { if (!eventType.id) { if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) { @@ -376,7 +380,7 @@ const loadUsers = async (eventType: NewBookingEventType, dynamicUserList: string const users = await prisma.user.findMany({ where: { username: { in: dynamicUserList }, - organization: userOrgQuery(req.headers.host ? req.headers.host.replace(/^https?:\/\//, "") : ""), + organization: userOrgQuery(reqHeadersHost ? reqHeadersHost.replace(/^https?:\/\//, "") : ""), }, select: { ...userSelect.select, @@ -535,7 +539,7 @@ async function getBookingData({ isNotAnApiCall: boolean; eventType: Awaited>; }) { - const bookingDataSchema = getBookingDataSchema(req, isNotAnApiCall, eventType); + const bookingDataSchema = getBookingDataSchema(req.body?.rescheduleUid, isNotAnApiCall, eventType); const reqBody = await bookingDataSchema.parseAsync(req.body); @@ -925,7 +929,7 @@ async function handler( let users: (Awaited>[number] & { isFixed?: boolean; metadata?: Prisma.JsonValue; - })[] = await loadUsers(eventType, dynamicUserList, req); + })[] = await loadUsers(eventType, dynamicUserList, req.headers.host); const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); if (!isDynamicAllowed && !eventTypeId) { From e48efcac47780e244032ae09c91e665bc643c0c4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 13 Dec 2023 10:50:59 -0500 Subject: [PATCH 14/65] Type fix --- packages/features/bookings/lib/handleNewBooking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 815e3a405d2128..4492e78f68d29f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -105,7 +105,7 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); type User = Prisma.UserGetPayload; type BufferedBusyTimes = BufferedBusyTime[]; type BookingType = Prisma.PromiseReturnType; -type Booking = Prisma.PromiseReturnType; +export type Booking = Prisma.PromiseReturnType; export type NewBookingEventType = | Awaited> | Awaited>; From 4c4816db0fe1510048ed81f3116c56b4adf93187 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 21:13:13 -0500 Subject: [PATCH 15/65] Move handleSeats --- .../lib/{ => handleSeats}/handleSeats.ts | 195 +++--------------- .../bookings/lib/handleSeats/types.d.ts | 50 +++++ 2 files changed, 81 insertions(+), 164 deletions(-) rename packages/features/bookings/lib/{ => handleSeats}/handleSeats.ts (80%) create mode 100644 packages/features/bookings/lib/handleSeats/types.d.ts diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts similarity index 80% rename from packages/features/bookings/lib/handleSeats.ts rename to packages/features/bookings/lib/handleSeats/handleSeats.ts index 9f993ebd658183..c4f272defc10b3 100644 --- a/packages/features/bookings/lib/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -1,13 +1,8 @@ -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"; @@ -16,155 +11,25 @@ import { 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 { AdditionalInformation, AppsStatus, 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[], - 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) - ); - } - } - } - } - } +} from "../handleNewBooking"; +import type { Booking, IEventTypePaymentCredentialType } from "../handleNewBooking"; +import lastAttendeeDeleteBooking from "./lib/lastAttendeeDeleteBooking"; +import type { NewSeatedBookingObject, SeatedBooking } from "./types"; - 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>; - 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; - smsReminderNumber: SmsReminderNumber; - eventTypeInfo: EventTypeInfo; - uid: short.SUUID; - eventTypeId: EventTypeId; - reqBodyMetadata: ReqBodyMetadata; - subscriberOptions: GetSubscriberOptions; - eventTrigger: WebhookTriggerEvents; -}) => { +const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { + const { eventType, reqBodyUser, rescheduleUid, reqBookingUid, evt, invitee } = seatedEventObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); let resultBooking: @@ -177,7 +42,7 @@ const handleSeats = async ({ }) | null = null; - const booking = await prisma.booking.findFirst({ + const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({ where: { OR: [ { @@ -205,14 +70,14 @@ const handleSeats = async ({ }, }); - if (!booking) { + if (!seatedBooking) { 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 + seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) && + dayjs.utc(seatedBooking.startTime).format() === evt.startTime ) { throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." }); } @@ -286,7 +151,7 @@ const handleSeats = async ({ 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) { + if (seatedBooking.user?.id !== reqUserId) { throw new HttpError({ statusCode: 401 }); } @@ -307,7 +172,7 @@ const handleSeats = async ({ if (!newTimeSlotBooking) { const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({ where: { - id: booking.id, + id: seatedBooking.id, }, data: { startTime: evt.startTime, @@ -381,7 +246,7 @@ const handleSeats = async ({ const attendeesToMove = [], attendeesToDelete = []; - for (const attendee of booking.attendees) { + 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( @@ -494,7 +359,7 @@ const handleSeats = async ({ // Update the old booking with the cancelled status await prisma.booking.update({ where: { - id: booking.id, + id: seatedBooking.id, }, data: { status: BookingStatus.CANCELLED, @@ -590,17 +455,19 @@ const handleSeats = async ({ } } 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) => { + 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 <= booking.attendees.length) { + if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) { throw new HttpError({ statusCode: 409, message: "Booking seats are full" }); } - const videoCallReference = booking.references.find((reference) => reference.type.includes("_video")); + const videoCallReference = seatedBooking.references.find((reference) => + reference.type.includes("_video") + ); if (videoCallReference) { evt.videoCallData = { @@ -635,20 +502,20 @@ const handleSeats = async ({ }, booking: { connect: { - id: booking.id, + id: seatedBooking.id, }, }, }, }, }, }, - ...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), + ...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), }, }); evt.attendeeSeatId = attendeeUniqueId; - const newSeat = booking.attendees.length !== 0; + const newSeat = seatedBooking.attendees.length !== 0; /** * Remember objects are passed into functions as references @@ -656,10 +523,10 @@ const handleSeats = async ({ * deep cloning evt to avoid this */ if (!evt?.uid) { - evt.uid = booking?.uid ?? null; + evt.uid = seatedBooking?.uid ?? null; } const copyEvent = cloneDeep(evt); - copyEvent.uid = booking.uid; + copyEvent.uid = seatedBooking.uid; if (noEmail !== true) { let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; @@ -691,11 +558,11 @@ const handleSeats = async ({ } const credentials = await refreshCredentials(allCredentials); const eventManager = new EventManager({ ...organizerUser, credentials }); - await eventManager.updateCalendarAttendees(evt, booking); + await eventManager.updateCalendarAttendees(evt, seatedBooking); - const foundBooking = await findBookingQuery(booking.id); + const foundBooking = await findBookingQuery(seatedBooking.id); - if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) { + if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) { const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { ...(paymentAppData.credentialId @@ -734,7 +601,7 @@ const handleSeats = async ({ evt, eventType, eventTypePaymentAppCredential as IEventTypePaymentCredentialType, - booking, + seatedBooking, fullName, bookerEmail ); @@ -774,7 +641,7 @@ const handleSeats = async ({ ...evt, ...eventTypeInfo, uid: resultBooking?.uid || uid, - bookingId: booking?.id, + bookingId: seatedBooking?.id, rescheduleUid, rescheduleStartTime: originalRescheduledBooking?.startTime ? dayjs(originalRescheduledBooking?.startTime).utc().format() @@ -785,7 +652,7 @@ const handleSeats = async ({ metadata: { ...metadata, ...reqBodyMetadata }, eventTypeId, status: "ACCEPTED", - smsReminderNumber: booking?.smsReminderNumber || undefined, + smsReminderNumber: seatedBooking?.smsReminderNumber || undefined, }; await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts new file mode 100644 index 00000000000000..4d0de3f7800dc8 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -0,0 +1,50 @@ +import type { Prisma } from "@prisma/client"; + +export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; + +export type NewSeatedBookingObject = { + rescheduleUid: string; + reqBookingUid: string; + eventType: NewBookingEventType; + evt: CalendarEvent; + invitee: Invitee; + allCredentials: Awaited>; + 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; + smsReminderNumber: SmsReminderNumber; + eventTypeInfo: EventTypeInfo; + uid: short.SUUID; + eventTypeId: EventTypeId; + reqBodyMetadata: ReqBodyMetadata; + subscriberOptions: GetSubscriberOptions; + eventTrigger: WebhookTriggerEvents; +}; + +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; + }; +}>; From ffaf5f8259e8dd9ae67b7e738385809fcc5ec3c9 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 21:13:38 -0500 Subject: [PATCH 16/65] Abstract lastAttendeeDeleteBooking --- .../lib/lastAttendeeDeleteBooking.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts diff --git a/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts new file mode 100644 index 00000000000000..a31db00f5af299 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts @@ -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[], + 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; From ebb822d98e8ca0bd8f82639fc9a23fdacba2df04 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 21:13:56 -0500 Subject: [PATCH 17/65] Create function for rescheduling seated events --- .../handleRescheduleSeatedEvent.ts | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts diff --git a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts new file mode 100644 index 00000000000000..8f68c3aad0eb2c --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts @@ -0,0 +1,417 @@ +import type { NewSeatedBookingObject, SeatedBooking } from "bookings/lib/handleSeats/types"; +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; + +import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; +import { sendRescheduledEmails, sendRescheduledSeatEmail } from "@calcom/emails"; +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { AdditionalInformation, AppsStatus, Person } from "@calcom/types/Calendar"; + +import { + refreshCredentials, + addVideoCallDataToEvt, + handleAppsStatus, + findBookingQuery, +} from "../handleNewBooking"; +import type { Booking, createLoggerWithEventDetails } from "../handleNewBooking"; + +const handleRescheduledSeatedEvent = async ( + seatedEventObject: NewSeatedBookingObject, + seatedBooking: SeatedBooking, + loggerWithEventDetails: ReturnType +) => { + const { + eventType, + allCredentials, + organizerUser, + originalRescheduledBooking, + bookerEmail, + tAttendees, + bookingSeat, + reqUserId, + rescheduleReason, + rescheduleUid, + reqAppsStatus, + noEmail, + isConfirmedByDefault, + additionalNotes, + attendeeLanguage, + } = seatedEventObject; + + let { evt } = seatedEventObject; + + // 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 (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 + + // 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: seatedBooking.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 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: "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: seatedBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + resultBooking = { ...foundBooking }; + } + } + + // seatAttendee is null when the organizer is rescheduling. + const seatAttendee: Partial | 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 }; + } +}; + +export default handleRescheduledSeatedEvent; From b94e8201aa1bb6aa79ec54c9b244387c2f9b68a5 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 21:40:50 -0500 Subject: [PATCH 18/65] Fix imports on reschedule seats function --- ...nt.ts => handleRescheduleSeatedBooking.ts} | 13 +- .../bookings/lib/handleSeats/handleSeats.ts | 400 +----------------- .../bookings/lib/handleSeats/types.d.ts | 14 + 3 files changed, 35 insertions(+), 392 deletions(-) rename packages/features/bookings/lib/handleSeats/{handleRescheduleSeatedEvent.ts => handleRescheduleSeatedBooking.ts} (97%) diff --git a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts similarity index 97% rename from packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts rename to packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts index 8f68c3aad0eb2c..739a5049e06cad 100644 --- a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedEvent.ts +++ b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts @@ -1,4 +1,8 @@ -import type { NewSeatedBookingObject, SeatedBooking } from "bookings/lib/handleSeats/types"; +import type { + HandleSeatsResultBooking, + NewSeatedBookingObject, + SeatedBooking, +} from "bookings/lib/handleSeats/types"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; @@ -17,17 +21,18 @@ import { findBookingQuery, } from "../handleNewBooking"; import type { Booking, createLoggerWithEventDetails } from "../handleNewBooking"; +import lastAttendeeDeleteBooking from "./lib/lastAttendeeDeleteBooking"; const handleRescheduledSeatedEvent = async ( seatedEventObject: NewSeatedBookingObject, seatedBooking: SeatedBooking, + resultBooking: HandleSeatsResultBooking | null, loggerWithEventDetails: ReturnType ) => { const { eventType, allCredentials, organizerUser, - originalRescheduledBooking, bookerEmail, tAttendees, bookingSeat, @@ -41,7 +46,7 @@ const handleRescheduledSeatedEvent = async ( attendeeLanguage, } = seatedEventObject; - let { evt } = seatedEventObject; + let { evt, originalRescheduledBooking } = seatedEventObject; // See if the new date has a booking already const newTimeSlotBooking = await prisma.booking.findFirst({ @@ -412,6 +417,8 @@ const handleRescheduledSeatedEvent = async ( resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid }; } + + return resultBooking; }; export default handleRescheduledSeatedEvent; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index c4f272defc10b3..3be6099ea7e011 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -4,7 +4,7 @@ import { uuid } from "short-uuid"; import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; -import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails"; +import { sendScheduledSeatsEmails } from "@calcom/emails"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { allowDisablingAttendeeConfirmationEmails, @@ -15,32 +15,17 @@ 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 { AdditionalInformation, AppsStatus, Person } from "@calcom/types/Calendar"; -import { - refreshCredentials, - addVideoCallDataToEvt, - createLoggerWithEventDetails, - handleAppsStatus, - findBookingQuery, -} from "../handleNewBooking"; -import type { Booking, IEventTypePaymentCredentialType } from "../handleNewBooking"; -import lastAttendeeDeleteBooking from "./lib/lastAttendeeDeleteBooking"; -import type { NewSeatedBookingObject, SeatedBooking } from "./types"; +import { refreshCredentials, createLoggerWithEventDetails, findBookingQuery } from "../handleNewBooking"; +import type { IEventTypePaymentCredentialType } from "../handleNewBooking"; +import handleRescheduledSeatedBooking from "./handleRescheduleSeatedBooking"; +import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types"; const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { const { eventType, reqBodyUser, rescheduleUid, reqBookingUid, evt, invitee } = seatedEventObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); - let resultBooking: - | (Partial & { - appsStatus?: AppsStatus[]; - seatReferenceUid?: string; - paymentUid?: string; - message?: string; - paymentId?: number; - }) - | null = null; + let resultBooking: HandleSeatsResultBooking = null; const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({ where: { @@ -84,375 +69,12 @@ const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { // 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[] + resultBooking = await handleRescheduledSeatedBooking( + seatedEventObject, + seatedBooking, + resultBooking, + loggerWithEventDetails ); - - // 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 - - // 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: seatedBooking.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 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: "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: seatedBooking.id, - }, - data: { - status: BookingStatus.CANCELLED, - }, - }); - - const foundBooking = await findBookingQuery(newTimeSlotBooking.id); - - resultBooking = { ...foundBooking }; - } - } - - // seatAttendee is null when the organizer is rescheduling. - const seatAttendee: Partial | 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 = seatedBooking.attendees.map((attendee) => { diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 4d0de3f7800dc8..942ee09cdef1c0 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -1,5 +1,9 @@ import type { Prisma } from "@prisma/client"; +import type { AppsStatus } from "@calcom/types/Calendar"; + +import type { Booking } from "../handleNewBooking"; + export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; export type NewSeatedBookingObject = { @@ -48,3 +52,13 @@ export type SeatedBooking = Prisma.BookingGetPayload<{ scheduledJobs: true; }; }>; + +export type HandleSeatsResultBooking = + | (Partial & { + appsStatus?: AppsStatus[]; + seatReferenceUid?: string; + paymentUid?: string; + message?: string; + paymentId?: number; + }) + | null; From bf7e59dc778656bcb2ef704089df6b63c0639067 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 22:13:23 -0500 Subject: [PATCH 19/65] Fix imports --- .../bookings/lib/handleSeats/handleSeats.ts | 26 ++++++++++++++++++- .../bookings/lib/handleSeats/types.d.ts | 4 +-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 3be6099ea7e011..12852a2d05289f 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -22,7 +22,31 @@ import handleRescheduledSeatedBooking from "./handleRescheduleSeatedBooking"; import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types"; const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { - const { eventType, reqBodyUser, rescheduleUid, reqBookingUid, evt, invitee } = seatedEventObject; + const { + eventType, + reqBodyUser, + rescheduleUid, + reqBookingUid, + invitee, + tAttendees, + attendeeLanguage, + additionalNotes, + noEmail, + allCredentials, + organizerUser, + paymentAppData, + fullName, + bookerEmail, + smsReminderNumber, + eventTypeInfo, + uid, + originalRescheduledBooking, + reqBodyMetadata, + eventTypeId, + subscriberOptions, + eventTrigger, + } = seatedEventObject; + let { evt } = seatedEventObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); let resultBooking: HandleSeatsResultBooking = null; diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 942ee09cdef1c0..c40893ab01f681 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -7,8 +7,8 @@ import type { Booking } from "../handleNewBooking"; export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; export type NewSeatedBookingObject = { - rescheduleUid: string; - reqBookingUid: string; + rescheduleUid: string | undefined; + reqBookingUid: string | undefined; eventType: NewBookingEventType; evt: CalendarEvent; invitee: Invitee; From 553441f552bebfcc0815c81567825d25371763fd Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 22:13:47 -0500 Subject: [PATCH 20/65] Import handleSeats function --- .../features/bookings/lib/handleNewBooking.ts | 1300 +++++++++-------- 1 file changed, 664 insertions(+), 636 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 4492e78f68d29f..0316f65bf0dc8f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -31,12 +31,10 @@ import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendRescheduledEmails, - sendRescheduledSeatEmail, sendRoundRobinCancelledEmails, sendRoundRobinRescheduledEmails, sendRoundRobinScheduledEmails, sendScheduledEmails, - sendScheduledSeatsEmails, } from "@calcom/emails"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; @@ -97,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"] }); @@ -1347,7 +1346,7 @@ async function handler( const calEventUserFieldsResponses = "calEventUserFieldsResponses" in reqBody ? reqBody.calEventUserFieldsResponses : null; - let evt: CalendarEvent = { + const evt: CalendarEvent = { bookerUrl: await getBookerUrl(organizerUser), type: eventType.slug, title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately @@ -1473,640 +1472,669 @@ async function handler( teamId, }; - const handleSeats = async () => { - let resultBooking: - | (Partial & { - 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 | 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: 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; - }; + // const handleSeats = async () => { + // let resultBooking: + // | (Partial & { + // 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 | 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: 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; From a73dabbfa2b474042bc38393ceb635a216ddbfcf Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 22:15:50 -0500 Subject: [PATCH 21/65] Fix rescheduleUid type --- .../bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts index 739a5049e06cad..834bf3f89308f8 100644 --- a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts @@ -24,7 +24,8 @@ import type { Booking, createLoggerWithEventDetails } from "../handleNewBooking" import lastAttendeeDeleteBooking from "./lib/lastAttendeeDeleteBooking"; const handleRescheduledSeatedEvent = async ( - seatedEventObject: NewSeatedBookingObject, + // If this function is being called then rescheduleUid is defined + seatedEventObject: NewSeatedBookingObject & { rescheduleUid: string }, seatedBooking: SeatedBooking, resultBooking: HandleSeatsResultBooking | null, loggerWithEventDetails: ReturnType From 93ea6497e7ec52c9e4966a58b18f92ced9c98361 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 23:15:43 -0500 Subject: [PATCH 22/65] Refactor owner reschedule to new time slot --- .../handleRescheduleSeatedBooking.ts | 425 ------------------ .../bookings/lib/handleSeats/handleSeats.ts | 11 +- .../handleRescheduleSeatedBooking.ts | 221 +++++++++ .../owner/moveSeatedBookingToNewTimeSlot.ts | 101 +++++ .../owner/ownerRescheduleSeatedBooking.ts | 189 ++++++++ .../bookings/lib/handleSeats/types.d.ts | 14 + 6 files changed, 531 insertions(+), 430 deletions(-) delete mode 100644 packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts create mode 100644 packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts create mode 100644 packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts create mode 100644 packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts diff --git a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts deleted file mode 100644 index 834bf3f89308f8..00000000000000 --- a/packages/features/bookings/lib/handleSeats/handleRescheduleSeatedBooking.ts +++ /dev/null @@ -1,425 +0,0 @@ -import type { - HandleSeatsResultBooking, - NewSeatedBookingObject, - SeatedBooking, -} from "bookings/lib/handleSeats/types"; -// eslint-disable-next-line no-restricted-imports -import { cloneDeep } from "lodash"; - -import EventManager from "@calcom/core/EventManager"; -import dayjs from "@calcom/dayjs"; -import { sendRescheduledEmails, sendRescheduledSeatEmail } from "@calcom/emails"; -import { HttpError } from "@calcom/lib/http-error"; -import prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; -import type { AdditionalInformation, AppsStatus, Person } from "@calcom/types/Calendar"; - -import { - refreshCredentials, - addVideoCallDataToEvt, - handleAppsStatus, - findBookingQuery, -} from "../handleNewBooking"; -import type { Booking, createLoggerWithEventDetails } from "../handleNewBooking"; -import lastAttendeeDeleteBooking from "./lib/lastAttendeeDeleteBooking"; - -const handleRescheduledSeatedEvent = async ( - // If this function is being called then rescheduleUid is defined - seatedEventObject: NewSeatedBookingObject & { rescheduleUid: string }, - seatedBooking: SeatedBooking, - resultBooking: HandleSeatsResultBooking | null, - loggerWithEventDetails: ReturnType -) => { - const { - eventType, - allCredentials, - organizerUser, - bookerEmail, - tAttendees, - bookingSeat, - reqUserId, - rescheduleReason, - rescheduleUid, - reqAppsStatus, - noEmail, - isConfirmedByDefault, - additionalNotes, - attendeeLanguage, - } = seatedEventObject; - - let { evt, originalRescheduledBooking } = seatedEventObject; - - // 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 (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 - - // 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: seatedBooking.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 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: "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: seatedBooking.id, - }, - data: { - status: BookingStatus.CANCELLED, - }, - }); - - const foundBooking = await findBookingQuery(newTimeSlotBooking.id); - - resultBooking = { ...foundBooking }; - } - } - - // seatAttendee is null when the organizer is rescheduling. - const seatAttendee: Partial | 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 }; - } - - return resultBooking; -}; - -export default handleRescheduledSeatedEvent; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 12852a2d05289f..dad81c16bf9ecc 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -18,10 +18,10 @@ import { BookingStatus } from "@calcom/prisma/enums"; import { refreshCredentials, createLoggerWithEventDetails, findBookingQuery } from "../handleNewBooking"; import type { IEventTypePaymentCredentialType } from "../handleNewBooking"; -import handleRescheduledSeatedBooking from "./handleRescheduleSeatedBooking"; +import handleRescheduledSeatedBooking from "./reschedule/handleRescheduleSeatedBooking"; import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types"; -const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { +const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { const { eventType, reqBodyUser, @@ -45,8 +45,8 @@ const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { eventTypeId, subscriberOptions, eventTrigger, - } = seatedEventObject; - let { evt } = seatedEventObject; + } = newSeatedBookingObject; + let { evt } = newSeatedBookingObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); let resultBooking: HandleSeatsResultBooking = null; @@ -94,7 +94,8 @@ const handleSeats = async (seatedEventObject: NewSeatedBookingObject) => { // There are two paths here, reschedule a booking with seats and booking seats without reschedule if (rescheduleUid) { resultBooking = await handleRescheduledSeatedBooking( - seatedEventObject, + // Assert that the rescheduleUid is defined + { ...newSeatedBookingObject, rescheduleUid }, seatedBooking, resultBooking, loggerWithEventDetails diff --git a/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts new file mode 100644 index 00000000000000..72007a3dff7ad0 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts @@ -0,0 +1,221 @@ +import type { + HandleSeatsResultBooking, + SeatedBooking, + RescheduleSeatedBookingObject, +} from "bookings/lib/handleSeats/types"; +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; + +import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; +import { sendRescheduledSeatEmail } from "@calcom/emails"; +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, findBookingQuery } from "../../handleNewBooking"; +import type { createLoggerWithEventDetails } from "../../handleNewBooking"; +import lastAttendeeDeleteBooking from "../lib/lastAttendeeDeleteBooking"; +import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking"; + +const handleRescheduledSeatedEvent = async ( + // If this function is being called then rescheduleUid is defined + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + seatedBooking: SeatedBooking, + resultBooking: HandleSeatsResultBooking | null, + loggerWithEventDetails: ReturnType +) => { + const { + evt, + eventType, + allCredentials, + organizerUser, + bookerEmail, + tAttendees, + bookingSeat, + reqUserId, + rescheduleUid, + } = rescheduleSeatedBookingObject; + + let { originalRescheduledBooking } = rescheduleSeatedBookingObject; + + // 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 (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: Partial | 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 }; + } + + return resultBooking; +}; + +export default handleRescheduledSeatedEvent; diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts new file mode 100644 index 00000000000000..9450bbfc97840f --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -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 { addVideoCallDataToEvt, 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 +) => { + 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 = 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); + + return { ...foundBooking, appsStatus: newBooking.appsStatus }; +}; + +export default moveSeatedBookingToNewTimeSlot; diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts new file mode 100644 index 00000000000000..7ac965ab2eb3da --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts @@ -0,0 +1,189 @@ +// 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 { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { addVideoCallDataToEvt, findBookingQuery } from "../../../handleNewBooking"; +import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; +import type { + NewTimeSlotBooking, + SeatedBooking, + RescheduleSeatedBookingObject, + HandleSeatsResultBooking, +} from "../../types"; +import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot"; + +const ownerRescheduleSeatedBooking = async ( + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + newTimeSlotBooking: NewTimeSlotBooking, + seatedBooking: SeatedBooking, + resultBooking: HandleSeatsResultBooking | null, + eventManager: EventManager, + loggerWithEventDetails: ReturnType +) => { + const { + originalRescheduledBooking, + tAttendees, + rescheduleReason, + rescheduleUid, + eventType, + noEmail, + isConfirmedByDefault, + additionalNotes, + attendeeLanguage, + } = rescheduleSeatedBookingObject; + let { evt } = rescheduleSeatedBookingObject; + // 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 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 { + // 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: "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: seatedBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + resultBooking = { ...foundBooking }; + } + return resultBooking; +}; + +export default ownerRescheduleSeatedBooking; diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index c40893ab01f681..94ffbfa889e8a4 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -37,6 +37,8 @@ export type NewSeatedBookingObject = { eventTrigger: WebhookTriggerEvents; }; +export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string }; + export type SeatedBooking = Prisma.BookingGetPayload<{ select: { uid: true; @@ -62,3 +64,15 @@ export type HandleSeatsResultBooking = paymentId?: number; }) | null; + +export type NewTimeSlotBooking = Prisma.BookingGetPayload<{ + select: { + id: true; + uid: true; + attendees: { + include: { + bookingSeat: true; + }; + }; + }; +}> | null; From c6cf2a295d81217d7530935fc53bb80bfac139a5 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 15 Dec 2023 23:42:21 -0500 Subject: [PATCH 23/65] Refactor combine two booking times together --- apps/web/pages/reschedule/[uid].tsx | 1 + .../owner/combineTwoSeatedBookings.ts | 159 ++++++++++++++++++ .../owner/ownerRescheduleSeatedBooking.ts | 158 ++--------------- .../bookings/lib/handleSeats/types.d.ts | 2 +- 4 files changed, 173 insertions(+), 147 deletions(-) create mode 100644 packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index fbfa33d963f934..7e9df55057c4ce 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -85,6 +85,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // if booking event type is for a seated event and no seat reference uid is provided, throw not found if (booking?.eventType?.seatsPerTimeSlot && !maybeSeatReferenceUid) { const userId = session?.user?.id; + console.log("🚀 ~ file: [uid].tsx:89 ~ getServerSideProps ~ userId:", userId); if (!userId && !seatReferenceUid) { return { diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts new file mode 100644 index 00000000000000..fca51619439934 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -0,0 +1,159 @@ +// 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 { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; +import { addVideoCallDataToEvt, findBookingQuery } from "../../../handleNewBooking"; +import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types"; + +const combineTwoSeatedBookings = async ( + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + seatedBooking: SeatedBooking, + newTimeSlotBooking: NewTimeSlotBooking, + eventManager: EventManager, + loggerWithEventDetails: ReturnType +) => { + 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: "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: seatedBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + return { ...foundBooking }; +}; + +export default combineTwoSeatedBookings; diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts index 7ac965ab2eb3da..c2fa04069b10c8 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts @@ -1,14 +1,6 @@ // 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 { HttpError } from "@calcom/lib/http-error"; -import prisma from "@calcom/prisma"; -import { BookingStatus } from "@calcom/prisma/enums"; -import { addVideoCallDataToEvt, findBookingQuery } from "../../../handleNewBooking"; import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; import type { NewTimeSlotBooking, @@ -16,28 +8,19 @@ import type { RescheduleSeatedBookingObject, HandleSeatsResultBooking, } from "../../types"; +import combineTwoSeatedBookings from "./combineTwoSeatedBookings"; import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot"; const ownerRescheduleSeatedBooking = async ( rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, - newTimeSlotBooking: NewTimeSlotBooking, + newTimeSlotBooking: NewTimeSlotBooking | null, seatedBooking: SeatedBooking, resultBooking: HandleSeatsResultBooking | null, eventManager: EventManager, loggerWithEventDetails: ReturnType ) => { - const { - originalRescheduledBooking, - tAttendees, - rescheduleReason, - rescheduleUid, - eventType, - noEmail, - isConfirmedByDefault, - additionalNotes, - attendeeLanguage, - } = rescheduleSeatedBookingObject; - let { evt } = rescheduleSeatedBookingObject; + const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject; + const { evt } = rescheduleSeatedBookingObject; // 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 { @@ -57,131 +40,14 @@ const ownerRescheduleSeatedBooking = async ( loggerWithEventDetails ); } else { - // 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: "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: seatedBooking.id, - }, - data: { - status: BookingStatus.CANCELLED, - }, - }); - - const foundBooking = await findBookingQuery(newTimeSlotBooking.id); - - resultBooking = { ...foundBooking }; + // 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; }; diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 94ffbfa889e8a4..184a129b19814e 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -75,4 +75,4 @@ export type NewTimeSlotBooking = Prisma.BookingGetPayload<{ }; }; }; -}> | null; +}>; From 435a4ab452a8655c46c5751780316254842d443b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Sat, 16 Dec 2023 00:17:57 -0500 Subject: [PATCH 24/65] Reschedule as an attendee --- apps/web/pages/reschedule/[uid].tsx | 4 + .../attendeeRescheduleSeatedBooking.ts | 102 ++++++++++++++++++ .../handleRescheduleSeatedBooking.ts | 3 +- .../bookings/lib/handleSeats/types.d.ts | 2 + .../lib/server/maybeGetBookingUidFromSeat.ts | 5 + 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index 7e9df55057c4ce..fda8065e505a00 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -84,6 +84,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // if booking event type is for a seated event and no seat reference uid is provided, throw not found if (booking?.eventType?.seatsPerTimeSlot && !maybeSeatReferenceUid) { + console.log( + "🚀 ~ file: [uid].tsx:87 ~ getServerSideProps ~ maybeSeatReferenceUid:", + maybeSeatReferenceUid + ); const userId = session?.user?.id; console.log("🚀 ~ file: [uid].tsx:89 ~ getServerSideProps ~ userId:", userId); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts new file mode 100644 index 00000000000000..efa3023ac807de --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -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 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); + + return { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid }; +}; + +export default attendeeRescheduleSeatedBooking; diff --git a/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts index 72007a3dff7ad0..1ac3600bdd7deb 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts @@ -2,6 +2,7 @@ import type { HandleSeatsResultBooking, SeatedBooking, RescheduleSeatedBookingObject, + SeatAttendee, } from "bookings/lib/handleSeats/types"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; @@ -134,7 +135,7 @@ const handleRescheduledSeatedEvent = async ( } // seatAttendee is null when the organizer is rescheduling. - const seatAttendee: Partial | null = bookingSeat?.attendee || null; + const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null; if (seatAttendee) { seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" }; diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 184a129b19814e..06f773b9768440 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -76,3 +76,5 @@ export type NewTimeSlotBooking = Prisma.BookingGetPayload<{ }; }; }>; + +export type SeatAttendee = Partial; diff --git a/packages/lib/server/maybeGetBookingUidFromSeat.ts b/packages/lib/server/maybeGetBookingUidFromSeat.ts index a6a4b8587c4217..be1be697daa8a6 100644 --- a/packages/lib/server/maybeGetBookingUidFromSeat.ts +++ b/packages/lib/server/maybeGetBookingUidFromSeat.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from "@calcom/prisma"; export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) { + console.log("🚀 ~ file: maybeGetBookingUidFromSeat.ts:4 ~ maybeGetBookingUidFromSeat ~ uid:", uid); // Look bookingUid in bookingSeat const bookingSeat = await prisma.bookingSeat.findUnique({ where: { @@ -15,6 +16,10 @@ export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: stri }, }, }); + console.log( + "🚀 ~ file: maybeGetBookingUidFromSeat.ts:18 ~ maybeGetBookingUidFromSeat ~ bookingSeat:", + bookingSeat + ); if (bookingSeat) return { uid: bookingSeat.booking.uid, seatReferenceUid: uid }; return { uid }; } From 878eec0041c2397ca45336e8535c23a2d54a6abc Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 18 Dec 2023 13:54:15 -0500 Subject: [PATCH 25/65] Refactor createNewSeat --- .../lib/handleSeats/create/createNewSeat.ts | 201 ++++++++++++++++++ .../bookings/lib/handleSeats/handleSeats.ts | 190 +---------------- ...dBooking.ts => rescheduleSeatedBooking.ts} | 4 +- 3 files changed, 209 insertions(+), 186 deletions(-) create mode 100644 packages/features/bookings/lib/handleSeats/create/createNewSeat.ts rename packages/features/bookings/lib/handleSeats/reschedule/{handleRescheduleSeatedBooking.ts => rescheduleSeatedBooking.ts} (98%) diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts new file mode 100644 index 00000000000000..c5f03697810a55 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -0,0 +1,201 @@ +// 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 { 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: "Booking seats are full" }); + } + + 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(); + + 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: 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, + invitee[0], + 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: "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, + 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; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index dad81c16bf9ecc..75f93ab4bbf645 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -1,24 +1,14 @@ // eslint-disable-next-line no-restricted-imports -import { cloneDeep } from "lodash"; -import { uuid } from "short-uuid"; - -import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; -import { 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 { 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 { refreshCredentials, createLoggerWithEventDetails, findBookingQuery } from "../handleNewBooking"; -import type { IEventTypePaymentCredentialType } from "../handleNewBooking"; -import handleRescheduledSeatedBooking from "./reschedule/handleRescheduleSeatedBooking"; +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) => { @@ -28,14 +18,6 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { rescheduleUid, reqBookingUid, invitee, - tAttendees, - attendeeLanguage, - additionalNotes, - noEmail, - allCredentials, - organizerUser, - paymentAppData, - fullName, bookerEmail, smsReminderNumber, eventTypeInfo, @@ -46,7 +28,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { subscriberOptions, eventTrigger, } = newSeatedBookingObject; - let { evt } = newSeatedBookingObject; + const { evt } = newSeatedBookingObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); let resultBooking: HandleSeatsResultBooking = null; @@ -93,7 +75,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { // There are two paths here, reschedule a booking with seats and booking seats without reschedule if (rescheduleUid) { - resultBooking = await handleRescheduledSeatedBooking( + resultBooking = await rescheduleSeatedBooking( // Assert that the rescheduleUid is defined { ...newSeatedBookingObject, rescheduleUid }, seatedBooking, @@ -101,167 +83,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { loggerWithEventDetails ); } 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 = 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: "Booking seats are full" }); - } - - 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(); - - 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: 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, - invitee[0], - 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: "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, - seatedBooking, - fullName, - bookerEmail - ); - - resultBooking = { ...foundBooking }; - resultBooking["message"] = "Payment required"; - resultBooking["paymentUid"] = payment?.uid; - resultBooking["id"] = payment?.id; - } else { - resultBooking = { ...foundBooking }; - } - - resultBooking["seatReferenceUid"] = evt.attendeeSeatId; + resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking); } // Here we should handle every after action that needs to be done after booking creation diff --git a/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts similarity index 98% rename from packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts rename to packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index 1ac3600bdd7deb..d3bcca48180ddf 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/handleRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -20,7 +20,7 @@ import type { createLoggerWithEventDetails } from "../../handleNewBooking"; import lastAttendeeDeleteBooking from "../lib/lastAttendeeDeleteBooking"; import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking"; -const handleRescheduledSeatedEvent = async ( +const rescheduleSeatedBooking = async ( // If this function is being called then rescheduleUid is defined rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, seatedBooking: SeatedBooking, @@ -219,4 +219,4 @@ const handleRescheduledSeatedEvent = async ( return resultBooking; }; -export default handleRescheduledSeatedEvent; +export default rescheduleSeatedBooking; From 2669006b79743ad7732df11e92956b8839e458ae Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 18 Dec 2023 16:12:31 -0500 Subject: [PATCH 26/65] Remove old handleSeats --- .../features/bookings/lib/handleNewBooking.ts | 631 ------------------ 1 file changed, 631 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 0316f65bf0dc8f..f4cf18e158684e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1472,637 +1472,6 @@ async function handler( teamId, }; - // const handleSeats = async () => { - // let resultBooking: - // | (Partial & { - // 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 | 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: 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({ From fb075f0bc213d1400769ba092c94457fa5a162e0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 19 Dec 2023 15:26:56 -0500 Subject: [PATCH 27/65] Remove lastAttendeeDeleteBooking from handleNewBooking --- .../features/bookings/lib/handleNewBooking.ts | 55 +------------------ 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f4cf18e158684e..fedf79e9fa3624 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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 { isValidPhoneNumber } from "libphonenumber-js"; @@ -11,7 +11,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 { @@ -24,7 +23,6 @@ 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 { @@ -1388,57 +1386,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>, - filteredAttendees: Partial[], - 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, From e8c2f52048865917706441457e7b02dac87b0b77 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 19 Dec 2023 15:27:18 -0500 Subject: [PATCH 28/65] Test for new attendee right params are passed --- .../lib/handleSeats/handleSeats.test.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 packages/features/bookings/lib/handleSeats/handleSeats.test.ts diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/handleSeats.test.ts new file mode 100644 index 00000000000000..3dfc5fe95b4e2a --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/handleSeats.test.ts @@ -0,0 +1,230 @@ +import { describe, test, vi, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { + getBooker, + TestData, + getOrganizer, + createBookingScenario, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + BookingLocations, + getDate, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import handleSeats from "./handleSeats"; +import * as handleSeatsModule from "./handleSeats"; + +describe("handleSeats", () => { + setupAndTeardown(); + + describe("Correct parameters being passed into handleSeats from handleNewBooking", () => { + vi.mock("./handleSeats"); + test("On new booking handleSeats is not called", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + expect(handleSeats).toHaveBeenCalledTimes(0); + }); + + test("handleSeats is called when a new attendee is added", async () => { + const spy = vi.spyOn(handleSeatsModule, "default"); + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00Z`; + const bookingUid = "abc123"; + + const bookingScenario = await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + const handleSeatsCall = spy.mock.calls[0][0]; + + expect(handleSeatsCall).toEqual( + expect.objectContaining({ + bookerEmail: booker.email, + reqBookingUid: bookingUid, + reqBodyUser: reqBookingUser, + tAttendees: expect.any(Function), + additionalNotes: expect.anything(), + noEmail: undefined, + }) + ); + + const bookingScenarioEventType = bookingScenario.eventTypes[0]; + expect(handleSeatsCall.eventTypeInfo).toEqual( + expect.objectContaining({ + eventTitle: bookingScenarioEventType.title, + eventDescription: bookingScenarioEventType.description, + length: bookingScenarioEventType.length, + }) + ); + + expect(handleSeatsCall.eventType).toEqual( + expect.objectContaining({ + id: bookingScenarioEventType.id, + slug: bookingScenarioEventType.slug, + workflows: bookingScenarioEventType.workflows, + seatsPerTimeSlot: bookingScenarioEventType.seatsPerTimeSlot, + seatsShowAttendees: bookingScenarioEventType.seatsShowAttendees, + }) + ); + + expect(handleSeatsCall.evt).toEqual( + expect.objectContaining({ + startTime: bookingStartTime, + }) + ); + + expect(handleSeatsCall.invitee).toEqual([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + ]); + }); + }); +}); From 26656dd42dc21a5668db8aee7fe5f01288951a17 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 20 Dec 2023 10:43:04 -0500 Subject: [PATCH 29/65] Unit test params for reschedule --- .../{ => test}/handleSeats.test.ts | 146 +++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) rename packages/features/bookings/lib/handleSeats/{ => test}/handleSeats.test.ts (62%) diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts similarity index 62% rename from packages/features/bookings/lib/handleSeats/handleSeats.test.ts rename to packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 3dfc5fe95b4e2a..fe0acccbb3d1b4 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -16,8 +16,8 @@ import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/ import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import handleSeats from "./handleSeats"; -import * as handleSeatsModule from "./handleSeats"; +import handleSeats from "../handleSeats"; +import * as handleSeatsModule from "../handleSeats"; describe("handleSeats", () => { setupAndTeardown(); @@ -227,4 +227,146 @@ describe("handleSeats", () => { ]); }); }); + + test("handleSeats is called on rescheduling a seated event", async () => { + const spy = vi.spyOn(handleSeatsModule, "default"); + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00Z`; + const bookingUid = "abc123"; + + const bookingScenario = await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + rescheduleUid: bookingUid, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + const handleSeatsCall = spy.mock.calls[0][0]; + + expect(handleSeatsCall).toEqual( + expect.objectContaining({ + rescheduleUid: bookingUid, + bookerEmail: booker.email, + reqBookingUid: bookingUid, + reqBodyUser: reqBookingUser, + tAttendees: expect.any(Function), + additionalNotes: expect.anything(), + noEmail: undefined, + }) + ); + + const bookingScenarioEventType = bookingScenario.eventTypes[0]; + expect(handleSeatsCall.eventTypeInfo).toEqual( + expect.objectContaining({ + eventTitle: bookingScenarioEventType.title, + eventDescription: bookingScenarioEventType.description, + length: bookingScenarioEventType.length, + }) + ); + + expect(handleSeatsCall.eventType).toEqual( + expect.objectContaining({ + id: bookingScenarioEventType.id, + slug: bookingScenarioEventType.slug, + workflows: bookingScenarioEventType.workflows, + seatsPerTimeSlot: bookingScenarioEventType.seatsPerTimeSlot, + seatsShowAttendees: bookingScenarioEventType.seatsShowAttendees, + }) + ); + + expect(handleSeatsCall.evt).toEqual( + expect.objectContaining({ + startTime: bookingStartTime, + }) + ); + + expect(handleSeatsCall.invitee).toEqual([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + ]); + }); }); From f50e425d5aff6bf1ec085243cc148ca0f117e061 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 20 Dec 2023 13:23:50 -0500 Subject: [PATCH 30/65] Typo fix --- .../lib/handleSeats/test/handleSeats.test.ts | 268 +++++++++--------- 1 file changed, 138 insertions(+), 130 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index fe0acccbb3d1b4..0a7cc2a2f2043c 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -16,7 +16,6 @@ import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/ import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -import handleSeats from "../handleSeats"; import * as handleSeatsModule from "../handleSeats"; describe("handleSeats", () => { @@ -26,6 +25,7 @@ describe("handleSeats", () => { vi.mock("./handleSeats"); test("On new booking handleSeats is not called", async () => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const spy = vi.spyOn(handleSeatsModule, "default"); const booker = getBooker({ email: "booker@example.com", name: "Booker", @@ -84,7 +84,7 @@ describe("handleSeats", () => { await handleNewBooking(req); - expect(handleSeats).toHaveBeenCalledTimes(0); + expect(spy).toHaveBeenCalledTimes(0); }); test("handleSeats is called when a new attendee is added", async () => { @@ -226,147 +226,155 @@ describe("handleSeats", () => { }), ]); }); - }); - test("handleSeats is called on rescheduling a seated event", async () => { - const spy = vi.spyOn(handleSeatsModule, "default"); - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + test("handleSeats is called on rescheduling a seated event", async () => { + const spy = vi.spyOn(handleSeatsModule, "default"); + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); - const organizer = getOrganizer({ - name: "Organizer", - email: "organizer@example.com", - id: 101, - schedules: [TestData.schedules.IstWorkHours], - }); + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); - const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); - const bookingStartTime = `${plus1DateString}T04:00:00Z`; - const bookingUid = "abc123"; - - const bookingScenario = await createBookingScenario( - getScenarioData({ - eventTypes: [ - { - id: 1, - slug: "seated-event", - slotInterval: 45, - length: 45, - users: [ - { - id: 101, - }, - ], - seatsPerTimeSlot: 3, - seatsShowAttendees: false, - }, - ], - bookings: [ - { - uid: bookingUid, - eventTypeId: 1, - status: BookingStatus.ACCEPTED, - startTime: bookingStartTime, - endTime: `${plus1DateString}T05:15:00.000Z`, - metadata: { - videoCallUrl: "https://existing-daily-video-call-url.example.com", + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00Z`; + const bookingUid = "abc123"; + + const bookingScenario = await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, }, - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - credentialId: null, + ], + bookings: [ + { + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", }, - ], + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + rescheduleUid: bookingUid, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, - ], - organizer, - }) - ); - - mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - videoMeetingData: { - id: "MOCK_ID", - password: "MOCK_PASS", - url: `http://mock-dailyvideo.example.com/meeting-1`, - }, - }); + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); - const reqBookingUser = "seatedAttendee"; + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); - const mockBookingData = getMockRequestDataForBooking({ - data: { - rescheduleUid: bookingUid, - eventTypeId: 1, - responses: { + const handleSeatsCall = spy.mock.calls[0][0]; + + expect(handleSeatsCall).toEqual( + expect.objectContaining({ + rescheduleUid: bookingUid, + bookerEmail: booker.email, + reqBookingUid: bookingUid, + reqBodyUser: reqBookingUser, + tAttendees: expect.any(Function), + additionalNotes: expect.anything(), + noEmail: undefined, + }) + ); + + const bookingScenarioEventType = bookingScenario.eventTypes[0]; + expect(handleSeatsCall.eventTypeInfo).toEqual( + expect.objectContaining({ + eventTitle: bookingScenarioEventType.title, + eventDescription: bookingScenarioEventType.description, + length: bookingScenarioEventType.length, + }) + ); + + expect(handleSeatsCall.eventType).toEqual( + expect.objectContaining({ + id: bookingScenarioEventType.id, + slug: bookingScenarioEventType.slug, + workflows: bookingScenarioEventType.workflows, + seatsPerTimeSlot: bookingScenarioEventType.seatsPerTimeSlot, + seatsShowAttendees: bookingScenarioEventType.seatsShowAttendees, + }) + ); + + expect(handleSeatsCall.evt).toEqual( + expect.objectContaining({ + startTime: bookingStartTime, + }) + ); + + expect(handleSeatsCall.invitee).toEqual([ + expect.objectContaining({ email: booker.email, name: booker.name, - location: { optionValue: "", value: BookingLocations.CalVideo }, - }, - bookingUid: bookingUid, - user: reqBookingUser, - }, + }), + ]); }); + }); - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, + describe("As an attendee", () => { + describe("Creating a new booking", () => { + test("Attendee should be added to existing seated event", () => { + return; + }); }); - - await handleNewBooking(req); - - const handleSeatsCall = spy.mock.calls[0][0]; - - expect(handleSeatsCall).toEqual( - expect.objectContaining({ - rescheduleUid: bookingUid, - bookerEmail: booker.email, - reqBookingUid: bookingUid, - reqBodyUser: reqBookingUser, - tAttendees: expect.any(Function), - additionalNotes: expect.anything(), - noEmail: undefined, - }) - ); - - const bookingScenarioEventType = bookingScenario.eventTypes[0]; - expect(handleSeatsCall.eventTypeInfo).toEqual( - expect.objectContaining({ - eventTitle: bookingScenarioEventType.title, - eventDescription: bookingScenarioEventType.description, - length: bookingScenarioEventType.length, - }) - ); - - expect(handleSeatsCall.eventType).toEqual( - expect.objectContaining({ - id: bookingScenarioEventType.id, - slug: bookingScenarioEventType.slug, - workflows: bookingScenarioEventType.workflows, - seatsPerTimeSlot: bookingScenarioEventType.seatsPerTimeSlot, - seatsShowAttendees: bookingScenarioEventType.seatsShowAttendees, - }) - ); - - expect(handleSeatsCall.evt).toEqual( - expect.objectContaining({ - startTime: bookingStartTime, - }) - ); - - expect(handleSeatsCall.invitee).toEqual([ - expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - ]); }); }); From 6c945e1b24cae68c4b3d61978b4ff2480b3ec7a6 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 20 Dec 2023 15:49:58 -0500 Subject: [PATCH 31/65] Clean up --- apps/web/pages/reschedule/[uid].tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index fda8065e505a00..fbfa33d963f934 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -84,12 +84,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // if booking event type is for a seated event and no seat reference uid is provided, throw not found if (booking?.eventType?.seatsPerTimeSlot && !maybeSeatReferenceUid) { - console.log( - "🚀 ~ file: [uid].tsx:87 ~ getServerSideProps ~ maybeSeatReferenceUid:", - maybeSeatReferenceUid - ); const userId = session?.user?.id; - console.log("🚀 ~ file: [uid].tsx:89 ~ getServerSideProps ~ userId:", userId); if (!userId && !seatReferenceUid) { return { From deeafa3a76fd19cc4b8d1e6b7e97cb887151dac1 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 3 Jan 2024 14:12:24 -0500 Subject: [PATCH 32/65] Create new seat test --- .../utils/bookingScenario/bookingScenario.ts | 22 ++- .../lib/handleSeats/test/handleSeats.test.ts | 133 +++++++++++++++++- 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 63ab59c9dfe8f3..b4a5018bb05c88 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -130,6 +130,7 @@ type WhiteListedBookingProps = { // 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> & WhiteListedBookingProps; @@ -322,7 +323,7 @@ async function addBookings(bookings: InputBooking[]) { ); } return { - uid: uuidv4(), + uid: booking.uid || uuidv4(), workflowReminders: [], references: [], title: "Test Booking Title", @@ -349,10 +350,20 @@ 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) => { + return { + ...attendee, + ...(attendee.bookingSeat && { + create: { + ...attendee.bookingSeat, + }, + }), + }; + }), }, }; } + return bookingCreate; }) ); @@ -506,7 +517,7 @@ export async function createBookingScenario(data: ScenarioData) { data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); - await addBookings(data.bookings); + const bookings = await addBookings(data.bookings); // mockBusyCalendarTimes([]); await addWebhooks(data.webhooks || []); // addPaymentMock(); @@ -1331,13 +1342,16 @@ export function getMockBookingReference( }; } -export function getMockBookingAttendee(attendee: Omit) { +export function getMockBookingAttendee( + attendee: Omit & { bookingSeat?: Prisma.BookingSeatCreateInput } +) { return { id: attendee.id, timeZone: attendee.timeZone, name: attendee.name, email: attendee.email, locale: attendee.locale, + bookingSeat: attendee.bookingSeat || null, }; } diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 0a7cc2a2f2043c..d8cf820472dd43 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -1,3 +1,5 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + import { describe, test, vi, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; @@ -11,6 +13,7 @@ import { mockSuccessfulVideoMeetingCreation, BookingLocations, getDate, + getMockBookingAttendee, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; @@ -372,8 +375,134 @@ describe("handleSeats", () => { describe("As an attendee", () => { describe("Creating a new booking", () => { - test("Attendee should be added to existing seated event", () => { - return; + test("Attendee should be added to existing seated event", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const bookingUid = "abc123"; + const bookingId = 1; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: bookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + // Booker's locale when the fresh booking happened earlier + locale: "en", + // Booker's timezone when the fresh booking happened earlier + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + const newAttendee = await prismaMock.attendee.findFirst({ + where: { + email: booker.email, + bookingId: bookingId, + }, + include: { + bookingSeat: true, + }, + }); + + // Check for the existence of the new attendee w/ booking seat + expect(newAttendee?.bookingSeat).toEqual( + expect.objectContaining({ + referenceUid: expect.any(String), + data: expect.any(Object), + bookingId: 1, + }) + ); }); }); }); From d3657c03971ea62416835938cd161e98acd2d6f0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 3 Jan 2024 14:46:25 -0500 Subject: [PATCH 33/65] Test when attendee already signs up for booking --- .../lib/handleSeats/test/handleSeats.test.ts | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index d8cf820472dd43..024a5c401b647d 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -379,8 +379,8 @@ describe("handleSeats", () => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; const booker = getBooker({ - email: "booker@example.com", - name: "Booker", + email: "seat2@example.com", + name: "Seat 2", }); const organizer = getOrganizer({ @@ -504,6 +504,117 @@ describe("handleSeats", () => { }) ); }); + + test("If attendee is already a part of the booking then throw an error", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "seat1@example.com", + name: "Seat 1", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const bookingUid = "abc123"; + const bookingId = 1; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: bookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@example.com", + // Booker's locale when the fresh booking happened earlier + locale: "en", + // Booker's timezone when the fresh booking happened earlier + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(() => handleNewBooking(req)).rejects.toThrowError("Already signed up for this booking."); + }); }); }); }); From 2f9efede44948cd44fbe8a01f7a9144d6423e75b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 3 Jan 2024 15:03:46 -0500 Subject: [PATCH 34/65] Type fix --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index b4a5018bb05c88..4c9aa97e9473e6 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -1343,7 +1343,9 @@ export function getMockBookingReference( } export function getMockBookingAttendee( - attendee: Omit & { bookingSeat?: Prisma.BookingSeatCreateInput } + attendee: Omit & { + bookingSeat?: Pick; + } ) { return { id: attendee.id, From f9a054127e317ee9fc47cdc76a46ea4df137167c Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 13:52:59 -0500 Subject: [PATCH 35/65] Test reschedule move attendee to existing booking --- .../utils/bookingScenario/bookingScenario.ts | 24 +- .../features/bookings/lib/handleNewBooking.ts | 2 +- .../lib/handleSeats/test/handleSeats.test.ts | 411 +++++++++++++++++- 3 files changed, 424 insertions(+), 13 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 4c9aa97e9473e6..f54f92b8a89355 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -288,9 +288,10 @@ async function addBookingsToDb( // Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock log.silly("Handling Prismock bug-3"); const fixedBookings = bookings.map((booking) => { - const startTime = getDateObj(booking.startTime); - const endTime = getDateObj(booking.endTime); - return { ...booking, startTime, endTime }; + // const startTime = getDateObj(booking.startTime); + // const endTime = getDateObj(booking.endTime); + // return { ...booking, startTime, endTime }; + return { ...booking }; }); await prismock.booking.createMany({ @@ -351,14 +352,17 @@ async function addBookings(bookings: InputBooking[]) { //@ts-ignore createMany: { data: booking.attendees.map((attendee) => { - return { - ...attendee, - ...(attendee.bookingSeat && { - create: { - ...attendee.bookingSeat, + if (attendee.bookingSeat) { + const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee; + return { + ...attendeeWithoutBookingSeat, + bookingSeat: { + create: { ...bookingSeat, bookingId: booking.id }, }, - }), - }; + }; + } else { + return attendee; + } }), }, }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index fedf79e9fa3624..2fd96e1af70e08 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1139,7 +1139,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({ diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 024a5c401b647d..b10c51c1175094 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -440,7 +440,7 @@ describe("handleSeats", () => { email: "seat1@test.com", // Booker's locale when the fresh booking happened earlier locale: "en", - // Booker's timezone when the fresh booking happened earlier + timeZone: "America/Toronto", bookingSeat: { referenceUid: "booking-seat-1", @@ -570,7 +570,7 @@ describe("handleSeats", () => { email: "seat1@example.com", // Booker's locale when the fresh booking happened earlier locale: "en", - // Booker's timezone when the fresh booking happened earlier + timeZone: "America/Toronto", bookingSeat: { referenceUid: "booking-seat-1", @@ -616,5 +616,412 @@ describe("handleSeats", () => { await expect(() => handleNewBooking(req)).rejects.toThrowError("Already signed up for this booking."); }); }); + + describe("Rescheduling a booking", () => { + test("When rescheduling to an existing booking, move attendee", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "seat2@example.com", + name: "Seat 2", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const secondBookingUid = "def456"; + const secondBookingId = 2; + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + const attendeeToReschedule = getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + // Booker's locale when the fresh booking happened earlier + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + attendeeToReschedule, + ], + }, + { + id: secondBookingId, + uid: secondBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: secondBookingStartTime, + endTime: `${plus2DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 3, + name: "Seat 3", + email: "seat3@test.com", + // Booker's locale when the fresh booking happened earlier + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-3", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: "booking-seat-2", + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + // Ensure that the attendee is no longer a part of the old booking + const oldBookingAttendees = await prismaMock.attendee.findMany({ + where: { + bookingId: firstBookingId, + }, + select: { + id: true, + }, + }); + + expect(oldBookingAttendees).not.toContain({ id: attendeeToReschedule.id }); + expect(oldBookingAttendees).toHaveLength(1); + + // Ensure that the attendee is a part of the new booking + const newBookingAttendees = await prismaMock.attendee.findMany({ + where: { + bookingId: secondBookingId, + }, + select: { + id: true, + }, + }); + + expect(newBookingAttendees).toContainEqual({ id: attendeeToReschedule.id }); + expect(newBookingAttendees).toHaveLength(2); + + // Ensure that the attendeeSeat is also updated to the new booking + const attendeeSeat = await prismaMock.bookingSeat.findFirst({ + where: { + attendeeId: attendeeToReschedule.id, + }, + select: { + bookingId: true, + }, + }); + + expect(attendeeSeat?.bookingId).toEqual(secondBookingId); + }); + + test("When rescheduling to an empty timeslot, create a new booking", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "seat2@example.com", + name: "Seat 2", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const secondBookingUid = "def456"; + const secondBookingId = 2; + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + const attendeeToReschedule = getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + // Booker's locale when the fresh booking happened earlier + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + attendeeToReschedule, + ], + }, + // { + // id: secondBookingId, + // uid: secondBookingUid, + // eventTypeId: 1, + // status: BookingStatus.ACCEPTED, + // startTime: secondBookingStartTime, + // endTime: `${plus2DateString}T05:15:00.000Z`, + // metadata: { + // videoCallUrl: "https://existing-daily-video-call-url.example.com", + // }, + // references: [ + // { + // type: appStoreMetadata.dailyvideo.type, + // uid: "MOCK_ID", + // meetingId: "MOCK_ID", + // meetingPassword: "MOCK_PASS", + // meetingUrl: "http://mock-dailyvideo.example.com", + // credentialId: null, + // }, + // ], + // attendees: [ + // getMockBookingAttendee({ + // id: 3, + // name: "Seat 3", + // email: "seat3@test.com", + // // Booker's locale when the fresh booking happened earlier + // locale: "en", + + // timeZone: "America/Toronto", + // bookingSeat: { + // referenceUid: "booking-seat-3", + // data: {}, + // }, + // }), + // ], + // }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: "booking-seat-2", + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + // Ensure that the attendee is no longer a part of the old booking + const oldBookingAttendees = await prismaMock.attendee.findMany({ + where: { + bookingId: firstBookingId, + }, + select: { + id: true, + }, + }); + + expect(oldBookingAttendees).not.toContain({ id: attendeeToReschedule.id }); + expect(oldBookingAttendees).toHaveLength(1); + + expect(createdBooking.id).not.toEqual(firstBookingId); + + const attendees = await prismaMock.attendee.findMany({}); + + // Ensure that the attendeeSeat is also updated to the new booking + const attendeeSeat = await prismaMock.bookingSeat.findFirst({ + where: { + email: booker.email, + }, + select: { + bookingId: true, + }, + }); + + expect(attendeeSeat?.bookingId).toEqual(createdBooking.id); + }); + }); }); }); From 2e8e599b704bc55d3c912cf535e47ac33cadd77e Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 14:18:08 -0500 Subject: [PATCH 36/65] On reschedule create new booking --- .../lib/handleSeats/test/handleSeats.test.ts | 105 ++++++------------ 1 file changed, 31 insertions(+), 74 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index b10c51c1175094..5e9612b93145e3 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -621,9 +621,22 @@ describe("handleSeats", () => { test("When rescheduling to an existing booking, move attendee", async () => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "seat2@example.com", + const attendeeToReschedule = getMockBookingAttendee({ + id: 2, name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }); + + const booker = getBooker({ + email: attendeeToReschedule.email, + name: attendeeToReschedule.name, }); const organizer = getOrganizer({ @@ -644,19 +657,6 @@ describe("handleSeats", () => { const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; - const attendeeToReschedule = getMockBookingAttendee({ - id: 2, - name: "Seat 2", - email: "seat2@test.com", - locale: "en", - - timeZone: "America/Toronto", - bookingSeat: { - referenceUid: "booking-seat-2", - data: {}, - }, - }); - await createBookingScenario( getScenarioData({ eventTypes: [ @@ -828,9 +828,22 @@ describe("handleSeats", () => { test("When rescheduling to an empty timeslot, create a new booking", async () => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "seat2@example.com", + const attendeeToReschedule = getMockBookingAttendee({ + id: 2, name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }); + + const booker = getBooker({ + email: attendeeToReschedule.email, + name: attendeeToReschedule.name, }); const organizer = getOrganizer({ @@ -845,25 +858,10 @@ describe("handleSeats", () => { const firstBookingUid = "abc123"; const firstBookingId = 1; - const secondBookingUid = "def456"; - const secondBookingId = 2; const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; - const attendeeToReschedule = getMockBookingAttendee({ - id: 2, - name: "Seat 2", - email: "seat2@test.com", - locale: "en", - - timeZone: "America/Toronto", - bookingSeat: { - referenceUid: "booking-seat-2", - data: {}, - }, - }); - await createBookingScenario( getScenarioData({ eventTypes: [ @@ -919,42 +917,6 @@ describe("handleSeats", () => { attendeeToReschedule, ], }, - // { - // id: secondBookingId, - // uid: secondBookingUid, - // eventTypeId: 1, - // status: BookingStatus.ACCEPTED, - // startTime: secondBookingStartTime, - // endTime: `${plus2DateString}T05:15:00.000Z`, - // metadata: { - // videoCallUrl: "https://existing-daily-video-call-url.example.com", - // }, - // references: [ - // { - // type: appStoreMetadata.dailyvideo.type, - // uid: "MOCK_ID", - // meetingId: "MOCK_ID", - // meetingPassword: "MOCK_PASS", - // meetingUrl: "http://mock-dailyvideo.example.com", - // credentialId: null, - // }, - // ], - // attendees: [ - // getMockBookingAttendee({ - // id: 3, - // name: "Seat 3", - // email: "seat3@test.com", - // // Booker's locale when the fresh booking happened earlier - // locale: "en", - - // timeZone: "America/Toronto", - // bookingSeat: { - // referenceUid: "booking-seat-3", - // data: {}, - // }, - // }), - // ], - // }, ], organizer, }) @@ -1008,15 +970,10 @@ describe("handleSeats", () => { expect(createdBooking.id).not.toEqual(firstBookingId); - const attendees = await prismaMock.attendee.findMany({}); - // Ensure that the attendeeSeat is also updated to the new booking const attendeeSeat = await prismaMock.bookingSeat.findFirst({ where: { - email: booker.email, - }, - select: { - bookingId: true, + bookingId: createdBooking.id, }, }); From 45eb62ddba3e51149e962655e94c6c6969079dfd Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 14:26:00 -0500 Subject: [PATCH 37/65] Test on last attendee cancel booking --- .../lib/handleSeats/test/handleSeats.test.ts | 149 +++++++++++++++++- 1 file changed, 146 insertions(+), 3 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 5e9612b93145e3..eb1df7a4d294fd 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -970,14 +970,157 @@ describe("handleSeats", () => { expect(createdBooking.id).not.toEqual(firstBookingId); - // Ensure that the attendeeSeat is also updated to the new booking - const attendeeSeat = await prismaMock.bookingSeat.findFirst({ + // Ensure that the attendee and bookingSeat is also updated to the new booking + const attendee = await prismaMock.attendee.findFirst({ where: { bookingId: createdBooking.id, }, + include: { + bookingSeat: true, + }, + }); + + expect(attendee?.bookingSeat?.bookingId).toEqual(createdBooking.id); + }); + + test("When last attendee is rescheduled, delete old booking", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const attendeeToReschedule = getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }); + + const booker = getBooker({ + email: attendeeToReschedule.email, + name: attendeeToReschedule.name, + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [attendeeToReschedule], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: "booking-seat-2", + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + // Ensure that the old booking is cancelled + const oldBooking = await prismaMock.booking.findFirst({ + where: { + id: firstBookingId, + }, + select: { + status: true, + }, + }); + + expect(oldBooking?.status).toEqual(BookingStatus.CANCELLED); + + // Ensure that the attendee and attendeeSeat is also updated to the new booking + const attendeeSeat = await prismaMock.attendee.findFirst({ + where: { + bookingId: createdBooking.id, + }, + include: { + bookingSeat: true, + }, }); - expect(attendeeSeat?.bookingId).toEqual(createdBooking.id); + expect(attendeeSeat?.bookingSeat?.bookingId).toEqual(createdBooking.id); }); }); }); From 6fc8a6589200e3d8c54d175586b17283b09fbff4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 15:12:33 -0500 Subject: [PATCH 38/65] Owner reschedule to new time slot --- .../lib/handleSeats/test/handleSeats.test.ts | 173 +++++++++++++++++- 1 file changed, 168 insertions(+), 5 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index eb1df7a4d294fd..b9436a1d423b41 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -438,7 +438,6 @@ describe("handleSeats", () => { id: 1, name: "Seat 1", email: "seat1@test.com", - // Booker's locale when the fresh booking happened earlier locale: "en", timeZone: "America/Toronto", @@ -568,7 +567,6 @@ describe("handleSeats", () => { id: 1, name: "Seat 1", email: "seat1@example.com", - // Booker's locale when the fresh booking happened earlier locale: "en", timeZone: "America/Toronto", @@ -700,7 +698,6 @@ describe("handleSeats", () => { id: 1, name: "Seat 1", email: "seat1@test.com", - // Booker's locale when the fresh booking happened earlier locale: "en", timeZone: "America/Toronto", @@ -737,7 +734,6 @@ describe("handleSeats", () => { id: 3, name: "Seat 3", email: "seat3@test.com", - // Booker's locale when the fresh booking happened earlier locale: "en", timeZone: "America/Toronto", @@ -905,7 +901,6 @@ describe("handleSeats", () => { id: 1, name: "Seat 1", email: "seat1@test.com", - // Booker's locale when the fresh booking happened earlier locale: "en", timeZone: "America/Toronto", @@ -1124,4 +1119,172 @@ describe("handleSeats", () => { }); }); }); + + describe("As an owner", () => { + describe("Rescheduling a booking", () => { + test("When rescheduling to new timeslot, ensure all attendees are moved", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const secondBookingUid = "def456"; + const secondBookingId = 2; + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 3, + name: "Seat 3", + email: "seat3@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-3", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: firstBookingUid, + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + req.userId = organizer.id; + + const rescheduledBooking = await handleNewBooking(req); + + // Ensure that the booking has been moved + expect(rescheduledBooking?.startTime).toEqual(secondBookingStartTime); + expect(rescheduledBooking?.endTime).toEqual(secondBookingEndTime); + + // Ensure that the attendees are still a part of the event + const attendees = await prismaMock.attendee.findMany({ + where: { + bookingId: rescheduledBooking?.id, + }, + }); + + expect(attendees).toHaveLength(3); + + // Ensure that the bookingSeats are still a part of the event + const bookingSeats = await prismaMock.bookingSeat.findMany({ + where: { + bookingId: rescheduledBooking?.id, + }, + }); + + expect(bookingSeats).toHaveLength(3); + }); + }); + }); }); From 294152dc588eb3e5765612fbba6663c6df1bf378 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 16:05:10 -0500 Subject: [PATCH 39/65] Owner rescheduling, merge two bookings together --- .../lib/handleSeats/test/handleSeats.test.ts | 213 +++++++++++++++++- 1 file changed, 211 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index b9436a1d423b41..ad9ae1fcb815a6 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -1142,8 +1142,6 @@ describe("handleSeats", () => { const firstBookingUid = "abc123"; const firstBookingId = 1; - const secondBookingUid = "def456"; - const secondBookingId = 2; const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; @@ -1285,6 +1283,217 @@ describe("handleSeats", () => { expect(bookingSeats).toHaveLength(3); }); + + test("When rescheduling to existing booking, merge attendees ", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.00Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const secondBookingUid = "def456"; + const secondBookingId = 2; + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 4, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }), + ], + }, + { + id: secondBookingId, + uid: secondBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: secondBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 3, + name: "Seat 3", + email: "seat3@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-3", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 4, + name: "Seat 4", + email: "seat4@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-4", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: firstBookingUid, + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + req.userId = organizer.id; + + const rescheduledBooking = await handleNewBooking(req); + + // Ensure that the booking has been moved + expect(rescheduledBooking?.startTime).toEqual(secondBookingStartTime); + expect(rescheduledBooking?.endTime).toEqual(secondBookingEndTime); + + // Ensure that the attendees are still a part of the event + const attendees = await prismaMock.attendee.findMany({ + where: { + bookingId: rescheduledBooking?.id, + }, + }); + + expect(attendees).toHaveLength(4); + + // Ensure that the bookingSeats are still a part of the event + const bookingSeats = await prismaMock.bookingSeat.findMany({ + where: { + bookingId: rescheduledBooking?.id, + }, + }); + + expect(bookingSeats).toHaveLength(4); + + // Ensure that the previous booking has been canceled + const originalBooking = await prismaMock.booking.findFirst({ + where: { + id: firstBookingId, + }, + select: { + status: true, + }, + }); + + expect(originalBooking?.status).toEqual(BookingStatus.CANCELLED); + }); }); }); }); From 27b04fb03a3054392b28201802ad3a8e9f4de39a Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 16:38:41 -0500 Subject: [PATCH 40/65] Test: when merging more than available seats, then fail --- .../lib/handleSeats/test/handleSeats.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index ad9ae1fcb815a6..72ab13255b2d9c 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -1494,6 +1494,185 @@ describe("handleSeats", () => { expect(originalBooking?.status).toEqual(BookingStatus.CANCELLED); }); + test("When merging more attendees than seats, fail ", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const firstBookingStartTime = `${plus1DateString}T04:00:00.00Z`; + const firstBookingUid = "abc123"; + const firstBookingId = 1; + + const secondBookingUid = "def456"; + const secondBookingId = 2; + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: firstBookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: firstBookingUid, + eventTypeId: 1, + userId: organizer.id, + status: BookingStatus.ACCEPTED, + startTime: firstBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }), + ], + }, + { + id: secondBookingId, + uid: secondBookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: secondBookingStartTime, + endTime: secondBookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 3, + name: "Seat 3", + email: "seat3@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-3", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 4, + name: "Seat 4", + email: "seat4@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-4", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + rescheduleUid: firstBookingUid, + start: secondBookingStartTime, + end: secondBookingEndTime, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + req.userId = organizer.id; + + // const rescheduledBooking = await handleNewBooking(req); + await expect(() => handleNewBooking(req)).rejects.toThrowError( + "Booking does not have enough available seats" + ); + }); }); }); }); From d030f61c7e8d256310ef5b99a64650567207a238 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 4 Jan 2024 16:41:33 -0500 Subject: [PATCH 41/65] Test: fail when event is full --- .../lib/handleSeats/test/handleSeats.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 72ab13255b2d9c..99cc2de968924c 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -613,6 +613,128 @@ describe("handleSeats", () => { await expect(() => handleNewBooking(req)).rejects.toThrowError("Already signed up for this booking."); }); + + test("If event is already full, fail", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "seat3@example.com", + name: "Seat 3", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00.000Z`; + const bookingUid = "abc123"; + const bookingId = 1; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: bookingId, + slug: "seated-event", + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + seatsPerTimeSlot: 2, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: 1, + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + getMockBookingAttendee({ + id: 2, + name: "Seat 2", + email: "seat2@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-2", + data: {}, + }, + }), + ], + }, + ], + organizer, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(() => handleNewBooking(req)).rejects.toThrowError("Booking seats are full"); + }); }); describe("Rescheduling a booking", () => { From 0e4d03f75fef995ea7ee034bde7bdd1ccb767ade Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 5 Jan 2024 10:11:21 -0500 Subject: [PATCH 42/65] Remove duplicate E2E tests --- apps/web/playwright/booking-seats.e2e.ts | 297 +---------------------- 1 file changed, 3 insertions(+), 294 deletions(-) diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts index af0c69bca8364f..5e0d62d60cf074 100644 --- a/apps/web/playwright/booking-seats.e2e.ts +++ b/apps/web/playwright/booking-seats.e2e.ts @@ -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, From 8d27cff430a1c5d24386b3a6e1c6af096e4609fd Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Fri, 5 Jan 2024 11:24:35 -0500 Subject: [PATCH 43/65] Clean up --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 2 +- packages/lib/server/maybeGetBookingUidFromSeat.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index f54f92b8a89355..968573821f07f7 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -521,7 +521,7 @@ export async function createBookingScenario(data: ScenarioData) { data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); - const bookings = await addBookings(data.bookings); + await addBookings(data.bookings); // mockBusyCalendarTimes([]); await addWebhooks(data.webhooks || []); // addPaymentMock(); diff --git a/packages/lib/server/maybeGetBookingUidFromSeat.ts b/packages/lib/server/maybeGetBookingUidFromSeat.ts index be1be697daa8a6..a6a4b8587c4217 100644 --- a/packages/lib/server/maybeGetBookingUidFromSeat.ts +++ b/packages/lib/server/maybeGetBookingUidFromSeat.ts @@ -1,7 +1,6 @@ import type { PrismaClient } from "@calcom/prisma"; export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) { - console.log("🚀 ~ file: maybeGetBookingUidFromSeat.ts:4 ~ maybeGetBookingUidFromSeat ~ uid:", uid); // Look bookingUid in bookingSeat const bookingSeat = await prisma.bookingSeat.findUnique({ where: { @@ -16,10 +15,6 @@ export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: stri }, }, }); - console.log( - "🚀 ~ file: maybeGetBookingUidFromSeat.ts:18 ~ maybeGetBookingUidFromSeat ~ bookingSeat:", - bookingSeat - ); if (bookingSeat) return { uid: bookingSeat.booking.uid, seatReferenceUid: uid }; return { uid }; } From 758224e51ad0d1c931d9cbba141780743a0bb2cf Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 9 Jan 2024 16:26:33 -0500 Subject: [PATCH 44/65] Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent` --- packages/features/bookings/lib/handleNewBooking.ts | 4 ++-- .../handleSeats/reschedule/owner/combineTwoSeatedBookings.ts | 4 ++-- .../reschedule/owner/moveSeatedBookingToNewTimeSlot.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 8e3238dc8dde86..e93253f5a6e5d4 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -858,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) { @@ -1659,7 +1659,7 @@ async function handler( ); } - addVideoCallDataToEvt(originalRescheduledBooking.references, evt); + addVideoCallDataToEvent(originalRescheduledBooking.references, evt); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index fca51619439934..cf02914f0e2319 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -9,7 +9,7 @@ import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; -import { addVideoCallDataToEvt, findBookingQuery } from "../../../handleNewBooking"; +import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking"; import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types"; const combineTwoSeatedBookings = async ( @@ -117,7 +117,7 @@ const combineTwoSeatedBookings = async ( evt.attendees = updatedBookingAttendees; - evt = addVideoCallDataToEvt(updatedNewBooking.references, evt); + evt = addVideoCallDataToEvent(updatedNewBooking.references, evt); const copyEvent = cloneDeep(evt); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 9450bbfc97840f..0dcd16c605972a 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -6,7 +6,7 @@ import { sendRescheduledEmails } from "@calcom/emails"; import prisma from "@calcom/prisma"; import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar"; -import { addVideoCallDataToEvt, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking"; +import { addVideoCallDataToEvent, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking"; import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking"; import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types"; @@ -45,7 +45,7 @@ const moveSeatedBookingToNewTimeSlot = async ( }, }); - evt = addVideoCallDataToEvt(newBooking.references, evt); + evt = addVideoCallDataToEvent(newBooking.references, evt); const copyEvent = cloneDeep(evt); From 9bd94947ece8a8f7278d57424e76c0c635a239eb Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 9 Jan 2024 16:34:08 -0500 Subject: [PATCH 45/65] Refactor `calcAppsStatus` --- packages/features/bookings/lib/handleNewBooking.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index e93253f5a6e5d4..7b39d79820bd54 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -889,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, @@ -917,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) { From 649dd1fd0d6df98dfe77424b7713b7bf2aa178e0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 9 Jan 2024 16:47:02 -0500 Subject: [PATCH 46/65] Assign `evt` to resutl of `addVideoCallDataToEvent` --- packages/features/bookings/lib/handleNewBooking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7b39d79820bd54..28493495c50a6c 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1410,7 +1410,7 @@ async function handler( // For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0. const iCalSequence = getICalSequence(originalRescheduledBooking); - const evt: CalendarEvent = { + let evt: CalendarEvent = { bookerUrl: eventType.team ? await getBookerBaseUrl({ organizationId: eventType.team.parentId }) : await getBookerBaseUrl(organizerUser), @@ -1658,7 +1658,7 @@ async function handler( ); } - addVideoCallDataToEvent(originalRescheduledBooking.references, evt); + evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { From adddd1bb77c5134bca19ae244bcfc9b49c025a15 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Tue, 9 Jan 2024 17:10:44 -0500 Subject: [PATCH 47/65] Use prisma.transaction when moving attendees --- .../reschedule/attendee/attendeeRescheduleSeatedBooking.ts | 6 +++--- .../reschedule/owner/combineTwoSeatedBookings.ts | 2 +- .../lib/handleSeats/reschedule/rescheduleSeatedBooking.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index efa3023ac807de..31d289e9577e87 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -56,8 +56,8 @@ const attendeeRescheduleSeatedBooking = async ( // 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({ + await prisma.$transaction([ + prisma.attendee.update({ where: { id: seatAttendee.id, }, @@ -65,7 +65,7 @@ const attendeeRescheduleSeatedBooking = async ( bookingId: newTimeSlotBooking.id, }, }), - await prisma.bookingSeat.update({ + prisma.bookingSeat.update({ where: { id: bookingSeat.id, }, diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index cf02914f0e2319..a002050a6dfab2 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -80,7 +80,7 @@ const combineTwoSeatedBookings = async ( ); } - await Promise.all([ + await prisma.$transaction([ ...moveAttendeeCalls, // Delete any attendees that are already a part of that new time slot booking prisma.attendee.deleteMany({ diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index d3bcca48180ddf..d046818db879ec 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -173,8 +173,8 @@ const rescheduleSeatedBooking = async ( // 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({ + await prisma.$transaction([ + prisma.attendee.update({ where: { id: seatAttendee.id, }, @@ -182,7 +182,7 @@ const rescheduleSeatedBooking = async ( bookingId: newTimeSlotBooking.id, }, }), - await prisma.bookingSeat.update({ + prisma.bookingSeat.update({ where: { id: bookingSeat.id, }, From c6e5853e2cf91b9f3adb8dd3da859b2513b86a27 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 10 Jan 2024 09:53:17 -0500 Subject: [PATCH 48/65] Clean create seat call --- .../bookings/lib/handleSeats/create/createNewSeat.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index c5f03697810a55..924100498396a1 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -61,6 +61,8 @@ const createNewSeat = async ( const attendeeUniqueId = uuid(); + const inviteeToAdd = invitee[0]; + await prisma.booking.update({ where: { uid: reqBookingUid, @@ -71,10 +73,10 @@ const createNewSeat = async ( data: { attendees: { create: { - email: invitee[0].email, - name: invitee[0].name, - timeZone: invitee[0].timeZone, - locale: invitee[0].language.locale, + email: inviteeToAdd.email, + name: inviteeToAdd.name, + timeZone: inviteeToAdd.timeZone, + locale: inviteeToAdd.language.locale, bookingSeat: { create: { referenceUid: attendeeUniqueId, From 18259441a583312f3ad69611e43e81a855720823 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Wed, 10 Jan 2024 11:04:32 -0500 Subject: [PATCH 49/65] Use ErrorCode enum --- apps/web/public/static/locales/en/common.json | 5 +++++ .../bookings/lib/handleSeats/create/createNewSeat.ts | 7 ++++--- packages/features/bookings/lib/handleSeats/handleSeats.ts | 5 +++-- .../reschedule/owner/combineTwoSeatedBookings.ts | 3 ++- packages/lib/errorCodes.ts | 6 ++++++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 188d9d21df47ec..a15b8c83ea2f2d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2190,5 +2190,10 @@ "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", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 924100498396a1..ea5bbc3fbed598 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -8,6 +8,7 @@ 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"; @@ -45,7 +46,7 @@ const createNewSeat = async ( evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] }; if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) { - throw new HttpError({ statusCode: 409, message: "Booking seats are full" }); + throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull }); } const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video")); @@ -172,10 +173,10 @@ const createNewSeat = async ( }); if (!eventTypePaymentAppCredential) { - throw new HttpError({ statusCode: 400, message: "Missing payment credentials" }); + throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentCredential }); } if (!eventTypePaymentAppCredential?.appId) { - throw new HttpError({ statusCode: 400, message: "Missing payment app id" }); + throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentAppId }); } const payment = await handlePayment( diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 75f93ab4bbf645..ecb8909c6d5520 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -2,6 +2,7 @@ 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"; @@ -62,7 +63,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { }); if (!seatedBooking) { - throw new HttpError({ statusCode: 404, message: "Could not find booking" }); + throw new HttpError({ statusCode: 404, message: ErrorCode.BookingNotFound }); } // See if attendee is already signed up for timeslot @@ -70,7 +71,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) && dayjs.utc(seatedBooking.startTime).format() === evt.startTime ) { - throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." }); + throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking }); } // There are two paths here, reschedule a booking with seats and booking seats without reschedule diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index a002050a6dfab2..d4b5b1ab19e097 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -4,6 +4,7 @@ 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"; @@ -52,7 +53,7 @@ const combineTwoSeatedBookings = async ( attendeesToMove.length + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length > eventType.seatsPerTimeSlot ) { - throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" }); + throw new HttpError({ statusCode: 409, message: ErrorCode.NotEnoughAvailableSeats }); } const moveAttendeeCalls = []; diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 07c51ae693b6fe..722957f5fc20c1 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -6,4 +6,10 @@ 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", + UpdateDatedBookingNotFound = "update_dated_booking_not_found_error", + NotEnoughAvailableSeats = "not_enough_available_seats_error", } From 13090ae8fd55eedbed7e026b12c75f574681c972 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 11 Jan 2024 12:35:58 -0500 Subject: [PATCH 50/65] Use attendeeRescheduledSeatedBooking function --- .../reschedule/rescheduleSeatedBooking.ts | 109 +++--------------- 1 file changed, 14 insertions(+), 95 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index d046818db879ec..fd511e35f8634d 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -4,20 +4,18 @@ import type { RescheduleSeatedBookingObject, SeatAttendee, } from "bookings/lib/handleSeats/types"; -// eslint-disable-next-line no-restricted-imports -import { cloneDeep } from "lodash"; +// eslint-disable-next-line no-restricted-imports import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; -import { sendRescheduledSeatEmail } from "@calcom/emails"; 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, findBookingQuery } from "../../handleNewBooking"; +import { refreshCredentials } from "../../handleNewBooking"; import type { createLoggerWithEventDetails } from "../../handleNewBooking"; -import lastAttendeeDeleteBooking from "../lib/lastAttendeeDeleteBooking"; +import attendeeRescheduleSeatedBooking from "./attendee/attendeeRescheduleSeatedBooking"; import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking"; const rescheduleSeatedBooking = async ( @@ -27,19 +25,10 @@ const rescheduleSeatedBooking = async ( resultBooking: HandleSeatsResultBooking | null, loggerWithEventDetails: ReturnType ) => { - const { - evt, - eventType, - allCredentials, - organizerUser, - bookerEmail, - tAttendees, - bookingSeat, - reqUserId, - rescheduleUid, - } = rescheduleSeatedBookingObject; - - let { originalRescheduledBooking } = rescheduleSeatedBookingObject; + 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({ @@ -137,83 +126,13 @@ const rescheduleSeatedBooking = async ( // seatAttendee is null when the organizer is rescheduling. const seatAttendee: SeatAttendee | 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 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); - - resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid }; + resultBooking = attendeeRescheduleSeatedBooking( + rescheduleSeatedBookingObject, + seatAttendee, + newTimeSlotBooking, + originalBookingEvt, + eventManager + ); } return resultBooking; From 4d8d3fa01e25ba0bd0f380d0507c95f56fd3abd3 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 11 Jan 2024 12:40:17 -0500 Subject: [PATCH 51/65] Await function --- .../lib/handleSeats/reschedule/rescheduleSeatedBooking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index fd511e35f8634d..b686a10d12b5c3 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -126,7 +126,7 @@ const rescheduleSeatedBooking = async ( // seatAttendee is null when the organizer is rescheduling. const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null; if (seatAttendee) { - resultBooking = attendeeRescheduleSeatedBooking( + resultBooking = await attendeeRescheduleSeatedBooking( rescheduleSeatedBookingObject, seatAttendee, newTimeSlotBooking, From 7f972fc2aec37fb90b15e400c7145942a810d3c1 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 11 Jan 2024 13:03:08 -0500 Subject: [PATCH 52/65] Prevent double triggering of workflows --- .../bookings/lib/handleSeats/handleSeats.ts | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index ecb8909c6d5520..e34db97dc0fa29 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -87,45 +87,46 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking); } - // Here we should handle every after action that needs to be done after booking creation + // 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 })); + } - // 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, + }; - 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 }); + await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); + } return resultBooking; }; From 9ec2f0e63df4d0e5469e93650fc23e384b471f1c Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 11 Jan 2024 13:04:49 -0500 Subject: [PATCH 53/65] Use inviteeToAdd in createNewSeat --- .../features/bookings/lib/handleSeats/create/createNewSeat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index ea5bbc3fbed598..2b381a405c5053 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -133,7 +133,7 @@ const createNewSeat = async ( } await sendScheduledSeatsEmails( copyEvent, - invitee[0], + inviteeToAdd, newSeat, !!eventType.seatsShowAttendees, isHostConfirmationEmailsDisabled, From 6c31a03103e4074ec8748cd938bc0c66d48da4c0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 11 Jan 2024 13:08:37 -0500 Subject: [PATCH 54/65] Remove unused error code --- packages/lib/errorCodes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 722957f5fc20c1..8b2a685e54c72c 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -10,6 +10,5 @@ export enum ErrorCode { BookingSeatsFull = "booking_seats_full_error", MissingPaymentCredential = "missing_payment_credential_error", MissingPaymentAppId = "missing_payment_app_id_error", - UpdateDatedBookingNotFound = "update_dated_booking_not_found_error", NotEnoughAvailableSeats = "not_enough_available_seats_error", } From e75c85b650cdd7fbff9f392707d9f77fa9f641d1 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 11:20:00 -0500 Subject: [PATCH 55/65] Remove old handleSeats file --- packages/features/bookings/lib/handleSeats.ts | 796 ------------------ 1 file changed, 796 deletions(-) delete mode 100644 packages/features/bookings/lib/handleSeats.ts diff --git a/packages/features/bookings/lib/handleSeats.ts b/packages/features/bookings/lib/handleSeats.ts deleted file mode 100644 index 9f993ebd658183..00000000000000 --- a/packages/features/bookings/lib/handleSeats.ts +++ /dev/null @@ -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[], - 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>; - 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; - 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 & { - 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 | 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; From 25a1ee7fb181bd6c15b8999386c2154ba5bd1901 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:05:59 -0500 Subject: [PATCH 56/65] Type fix --- packages/features/bookings/lib/handleSeats/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 06f773b9768440..748767455ea33c 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -2,7 +2,7 @@ import type { Prisma } from "@prisma/client"; import type { AppsStatus } from "@calcom/types/Calendar"; -import type { Booking } from "../handleNewBooking"; +import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking"; export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; From a8404cdd8205d29e41abce403ce48503f2731c5b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:06:53 -0500 Subject: [PATCH 57/65] Type fix --- .../reschedule/rescheduleSeatedBooking.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index b686a10d12b5c3..7396f2e4bb42a1 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -1,10 +1,3 @@ -import type { - HandleSeatsResultBooking, - SeatedBooking, - RescheduleSeatedBookingObject, - SeatAttendee, -} from "bookings/lib/handleSeats/types"; - // eslint-disable-next-line no-restricted-imports import EventManager from "@calcom/core/EventManager"; import dayjs from "@calcom/dayjs"; @@ -15,6 +8,12 @@ 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"; From f917220e6e81fc2e0bd76fdac17e02e54fb73153 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:20:57 -0500 Subject: [PATCH 58/65] Type fix --- .../bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts index a31db00f5af299..4c1dc53dfa8691 100644 --- a/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts +++ b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts @@ -14,11 +14,11 @@ import type { OriginalRescheduledBooking } from "../../handleNewBooking"; and any calendar or video integrations */ const lastAttendeeDeleteBooking = async ( originalRescheduledBooking: OriginalRescheduledBooking, - filteredAttendees: Partial[], + filteredAttendees: Partial[] | undefined, originalBookingEvt?: CalendarEvent ) => { let deletedReferences = false; - if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) { + if ((!filteredAttendees || filteredAttendees.length === 0) && originalRescheduledBooking) { const integrationsToDelete = []; for (const reference of originalRescheduledBooking.references) { From 3e997136a6e591a59f92b0c37fa2964231a29971 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:27:13 -0500 Subject: [PATCH 59/65] Type fix --- .../reschedule/owner/ownerRescheduleSeatedBooking.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts index c2fa04069b10c8..03a03a1dc64b60 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts @@ -21,8 +21,7 @@ const ownerRescheduleSeatedBooking = async ( ) => { const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject; const { evt } = rescheduleSeatedBookingObject; - // Moving forward in this block is the owner making changes to the booking. All attendees should be affected - evt.attendees = originalRescheduledBooking.attendees.map((attendee) => { + evt.attendees = originalRescheduledBooking?.attendees.map((attendee) => { return { name: attendee.name, email: attendee.email, From e740806e2f73fd71db940fb7e25404755fec00ca Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:38:01 -0500 Subject: [PATCH 60/65] Type fix --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 11ae79de421f83..ae1d44b36a576f 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2,7 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; import prismock from "../../../../../tests/libs/__mocks__/prisma"; -import type { BookingReference, Attendee, Booking, Membership } from "@prisma/client"; +import type { BookingReference, Attendee, Booking, Membership, BookingSeat } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client"; import type Stripe from "stripe"; @@ -137,7 +137,7 @@ type WhiteListedBookingProps = { endTime: string; title?: string; status: BookingStatus; - attendees?: { email: string }[]; + attendees?: { email: string; bookingSeat: BookingSeat }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; From 0ddcde338befcc0c6b887b92ccbedf0cdd2595ed Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:46:17 -0500 Subject: [PATCH 61/65] Type fix --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index ae1d44b36a576f..baa9ad0c1e96b1 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -137,7 +137,7 @@ type WhiteListedBookingProps = { endTime: string; title?: string; status: BookingStatus; - attendees?: { email: string; bookingSeat: BookingSeat }[]; + attendees?: { email: string; bookingSeat?: BookingSeat | null }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; From ae22585dc00f1fb47fa224f72e671dd58826049b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 13:49:43 -0500 Subject: [PATCH 62/65] Type fix --- .../web/test/utils/bookingScenario/bookingScenario.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index baa9ad0c1e96b1..3100ddeb7e7708 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2,7 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; import prismock from "../../../../../tests/libs/__mocks__/prisma"; -import type { BookingReference, Attendee, Booking, Membership, BookingSeat } from "@prisma/client"; +import type { BookingReference, Attendee, Booking, Membership } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client"; import type Stripe from "stripe"; @@ -128,6 +128,8 @@ export type InputEventType = { durationLimits?: IntervalLimit; } & Partial>; +type AttendeeBookingSeatInput = Pick; + type WhiteListedBookingProps = { id?: number; uid?: string; @@ -137,7 +139,10 @@ type WhiteListedBookingProps = { endTime: string; title?: string; status: BookingStatus; - attendees?: { email: string; bookingSeat?: BookingSeat | null }[]; + attendees?: { + email: string; + bookingSeat?: AttendeeBookingSeatInput; + }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; @@ -1411,7 +1416,7 @@ export function getMockBookingReference( export function getMockBookingAttendee( attendee: Omit & { - bookingSeat?: Pick; + bookingSeat?: AttendeeBookingSeatInput; } ) { return { From ae48f305bf48d032425b2cb1fac8623f8fb00f0c Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 14:06:01 -0500 Subject: [PATCH 63/65] Type fix --- apps/web/test/utils/bookingScenario/bookingScenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 3100ddeb7e7708..76cb7981e2f452 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -141,7 +141,7 @@ type WhiteListedBookingProps = { status: BookingStatus; attendees?: { email: string; - bookingSeat?: AttendeeBookingSeatInput; + bookingSeat?: AttendeeBookingSeatInput | null; }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId From c233a8cb1de49c71a8a7f3a2c5d0b17f68085527 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Mon, 15 Jan 2024 15:28:20 -0500 Subject: [PATCH 64/65] Fix tests --- .../utils/bookingScenario/bookingScenario.ts | 7 +++---- .../reschedule/rescheduleSeatedBooking.ts | 2 +- .../lib/handleSeats/test/handleSeats.test.ts | 21 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 76cb7981e2f452..58a940cdd15f00 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -305,10 +305,9 @@ async function addBookingsToDb( // Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock log.silly("Handling Prismock bug-3"); const fixedBookings = bookings.map((booking) => { - // const startTime = getDateObj(booking.startTime); - // const endTime = getDateObj(booking.endTime); - // return { ...booking, startTime, endTime }; - return { ...booking }; + const startTime = getDateObj(booking.startTime); + const endTime = getDateObj(booking.endTime); + return { ...booking, startTime, endTime }; }); await prismock.booking.createMany({ diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts index 7396f2e4bb42a1..520dd8be29e700 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -32,7 +32,7 @@ const rescheduleSeatedBooking = async ( // See if the new date has a booking already const newTimeSlotBooking = await prisma.booking.findFirst({ where: { - startTime: evt.startTime, + startTime: dayjs(evt.startTime).toDate(), eventTypeId: eventType.id, status: BookingStatus.ACCEPTED, }, diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 99cc2de968924c..8bcf4519867e2e 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -3,6 +3,7 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, test, vi, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { BookingStatus } from "@calcom/prisma/enums"; import { getBooker, @@ -611,7 +612,7 @@ describe("handleSeats", () => { body: mockBookingData, }); - await expect(() => handleNewBooking(req)).rejects.toThrowError("Already signed up for this booking."); + await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.AlreadySignedUpForBooking); }); test("If event is already full, fail", async () => { @@ -733,7 +734,7 @@ describe("handleSeats", () => { body: mockBookingData, }); - await expect(() => handleNewBooking(req)).rejects.toThrowError("Booking seats are full"); + await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.BookingSeatsFull); }); }); @@ -923,11 +924,11 @@ describe("handleSeats", () => { bookingId: secondBookingId, }, select: { - id: true, + email: true, }, }); - expect(newBookingAttendees).toContainEqual({ id: attendeeToReschedule.id }); + expect(newBookingAttendees).toContainEqual({ email: attendeeToReschedule.email }); expect(newBookingAttendees).toHaveLength(2); // Ensure that the attendeeSeat is also updated to the new booking @@ -1429,8 +1430,8 @@ describe("handleSeats", () => { const secondBookingUid = "def456"; const secondBookingId = 2; const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); - const secondBookingStartTime = `${plus2DateString}T04:00:00Z`; - const secondBookingEndTime = `${plus2DateString}T05:15:00Z`; + const secondBookingStartTime = `${plus2DateString}T04:00:00.000Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00.000Z`; await createBookingScenario( getScenarioData({ @@ -1583,8 +1584,8 @@ describe("handleSeats", () => { const rescheduledBooking = await handleNewBooking(req); // Ensure that the booking has been moved - expect(rescheduledBooking?.startTime).toEqual(secondBookingStartTime); - expect(rescheduledBooking?.endTime).toEqual(secondBookingEndTime); + expect(rescheduledBooking?.startTime).toEqual(new Date(secondBookingStartTime)); + expect(rescheduledBooking?.endTime).toEqual(new Date(secondBookingEndTime)); // Ensure that the attendees are still a part of the event const attendees = await prismaMock.attendee.findMany({ @@ -1791,9 +1792,7 @@ describe("handleSeats", () => { req.userId = organizer.id; // const rescheduledBooking = await handleNewBooking(req); - await expect(() => handleNewBooking(req)).rejects.toThrowError( - "Booking does not have enough available seats" - ); + await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.NotEnoughAvailableSeats); }); }); }); From 5d852acc62d215fc7b1e557a570b1426e5fe0a51 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 16 Jan 2024 02:34:12 +0530 Subject: [PATCH 65/65] chore: add error message for no availability (#13230) * chore: add error message for no default user availability * chore: check only availability * chore: change message * chore: add eventType --- apps/web/public/static/locales/en/common.json | 1 + packages/core/getUserAvailability.ts | 10 +++++++++- packages/lib/errorCodes.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 24c0536765020d..56ee83f12e8272 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1287,6 +1287,7 @@ "default_calendar_selected": "Default calendar", "hide_from_profile": "Hide from profile", "event_setup_tab_title": "Event Setup", + "availability_not_found_in_schedule_error":"No availability found in schedule", "event_limit_tab_title": "Limits", "event_limit_tab_description": "How often you can be booked", "event_advanced_tab_description": "Calendar settings & more...", diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 4f33347792c21c..e955531ae582be 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { getWorkingHours } from "@calcom/lib/availability"; import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; +import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import logger from "@calcom/lib/logger"; @@ -258,6 +259,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent; const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule; + log.debug( "Using schedule:", safeStringify({ @@ -272,8 +274,14 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone; + if ( + !(schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)) + ) { + throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule }); + } + const availability = ( - schedule.availability || (eventType?.availability.length ? eventType.availability : user.availability) + schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability) ).map((a) => ({ ...a, userId: user.id, diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 38f72cccf9709d..3148da155753c8 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -11,5 +11,6 @@ export enum ErrorCode { 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", }