diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts index 17aaa4caee105b..4e15683d27d69a 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, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8ab5a9857b52fb..56ee83f12e8272 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2194,6 +2194,11 @@ "uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types", "dont_want_to_wait": "Don't want to wait?", "meeting_started": "Meeting Started", + "booking_not_found_error": "Could not find booking", + "booking_seats_full_error": "Booking seats are full", + "missing_payment_credential_error": "Missing payment credentials", + "missing_payment_app_id_error": "Missing payment app id", + "not_enough_available_seats_error": "Booking does not have enough available seats", "user_redirect_title": "{{username}} is currently away for a brief period of time.", "user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.", "out_of_office": "Out of office", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 02f863a91e341b..33ef4e6f2fbc00 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -129,6 +129,8 @@ export type InputEventType = { durationLimits?: IntervalLimit; } & Partial>; +type AttendeeBookingSeatInput = Pick; + type WhiteListedBookingProps = { id?: number; uid?: string; @@ -138,11 +140,15 @@ type WhiteListedBookingProps = { endTime: string; title?: string; status: BookingStatus; - attendees?: { email: string }[]; + attendees?: { + email: string; + bookingSeat?: AttendeeBookingSeatInput | null; + }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId credentialId?: number | null; })[]; + bookingSeat?: Prisma.BookingSeatCreateInput[]; }; type InputBooking = Partial> & WhiteListedBookingProps; @@ -335,7 +341,7 @@ async function addBookings(bookings: InputBooking[]) { ); } return { - uid: uuidv4(), + uid: booking.uid || uuidv4(), workflowReminders: [], references: [], title: "Test Booking Title", @@ -362,10 +368,23 @@ async function addBookings(bookings: InputBooking[]) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore createMany: { - data: booking.attendees, + data: booking.attendees.map((attendee) => { + if (attendee.bookingSeat) { + const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee; + return { + ...attendeeWithoutBookingSeat, + bookingSeat: { + create: { ...bookingSeat, bookingId: booking.id }, + }, + }; + } else { + return attendee; + } + }), }, }; } + return bookingCreate; }) ); @@ -1398,13 +1417,18 @@ export function getMockBookingReference( }; } -export function getMockBookingAttendee(attendee: Omit) { +export function getMockBookingAttendee( + attendee: Omit & { + bookingSeat?: AttendeeBookingSeatInput; + } +) { 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/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index b84bd918387ddc..e495aadd4ebf2d 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 type { IncomingMessage } from "http"; @@ -12,7 +12,6 @@ import type { Logger } from "tslog"; import { v5 as uuidv5 } from "uuid"; import z from "zod"; -import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata"; import type { LocationObject } from "@calcom/app-store/locations"; import { @@ -25,19 +24,16 @@ import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; -import { deleteMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendRescheduledEmails, - sendRescheduledSeatEmail, sendRoundRobinCancelledEmails, sendRoundRobinRescheduledEmails, sendRoundRobinScheduledEmails, sendScheduledEmails, - sendScheduledSeatsEmails, } from "@calcom/emails"; import getICalUID from "@calcom/emails/lib/getICalUID"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; @@ -99,7 +95,8 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventTypeInfo } from "../../webhooks/lib/sendPayload"; import getBookingDataSchema from "./getBookingDataSchema"; -import type { BookingSeat } from "./handleSeats"; +import handleSeats from "./handleSeats/handleSeats"; +import type { BookingSeat } from "./handleSeats/types"; const translator = short(); const log = logger.getSubLogger({ prefix: ["[api] book:user"] }); @@ -861,7 +858,7 @@ export function getCustomInputsResponses( * * @returns updated evt with video call data */ -export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => { +export const addVideoCallDataToEvent = (bookingReferences: BookingReference[], evt: CalendarEvent) => { const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); if (videoCallReference) { @@ -892,7 +889,7 @@ export function handleAppsStatus( reqAppsStatus: ReqAppsStatus ) { // Taking care of apps status - let resultStatus: AppsStatus[] = results.map((app) => ({ + const resultStatus: AppsStatus[] = results.map((app) => ({ appName: app.appName, type: app.type, success: app.success ? 1 : 0, @@ -920,8 +917,7 @@ export function handleAppsStatus( } return prev; }, {} as { [key: string]: AppsStatus }); - resultStatus = Object.values(calcAppsStatus); - return resultStatus; + return Object.values(calcAppsStatus); } function getICalSequence(originalRescheduledBooking: BookingType | null) { @@ -1202,7 +1198,7 @@ async function handler( let originalRescheduledBooking: BookingType = null; - //this gets the orginal rescheduled booking + //this gets the original rescheduled booking if (rescheduleUid) { // rescheduleUid can be bookingUid and bookingSeatUid bookingSeat = await prisma.bookingSeat.findUnique({ @@ -1467,57 +1463,6 @@ async function handler( evt.destinationCalendar?.push(...teamDestinationCalendars); } - /* Check if the original booking has no more attendees, if so delete the booking - and any calendar or video integrations */ - const lastAttendeeDeleteBooking = async ( - originalRescheduledBooking: Awaited>, - 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, @@ -1557,640 +1502,39 @@ async function handler( triggerEvent: WebhookTriggerEvents.MEETING_STARTED, 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: rescheduleUid ? false : evt.requiresConfirmation || false, - isRescheduleEvent: !!rescheduleUid, - isFirstRecurringEvent: true, - emailAttendeeSendToOverride: bookerEmail, - seatReferenceUid: evt.attendeeSeatId, - eventTypeRequiresConfirmation: eventType.requiresConfirmation, - }); - } catch (error) { - loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); - } - - const webhookData = { - ...evt, - ...eventTypeInfo, - uid: resultBooking?.uid || uid, - bookingId: booking?.id, - rescheduleId: originalRescheduledBooking?.id || undefined, - rescheduleUid, - rescheduleStartTime: originalRescheduledBooking?.startTime - ? dayjs(originalRescheduledBooking?.startTime).utc().format() - : undefined, - rescheduleEndTime: originalRescheduledBooking?.endTime - ? dayjs(originalRescheduledBooking?.endTime).utc().format() - : undefined, - metadata: { ...metadata, ...reqBody.metadata }, - eventTypeId, - status: "ACCEPTED", - smsReminderNumber: booking?.smsReminderNumber || undefined, - }; - - await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); - - return resultBooking; - }; // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot && (reqBody.bookingUid || rescheduleUid)) { - const newBooking = await handleSeats(); + const newBooking = await handleSeats({ + rescheduleUid, + reqBookingUid: reqBody.bookingUid, + eventType, + evt, + invitee, + allCredentials, + organizerUser, + originalRescheduledBooking, + bookerEmail, + tAttendees, + bookingSeat, + reqUserId: req.userId, + rescheduleReason, + reqBodyUser: reqBody.user, + noEmail, + isConfirmedByDefault, + additionalNotes, + reqAppsStatus, + attendeeLanguage, + paymentAppData, + fullName, + smsReminderNumber, + eventTypeInfo, + uid, + eventTypeId, + reqBodyMetadata: reqBody.metadata, + subscriberOptions, + eventTrigger, + }); if (newBooking) { req.statusCode = 201; return newBooking; @@ -2321,7 +1665,7 @@ async function handler( ); } - addVideoCallDataToEvt(originalRescheduledBooking.references, evt); + evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { 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; 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..2b381a405c5053 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -0,0 +1,204 @@ +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; +import { uuid } from "short-uuid"; + +import EventManager from "@calcom/core/EventManager"; +import { sendScheduledSeatsEmails } from "@calcom/emails"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { HttpError } from "@calcom/lib/http-error"; +import { handlePayment } from "@calcom/lib/payment/handlePayment"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import type { IEventTypePaymentCredentialType } from "../../handleNewBooking"; +import { refreshCredentials, findBookingQuery } from "../../handleNewBooking"; +import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types"; + +const createNewSeat = async ( + rescheduleSeatedBookingObject: NewSeatedBookingObject, + seatedBooking: SeatedBooking +) => { + const { + tAttendees, + attendeeLanguage, + invitee, + eventType, + reqBookingUid, + additionalNotes, + noEmail, + paymentAppData, + allCredentials, + organizerUser, + fullName, + bookerEmail, + } = rescheduleSeatedBookingObject; + let { evt } = rescheduleSeatedBookingObject; + let resultBooking: HandleSeatsResultBooking; + // Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language + const bookingAttendees = seatedBooking.attendees.map((attendee) => { + return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } }; + }); + + evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] }; + + if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) { + throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull }); + } + + const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video")); + + if (videoCallReference) { + evt.videoCallData = { + type: videoCallReference.type, + id: videoCallReference.meetingId, + password: videoCallReference?.meetingPassword, + url: videoCallReference.meetingUrl, + }; + } + + const attendeeUniqueId = uuid(); + + const inviteeToAdd = invitee[0]; + + await prisma.booking.update({ + where: { + uid: reqBookingUid, + }, + include: { + attendees: true, + }, + data: { + attendees: { + create: { + email: inviteeToAdd.email, + name: inviteeToAdd.name, + timeZone: inviteeToAdd.timeZone, + locale: inviteeToAdd.language.locale, + bookingSeat: { + create: { + referenceUid: attendeeUniqueId, + data: { + description: additionalNotes, + }, + booking: { + connect: { + id: seatedBooking.id, + }, + }, + }, + }, + }, + }, + ...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), + }, + }); + + evt.attendeeSeatId = attendeeUniqueId; + + const newSeat = seatedBooking.attendees.length !== 0; + + /** + * Remember objects are passed into functions as references + * so if you modify it in a inner function it will be modified in the outer function + * deep cloning evt to avoid this + */ + if (!evt?.uid) { + evt.uid = seatedBooking?.uid ?? null; + } + const copyEvent = cloneDeep(evt); + copyEvent.uid = seatedBooking.uid; + if (noEmail !== true) { + let isHostConfirmationEmailsDisabled = false; + let isAttendeeConfirmationEmailDisabled = false; + + const workflows = eventType.workflows.map((workflow) => workflow.workflow); + + if (eventType.workflows) { + isHostConfirmationEmailsDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.host || false; + isAttendeeConfirmationEmailDisabled = + eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; + + if (isHostConfirmationEmailsDisabled) { + isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + } + + if (isAttendeeConfirmationEmailDisabled) { + isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + } + } + await sendScheduledSeatsEmails( + copyEvent, + inviteeToAdd, + newSeat, + !!eventType.seatsShowAttendees, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled + ); + } + const credentials = await refreshCredentials(allCredentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); + await eventManager.updateCalendarAttendees(evt, seatedBooking); + + const foundBooking = await findBookingQuery(seatedBooking.id); + + if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) { + const credentialPaymentAppCategories = await prisma.credential.findMany({ + where: { + ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), + app: { + categories: { + hasSome: ["payment"], + }, + }, + }, + select: { + key: true, + appId: true, + app: { + select: { + categories: true, + dirName: true, + }, + }, + }, + }); + + const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => { + return credential.appId === paymentAppData.appId; + }); + + if (!eventTypePaymentAppCredential) { + throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentCredential }); + } + if (!eventTypePaymentAppCredential?.appId) { + throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentAppId }); + } + + const payment = await handlePayment( + evt, + eventType, + eventTypePaymentAppCredential as IEventTypePaymentCredentialType, + seatedBooking, + fullName, + bookerEmail + ); + + resultBooking = { ...foundBooking }; + resultBooking["message"] = "Payment required"; + resultBooking["paymentUid"] = payment?.uid; + resultBooking["id"] = payment?.id; + } else { + resultBooking = { ...foundBooking }; + } + + resultBooking["seatReferenceUid"] = evt.attendeeSeatId; + + return resultBooking; +}; + +export default createNewSeat; diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts new file mode 100644 index 00000000000000..e34db97dc0fa29 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -0,0 +1,134 @@ +// eslint-disable-next-line no-restricted-imports +import dayjs from "@calcom/dayjs"; +import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; +import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { createLoggerWithEventDetails } from "../handleNewBooking"; +import createNewSeat from "./create/createNewSeat"; +import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking"; +import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types"; + +const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { + const { + eventType, + reqBodyUser, + rescheduleUid, + reqBookingUid, + invitee, + bookerEmail, + smsReminderNumber, + eventTypeInfo, + uid, + originalRescheduledBooking, + reqBodyMetadata, + eventTypeId, + subscriberOptions, + eventTrigger, + } = newSeatedBookingObject; + const { evt } = newSeatedBookingObject; + const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); + + let resultBooking: HandleSeatsResultBooking = null; + + const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({ + where: { + OR: [ + { + uid: rescheduleUid || reqBookingUid, + }, + { + eventTypeId: eventType.id, + startTime: evt.startTime, + }, + ], + status: BookingStatus.ACCEPTED, + }, + select: { + uid: true, + id: true, + attendees: { include: { bookingSeat: true } }, + userId: true, + references: true, + startTime: true, + user: true, + status: true, + smsReminderNumber: true, + endTime: true, + scheduledJobs: true, + }, + }); + + if (!seatedBooking) { + throw new HttpError({ statusCode: 404, message: ErrorCode.BookingNotFound }); + } + + // See if attendee is already signed up for timeslot + if ( + seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) && + dayjs.utc(seatedBooking.startTime).format() === evt.startTime + ) { + throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking }); + } + + // There are two paths here, reschedule a booking with seats and booking seats without reschedule + if (rescheduleUid) { + resultBooking = await rescheduleSeatedBooking( + // Assert that the rescheduleUid is defined + { ...newSeatedBookingObject, rescheduleUid }, + seatedBooking, + resultBooking, + loggerWithEventDetails + ); + } else { + resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking); + } + + // If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking + if (resultBooking) { + // Obtain event metadata that includes videoCallUrl + const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined; + try { + await scheduleWorkflowReminders({ + workflows: eventType.workflows, + smsReminderNumber: smsReminderNumber || null, + calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } }, + isNotConfirmed: evt.requiresConfirmation || false, + isRescheduleEvent: !!rescheduleUid, + isFirstRecurringEvent: true, + emailAttendeeSendToOverride: bookerEmail, + seatReferenceUid: evt.attendeeSeatId, + eventTypeRequiresConfirmation: eventType.requiresConfirmation, + }); + } catch (error) { + loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); + } + + const webhookData = { + ...evt, + ...eventTypeInfo, + uid: resultBooking?.uid || uid, + bookingId: seatedBooking?.id, + rescheduleUid, + rescheduleStartTime: originalRescheduledBooking?.startTime + ? dayjs(originalRescheduledBooking?.startTime).utc().format() + : undefined, + rescheduleEndTime: originalRescheduledBooking?.endTime + ? dayjs(originalRescheduledBooking?.endTime).utc().format() + : undefined, + metadata: { ...metadata, ...reqBodyMetadata }, + eventTypeId, + status: "ACCEPTED", + smsReminderNumber: seatedBooking?.smsReminderNumber || undefined, + }; + + await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); + } + + return resultBooking; +}; + +export default handleSeats; 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..4c1dc53dfa8691 --- /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[] | undefined, + originalBookingEvt?: CalendarEvent +) => { + let deletedReferences = false; + if ((!filteredAttendees || filteredAttendees.length === 0) && originalRescheduledBooking) { + const integrationsToDelete = []; + + for (const reference of originalRescheduledBooking.references) { + if (reference.credentialId) { + const credential = await prisma.credential.findUnique({ + where: { + id: reference.credentialId, + }, + select: credentialForCalendarServiceSelect, + }); + + if (credential) { + if (reference.type.includes("_video")) { + integrationsToDelete.push(deleteMeeting(credential, reference.uid)); + } + if (reference.type.includes("_calendar") && originalBookingEvt) { + const calendar = await getCalendar(credential); + if (calendar) { + integrationsToDelete.push( + calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId) + ); + } + } + } + } + } + + await Promise.all(integrationsToDelete).then(async () => { + await prisma.booking.update({ + where: { + id: originalRescheduledBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + }); + deletedReferences = true; + } + return deletedReferences; +}; + +export default lastAttendeeDeleteBooking; 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..31d289e9577e87 --- /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 prisma.$transaction([ + prisma.attendee.update({ + where: { + id: seatAttendee.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + }, + }), + prisma.bookingSeat.update({ + where: { + id: bookingSeat.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + }, + }), + ]); + } + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) + ? calendarResult?.updatedEvent[0]?.iCalUID + : calendarResult?.updatedEvent?.iCalUID || undefined; + + await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person); + const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { + return attendee.email !== bookerEmail; + }); + await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + return { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid }; +}; + +export default attendeeRescheduleSeatedBooking; 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..d4b5b1ab19e097 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -0,0 +1,160 @@ +// eslint-disable-next-line no-restricted-imports +import { cloneDeep } from "lodash"; +import { uuid } from "short-uuid"; + +import type EventManager from "@calcom/core/EventManager"; +import { sendRescheduledEmails } from "@calcom/emails"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; +import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking"; +import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types"; + +const combineTwoSeatedBookings = async ( + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + seatedBooking: SeatedBooking, + newTimeSlotBooking: NewTimeSlotBooking, + eventManager: EventManager, + loggerWithEventDetails: ReturnType +) => { + const { + eventType, + tAttendees, + attendeeLanguage, + rescheduleUid, + noEmail, + isConfirmedByDefault, + additionalNotes, + rescheduleReason, + } = rescheduleSeatedBookingObject; + let { evt } = rescheduleSeatedBookingObject; + // Merge two bookings together + const attendeesToMove = [], + attendeesToDelete = []; + + for (const attendee of seatedBooking.attendees) { + // If the attendee already exists on the new booking then delete the attendee record of the old booking + if ( + newTimeSlotBooking.attendees.some((newBookingAttendee) => newBookingAttendee.email === attendee.email) + ) { + attendeesToDelete.push(attendee.id); + // If the attendee does not exist on the new booking then move that attendee record to the new booking + } else { + attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id }); + } + } + + // Confirm that the new event will have enough available seats + if ( + !eventType.seatsPerTimeSlot || + attendeesToMove.length + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length > + eventType.seatsPerTimeSlot + ) { + throw new HttpError({ statusCode: 409, message: ErrorCode.NotEnoughAvailableSeats }); + } + + const moveAttendeeCalls = []; + for (const attendeeToMove of attendeesToMove) { + moveAttendeeCalls.push( + prisma.attendee.update({ + where: { + id: attendeeToMove.id, + }, + data: { + bookingId: newTimeSlotBooking.id, + bookingSeat: { + upsert: { + create: { + referenceUid: uuid(), + bookingId: newTimeSlotBooking.id, + }, + update: { + bookingId: newTimeSlotBooking.id, + }, + }, + }, + }, + }) + ); + } + + await prisma.$transaction([ + ...moveAttendeeCalls, + // Delete any attendees that are already a part of that new time slot booking + prisma.attendee.deleteMany({ + where: { + id: { + in: attendeesToDelete, + }, + }, + }), + ]); + + const updatedNewBooking = await prisma.booking.findUnique({ + where: { + id: newTimeSlotBooking.id, + }, + include: { + attendees: true, + references: true, + }, + }); + + if (!updatedNewBooking) { + throw new HttpError({ statusCode: 404, message: "Updated booking not found" }); + } + + // Update the evt object with the new attendees + const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => { + const evtAttendee = { + ...attendee, + language: { translate: tAttendees, locale: attendeeLanguage ?? "en" }, + }; + return evtAttendee; + }); + + evt.attendees = updatedBookingAttendees; + + evt = addVideoCallDataToEvent(updatedNewBooking.references, evt); + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = Array.isArray(calendarResult?.updatedEvent) + ? calendarResult?.updatedEvent[0]?.iCalUID + : calendarResult?.updatedEvent?.iCalUID || undefined; + + if (noEmail !== true && isConfirmedByDefault) { + // TODO send reschedule emails to attendees of the old booking + loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } + + // Update the old booking with the cancelled status + await prisma.booking.update({ + where: { + id: seatedBooking.id, + }, + data: { + status: BookingStatus.CANCELLED, + }, + }); + + const foundBooking = await findBookingQuery(newTimeSlotBooking.id); + + return { ...foundBooking }; +}; + +export default combineTwoSeatedBookings; 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..0dcd16c605972a --- /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 { addVideoCallDataToEvent, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking"; +import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking"; +import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types"; + +const moveSeatedBookingToNewTimeSlot = async ( + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + seatedBooking: SeatedBooking, + eventManager: EventManager, + loggerWithEventDetails: ReturnType +) => { + const { + rescheduleReason, + rescheduleUid, + eventType, + organizerUser, + reqAppsStatus, + noEmail, + isConfirmedByDefault, + additionalNotes, + } = rescheduleSeatedBookingObject; + let { evt } = rescheduleSeatedBookingObject; + + const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({ + where: { + id: seatedBooking.id, + }, + data: { + startTime: evt.startTime, + endTime: evt.endTime, + cancellationReason: rescheduleReason, + }, + include: { + user: true, + references: true, + payment: true, + attendees: true, + }, + }); + + evt = addVideoCallDataToEvent(newBooking.references, evt); + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id); + + // @NOTE: This code is duplicated and should be moved to a function + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + + const results = updateManager.results; + + const calendarResult = results.find((result) => result.type.includes("_calendar")); + + evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined; + + if (results.length > 0 && results.some((res) => !res.success)) { + const error = { + errorCode: "BookingReschedulingMeetingFailed", + message: "Booking Rescheduling failed", + }; + loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, JSON.stringify({ error, results })); + } else { + const metadata: AdditionalInformation = {}; + if (results.length) { + // TODO: Handle created event metadata more elegantly + const [updatedEvent] = Array.isArray(results[0].updatedEvent) + ? results[0].updatedEvent + : [results[0].updatedEvent]; + if (updatedEvent) { + metadata.hangoutLink = updatedEvent.hangoutLink; + metadata.conferenceData = updatedEvent.conferenceData; + metadata.entryPoints = updatedEvent.entryPoints; + evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus); + } + } + } + + if (noEmail !== true && isConfirmedByDefault) { + const copyEvent = cloneDeep(evt); + loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } + const foundBooking = await findBookingQuery(newBooking.id); + + return { ...foundBooking, appsStatus: newBooking.appsStatus }; +}; + +export default moveSeatedBookingToNewTimeSlot; 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..03a03a1dc64b60 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/ownerRescheduleSeatedBooking.ts @@ -0,0 +1,54 @@ +// eslint-disable-next-line no-restricted-imports +import type EventManager from "@calcom/core/EventManager"; + +import type { createLoggerWithEventDetails } from "../../../handleNewBooking"; +import type { + NewTimeSlotBooking, + SeatedBooking, + RescheduleSeatedBookingObject, + HandleSeatsResultBooking, +} from "../../types"; +import combineTwoSeatedBookings from "./combineTwoSeatedBookings"; +import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot"; + +const ownerRescheduleSeatedBooking = async ( + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + newTimeSlotBooking: NewTimeSlotBooking | null, + seatedBooking: SeatedBooking, + resultBooking: HandleSeatsResultBooking | null, + eventManager: EventManager, + loggerWithEventDetails: ReturnType +) => { + const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject; + const { evt } = rescheduleSeatedBookingObject; + evt.attendees = originalRescheduledBooking?.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }; + }); + + // If there is no booking during the new time slot then update the current booking to the new date + if (!newTimeSlotBooking) { + resultBooking = await moveSeatedBookingToNewTimeSlot( + rescheduleSeatedBookingObject, + seatedBooking, + eventManager, + loggerWithEventDetails + ); + } else { + // If a booking already exists during the new time slot then merge the two bookings together + resultBooking = await combineTwoSeatedBookings( + rescheduleSeatedBookingObject, + seatedBooking, + newTimeSlotBooking, + eventManager, + loggerWithEventDetails + ); + } + return resultBooking; +}; + +export default ownerRescheduleSeatedBooking; diff --git a/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts new file mode 100644 index 00000000000000..520dd8be29e700 --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/reschedule/rescheduleSeatedBooking.ts @@ -0,0 +1,140 @@ +// eslint-disable-next-line no-restricted-imports +import EventManager from "@calcom/core/EventManager"; +import dayjs from "@calcom/dayjs"; +import { HttpError } from "@calcom/lib/http-error"; +import prisma from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { Person } from "@calcom/types/Calendar"; + +import { refreshCredentials } from "../../handleNewBooking"; +import type { createLoggerWithEventDetails } from "../../handleNewBooking"; +import type { + HandleSeatsResultBooking, + SeatedBooking, + RescheduleSeatedBookingObject, + SeatAttendee, +} from "../types"; +import attendeeRescheduleSeatedBooking from "./attendee/attendeeRescheduleSeatedBooking"; +import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking"; + +const rescheduleSeatedBooking = async ( + // If this function is being called then rescheduleUid is defined + rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, + seatedBooking: SeatedBooking, + resultBooking: HandleSeatsResultBooking | null, + loggerWithEventDetails: ReturnType +) => { + const { evt, eventType, allCredentials, organizerUser, bookerEmail, tAttendees, bookingSeat, reqUserId } = + rescheduleSeatedBookingObject; + + const { originalRescheduledBooking } = rescheduleSeatedBookingObject; + + // See if the new date has a booking already + const newTimeSlotBooking = await prisma.booking.findFirst({ + where: { + startTime: dayjs(evt.startTime).toDate(), + eventTypeId: eventType.id, + status: BookingStatus.ACCEPTED, + }, + select: { + id: true, + uid: true, + attendees: { + include: { + bookingSeat: true, + }, + }, + }, + }); + + const credentials = await refreshCredentials(allCredentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); + + if (!originalRescheduledBooking) { + // typescript isn't smart enough; + throw new Error("Internal Error."); + } + + const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce( + (filteredAttendees, attendee) => { + if (attendee.email === bookerEmail) { + return filteredAttendees; // skip current booker, as we know the language already. + } + filteredAttendees.push({ + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }); + return filteredAttendees; + }, + [] as Person[] + ); + + // If original booking has video reference we need to add the videoCallData to the new evt + const videoReference = originalRescheduledBooking.references.find((reference) => + reference.type.includes("_video") + ); + + const originalBookingEvt = { + ...evt, + title: originalRescheduledBooking.title, + startTime: dayjs(originalRescheduledBooking.startTime).utc().format(), + endTime: dayjs(originalRescheduledBooking.endTime).utc().format(), + attendees: updatedBookingAttendees, + // If the location is a video integration then include the videoCallData + ...(videoReference && { + videoCallData: { + type: videoReference.type, + id: videoReference.meetingId, + password: videoReference.meetingPassword, + url: videoReference.meetingUrl, + }, + }), + }; + + if (!bookingSeat) { + // if no bookingSeat is given and the userId != owner, 401. + // TODO: Next step; Evaluate ownership, what about teams? + if (seatedBooking.user?.id !== reqUserId) { + throw new HttpError({ statusCode: 401 }); + } + + // Moving forward in this block is the owner making changes to the booking. All attendees should be affected + evt.attendees = originalRescheduledBooking.attendees.map((attendee) => { + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }; + }); + + // If owner reschedules the event we want to update the entire booking + // Also if owner is rescheduling there should be no bookingSeat + resultBooking = await ownerRescheduleSeatedBooking( + rescheduleSeatedBookingObject, + newTimeSlotBooking, + seatedBooking, + resultBooking, + eventManager, + loggerWithEventDetails + ); + } + + // seatAttendee is null when the organizer is rescheduling. + const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null; + if (seatAttendee) { + resultBooking = await attendeeRescheduleSeatedBooking( + rescheduleSeatedBookingObject, + seatAttendee, + newTimeSlotBooking, + originalBookingEvt, + eventManager + ); + } + + return resultBooking; +}; + +export default rescheduleSeatedBooking; diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts new file mode 100644 index 00000000000000..8bcf4519867e2e --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -0,0 +1,1799 @@ +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, + TestData, + getOrganizer, + createBookingScenario, + getScenarioData, + 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"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +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 spy = vi.spyOn(handleSeatsModule, "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(spy).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, + }), + ]); + }); + + 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, + }), + ]); + }); + }); + + describe("As an attendee", () => { + describe("Creating a new booking", () => { + test("Attendee should be added to existing seated event", 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 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", + locale: "en", + + 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, + }) + ); + }); + + 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", + locale: "en", + + 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(ErrorCode.AlreadySignedUpForBooking); + }); + + 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(ErrorCode.BookingSeatsFull); + }); + }); + + 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 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 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, + 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: {}, + }, + }), + 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", + 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: { + email: true, + }, + }); + + expect(newBookingAttendees).toContainEqual({ email: attendeeToReschedule.email }); + 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 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: [ + getMockBookingAttendee({ + id: 1, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + 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 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); + + // 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?.bookingSeat?.bookingId).toEqual(createdBooking.id); + }); + }); + }); + + 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 { 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); + }); + + 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:00.000Z`; + const secondBookingEndTime = `${plus2DateString}T05:15:00.000Z`; + + 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(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({ + 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); + }); + 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(ErrorCode.NotEnoughAvailableSeats); + }); + }); + }); +}); 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..748767455ea33c --- /dev/null +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -0,0 +1,80 @@ +import type { Prisma } from "@prisma/client"; + +import type { AppsStatus } from "@calcom/types/Calendar"; + +import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking"; + +export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null; + +export type NewSeatedBookingObject = { + rescheduleUid: string | undefined; + reqBookingUid: string | undefined; + eventType: NewBookingEventType; + evt: CalendarEvent; + invitee: Invitee; + allCredentials: Awaited>; + 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 RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string }; + +export type SeatedBooking = Prisma.BookingGetPayload<{ + select: { + uid: true; + id: true; + attendees: { include: { bookingSeat: true } }; + userId: true; + references: true; + startTime: true; + user: true; + status: true; + smsReminderNumber: true; + endTime: true; + scheduledJobs: true; + }; +}>; + +export type HandleSeatsResultBooking = + | (Partial & { + appsStatus?: AppsStatus[]; + seatReferenceUid?: string; + paymentUid?: string; + message?: string; + paymentId?: number; + }) + | null; + +export type NewTimeSlotBooking = Prisma.BookingGetPayload<{ + select: { + id: true; + uid: true; + attendees: { + include: { + bookingSeat: true; + }; + }; + }; +}>; + +export type SeatAttendee = Partial; diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index 743820d47167fe..3148da155753c8 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -6,6 +6,11 @@ export enum ErrorCode { AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error", HostsUnavailableForBooking = "hosts_unavailable_for_booking", EventTypeNotFound = "event_type_not_found_error", + BookingNotFound = "booking_not_found_error", + BookingSeatsFull = "booking_seats_full_error", + MissingPaymentCredential = "missing_payment_credential_error", + MissingPaymentAppId = "missing_payment_app_id_error", + NotEnoughAvailableSeats = "not_enough_available_seats_error", AvailabilityNotFoundInSchedule = "availability_not_found_in_schedule_error", CancelledBookingsCannotBeRescheduled = "cancelled_bookings_cannot_be_rescheduled", }