From ed750c8df19e94dedc1ebdd43be9583d722e1b75 Mon Sep 17 00:00:00 2001 From: alannnc Date: Mon, 13 Mar 2023 21:19:05 -0700 Subject: [PATCH] Seated booking rescheduling. (#5427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP-already-reschedule-success-emails-missing * WIP now saving bookingSeatsReferences and identifyin on reschedule/book page * Remove logs and created test * WIP saving progress * Select second slot to pass test * Delete attendee from event * Clean up * Update with main changes * Fix emails not being sent * Changed test end url from success to booking * Remove unused pkg * Fix new booking reschedule * remove log * Renable test * remove unused pkg * rename table name * review changes * Fix and and other test to reschedule with seats * Fix api for cancel booking * Typings * Update [uid].tsx * Abstracted common pattern into maybeGetBookingUidFromSeat * Reverts * Nitpicks * Update handleCancelBooking.ts * Adds missing cascades * Improve booking seats changes (#6858) * Create sendCancelledSeatEmails * Draft attendee cancelled seat email * Send no longer attendee email to attendee * Send email to organizer when attendee cancels * Pass cloned event data to emails * Send booked email for first seat * Add seat reference uid from email link * Query for seatReferenceUId and add to cancel & reschedule links * WIP * Display proper attendee when rescheduling seats * Remove console.logs * Only check for already invited when not rescheduling * WIP sending reschedule email to just single attendee and owner * Merge branch 'main' into send-email-on-seats-attendee-changes * Remove console.logs * Add cloned event to seat emails * Do not show manage link for calendar event * First seat, have both attendees on calendar * WIP refactor booking seats reschedule logic * WIP Refactor handleSeats * Change relation of attendee & seat reference to a one-to-one * Migration with relationship change * Booking page handling unique seat references * Abstract to handleSeats * Remove console.logs and clean up * New migration file, delete on cascade * Check if attendee is already a part of the booking * Move deleting booking logic to `handleSeats` * When owner reschedule, move whole booking * Prevent owner from rescheduling if not enough seats * Add owner reschedule * Send reschedule email when moving to new timeslot * Add event data to reschedule email for seats * Remove DB changes from event manager * When a booking has no attendees then delete * Update calendar when merging bookings * Move both attendees and seat references when merging * Remove guest list from seats booking page * Update original booking when moving an attendee * Delete calendar and video events if no more attendees * Update or delete integrations when attendees cancel * Show no longer attendee if a single attendee cancels * Change booking to accepted if an attendee books on an empty booking * If booking in same slot then just return the booking * Clean up * Clean up * Remove booking select * Typos --------- Co-authored-by: zomars * Fix migration table name * Add missing trpc import * Rename bookingSeatReferences to bookingSeat * Change relationship between Attendee & BookingSeat to one to one * Fix some merge conflicts * Minor merge artifact fixup * Add the right 'Person' type * Check on email, less (although still) editable than name * Removed calEvent.attendeeUniqueId * rename referenceUId -> referenceUid * Squashes migrations * Run cached installs Should still be faster. Ensures prisma client is up to date. * Solve attendee form on booking page * Remove unused code * Some type fixes * Squash migrations * Type fixes * Fix for reschedule/[uid] redirect * Fix e2e test * Solve double declaration of host * Solve lint errors * Drop constraint only if exists * Renamed UId to Uid * Explicit vs. implicit * Attempt to work around text flakiness by adding a little break between animations * Various bugfixes * Persistently apply seatReferenceUid (#7545) * Persistently apply seatReferenceUid * Small ts fix * Setup guards correctly * Type fixes * Fix render 0 in conditional * Test refactoring * Fix type on handleSeats * Fix handle seats conditional * Fix type inference * Update packages/features/bookings/lib/handleNewBooking.ts * Update apps/web/components/booking/pages/BookingPage.tsx * Fix type and missing logic for reschedule * Fix delete of calendar event and booking * Add handleSeats return type * Fix seats booking creation * Fall through normal booking for initial booking, handleSeats for secondary/reschedule * Simplification of fetching booking * Enable seats for round-robin events * A lot harder than I expected * ignore-owner-if-seat-reference-given * Return seatReferenceUid when second seat * negate userIsOwner * Fix booking seats with a link without bookingUid * Needed a time check otherwise the attendee will be in the older booking * Can't open dialog twice in test.. * Allow passing the booking ID from the server * Fixed isCancelled check, fixed test * Delete through cascade instead of multiple deletes --------- Co-authored-by: Joe Au-Yeung Co-authored-by: Peer Richelsen Co-authored-by: zomars Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Alex van Andel Co-authored-by: Efraín Rochín Co-authored-by: Peer Richelsen --- .../web/components/booking/AvailableTimes.tsx | 16 +- .../components/booking/BookingListItem.tsx | 14 +- apps/web/components/booking/CancelBooking.tsx | 17 +- apps/web/components/booking/SlotPicker.tsx | 3 + .../booking/pages/AvailabilityPage.tsx | 30 +- .../components/booking/pages/BookingPage.tsx | 21 +- .../components/eventtype/EventAdvancedTab.tsx | 1 + apps/web/lib/getBooking.tsx | 8 + apps/web/lib/types/booking.ts | 12 +- apps/web/pages/[user]/book.tsx | 126 ++- apps/web/pages/booking/[uid].tsx | 68 +- apps/web/pages/reschedule/[uid].tsx | 28 +- apps/web/pages/team/[slug]/book.tsx | 11 +- apps/web/playwright/booking-seats.e2e.ts | 317 +++++++ apps/web/playwright/event-types.e2e.ts | 27 +- apps/web/playwright/fixtures/bookings.ts | 21 +- apps/web/playwright/fixtures/users.ts | 79 +- apps/web/playwright/lib/testUtils.ts | 28 +- apps/web/playwright/reschedule.e2e.ts | 17 +- apps/web/public/static/locales/en/common.json | 6 + .../googlecalendar/lib/CalendarService.ts | 10 +- packages/core/CalendarManager.ts | 2 +- packages/core/EventManager.ts | 118 ++- packages/emails/email-manager.ts | 22 +- packages/emails/src/components/ManageLink.tsx | 2 +- .../templates/AttendeeCancelledSeatEmail.tsx | 12 + .../src/templates/BaseScheduledEmail.tsx | 16 +- .../OrganizerAttendeeCancelledSeatEmail.tsx | 14 + .../src/templates/OrganizerScheduledEmail.tsx | 8 + packages/emails/src/templates/index.ts | 2 + .../attendee-cancelled-seat-email.ts | 21 + .../templates/attendee-scheduled-email.ts | 14 +- ...organizer-attendee-cancelled-seat-email.ts | 32 + .../templates/organizer-rescheduled-email.ts | 2 +- .../templates/organizer-scheduled-email.ts | 15 +- .../bookings/lib/handleCancelBooking.ts | 157 ++- .../features/bookings/lib/handleNewBooking.ts | 898 ++++++++++++++---- packages/lib/CalEventParser.ts | 24 +- .../lib/server/maybeGetBookingUidFromSeat.ts | 20 + .../migration.sql | 34 + packages/prisma/schema.prisma | 28 +- packages/prisma/zod-utils.ts | 2 + .../trpc/server/routers/viewer/bookings.tsx | 49 + packages/types/Calendar.d.ts | 8 +- 44 files changed, 1898 insertions(+), 462 deletions(-) create mode 100644 apps/web/playwright/booking-seats.e2e.ts create mode 100644 packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx create mode 100644 packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx create mode 100644 packages/emails/templates/attendee-cancelled-seat-email.ts create mode 100644 packages/emails/templates/organizer-attendee-cancelled-seat-email.ts create mode 100644 packages/lib/server/maybeGetBookingUidFromSeat.ts create mode 100644 packages/prisma/migrations/20230303162003_add_booking_seat_reference/migration.sql diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 3fdca80f65..ddea5dcb4c 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -24,6 +24,7 @@ type AvailableTimesProps = { eventTypeSlug: string; date?: Dayjs; seatsPerTimeSlot?: number | null; + bookingAttendees?: number | null; slots?: Slot[]; isLoading: boolean; ethSignature?: string; @@ -39,6 +40,7 @@ const AvailableTimes: FC = ({ timeFormat, onTimeFormatChange, seatsPerTimeSlot, + bookingAttendees, ethSignature, }) => { const [slotPickerRef] = useAutoAnimate(); @@ -116,18 +118,28 @@ const AvailableTimes: FC = ({ bookingUrl.query.bookingUid = slot.bookingUid; } + let slotFull, notEnoughSeats; + + if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot; + if (slot.attendees && bookingAttendees && seatsPerTimeSlot) + notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot; + return (
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */} {/* Current there is no way to disable Next.js Links */} - {seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? ( + {seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
{dayjs(slot.time).tz(timeZone()).format(timeFormat)} - {!!seatsPerTimeSlot &&

{t("booking_full")}

} + {notEnoughSeats ? ( +

{t("not_enough_seats")}

+ ) : slots ? ( +

{t("booking_full")}

+ ) : null}
) : ( { + if (!booking.seatsReferences[0]) { + return undefined; + } + return booking.seatsReferences[0].referenceUid; + }; + const pendingActions: ActionType[] = [ { id: "reject", @@ -135,7 +142,8 @@ function BookingListItem(booking: BookingItemProps) { cancel all remaining bookings or just that booking instance. */ href: `/booking/${booking.uid}?cancel=true${ isTabRecurring && isRecurring ? "&allRemainingBookings=true" : "" - }`, + }${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""} + `, icon: FiX, }, { @@ -146,7 +154,9 @@ function BookingListItem(booking: BookingItemProps) { id: "reschedule", icon: FiClock, label: t("reschedule_booking"), - href: `/reschedule/${booking.uid}`, + href: `/reschedule/${booking.uid}${ + booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" + }`, }, { id: "reschedule_request", diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index fc31685f1b..bde168f0ac 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -23,13 +23,14 @@ type Props = { setIsCancellationMode: (value: boolean) => void; theme: string | null; allRemainingBookings: boolean; + seatReferenceUid?: string; }; export default function CancelBooking(props: Props) { const [cancellationReason, setCancellationReason] = useState(""); const { t } = useLocale(); const router = useRouter(); - const { booking, allRemainingBookings } = props; + const { booking, allRemainingBookings, seatReferenceUid } = props; const [loading, setLoading] = useState(false); const telemetry = useTelemetry(); const [error, setError] = useState(booking ? null : t("booking_already_cancelled")); @@ -73,16 +74,16 @@ export default function CancelBooking(props: Props) { onClick={async () => { setLoading(true); - const payload = { - id: booking?.id, - cancellationReason: cancellationReason, - allRemainingBookings, - }; - telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters()); const res = await fetch("/api/cancel", { - body: JSON.stringify(payload), + body: JSON.stringify({ + id: booking?.id, + cancellationReason: cancellationReason, + allRemainingBookings, + // @NOTE: very important this shouldn't cancel with number ID use uid instead + seatReferenceUid, + }), headers: { "Content-Type": "application/json", }, diff --git a/apps/web/components/booking/SlotPicker.tsx b/apps/web/components/booking/SlotPicker.tsx index 555d207eb6..d75beb8d7c 100644 --- a/apps/web/components/booking/SlotPicker.tsx +++ b/apps/web/components/booking/SlotPicker.tsx @@ -78,6 +78,7 @@ export const SlotPicker = ({ recurringEventCount, users, seatsPerTimeSlot, + bookingAttendees, weekStart = 0, ethSignature, }: { @@ -89,6 +90,7 @@ export const SlotPicker = ({ onTimeFormatChange: (is24Hour: boolean) => void; timeZone?: string; seatsPerTimeSlot?: number; + bookingAttendees?: number; recurringEventCount?: number; users: string[]; weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -192,6 +194,7 @@ export const SlotPicker = ({ eventTypeId={eventType.id} eventTypeSlug={eventType.slug} seatsPerTimeSlot={seatsPerTimeSlot} + bookingAttendees={bookingAttendees} recurringCount={recurringEventCount} ethSignature={ethSignature} /> diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 534d5d4cf9..a10d6c8a96 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -23,8 +23,9 @@ import notEmpty from "@calcom/lib/notEmpty"; import { getRecurringFreq } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat"; +import { trpc } from "@calcom/trpc"; import { HeadSeo } from "@calcom/ui"; -import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon"; +import { FiCreditCard, FiUser, FiRefreshCcw } from "@calcom/ui/components/icon"; import { timeZone as localStorageTimeZone } from "@lib/clock"; @@ -52,6 +53,7 @@ const dateQuerySchema = z.object({ rescheduleUid: z.string().optional().default(""), date: z.string().optional().default(""), timeZone: z.string().optional().default(""), + seatReferenceUid: z.string().optional(), }); export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps; @@ -124,6 +126,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => { : undefined, ]; + const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery( + { + seatReferenceUid: rescheduleUid, + }, + { + enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot), + } + ); + return ( { recurringEventCount && "xl:w-[380px] xl:min-w-[380px]" )}> + {rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && ( +
+ {" "} + {t("event_type_seats", { numberOfSeats: bookingAttendees })} +
+ )} {!rescheduleUid && eventType.recurringEvent && (
@@ -247,6 +274,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => { timeZone={timeZone} users={userList} seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined} + bookingAttendees={bookingAttendees || undefined} recurringEventCount={recurringEventCount} ethSignature={gateState.rainbowToken} /> diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 6ebce26f45..2789581a66 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -115,7 +115,10 @@ const BookingFields = ({ } // We don't show `notes` field during reschedule - if (field.name === SystemField.Enum.notes && !!rescheduleUid) { + if ( + (field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) && + !!rescheduleUid + ) { return null; } @@ -242,12 +245,12 @@ const BookingPage = ({ const mutation = useMutation(createBooking, { onSuccess: async (responseData) => { - const { uid, paymentUid } = responseData; + const { uid } = responseData; - if (paymentUid) { + if ("paymentUid" in responseData && !!responseData.paymentUid) { return await router.push( createPaymentLink({ - paymentUid, + paymentUid: responseData.paymentUid, date, name: bookingForm.getValues("responses.name"), email: bookingForm.getValues("responses.email"), @@ -262,6 +265,7 @@ const BookingPage = ({ isSuccessBookingPage: true, email: bookingForm.getValues("responses.email"), eventTypeSlug: eventType.slug, + seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null, ...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }), }, }); @@ -409,7 +413,7 @@ const BookingPage = ({ ); } - const bookEvent = (booking: BookingFormValues) => { + const bookEvent = (bookingValues: BookingFormValues) => { telemetry.event( top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed, { isTeamBooking: document.URL.includes("team/") } @@ -432,7 +436,7 @@ const BookingPage = ({ // Identify set of bookings to one intance of recurring event to support batch changes const recurringEventId = uuidv4(); const recurringBookings = recurringDates.map((recurringDate) => ({ - ...booking, + ...bookingValues, start: dayjs(recurringDate).format(), end: dayjs(recurringDate).add(duration, "minute").format(), eventTypeId: eventType.id, @@ -452,7 +456,7 @@ const BookingPage = ({ recurringMutation.mutate(recurringBookings); } else { mutation.mutate({ - ...booking, + ...bookingValues, start: dayjs(date).tz(timeZone()).format(), end: dayjs(date).tz(timeZone()).add(duration, "minute").format(), eventTypeId: eventType.id, @@ -460,12 +464,13 @@ const BookingPage = ({ timeZone: timeZone(), language: i18n.language, rescheduleUid, - bookingUid: router.query.bookingUid as string, + bookingUid: (router.query.bookingUid as string) || booking?.uid, user: router.query.user, metadata, hasHashedBookingLink, hashedLink, ethSignature: gateState.rainbowToken, + seatReferenceUid: router.query.seatReferenceUid as string, }); } }; diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index b8bf84a2ad..06d3a5bd45 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -264,6 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick ( +>; diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 6886395074..189f46e836 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -1,8 +1,10 @@ import type { GetServerSidePropsContext } from "next"; +import { z } from "zod"; import type { LocationObject } from "@calcom/app-store/locations"; import { privacyFilteredLocations } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; +import dayjs from "@calcom/dayjs"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { parseRecurringEvent } from "@calcom/lib"; import { @@ -12,15 +14,13 @@ import { getUsernameList, } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { bookEventTypeSelect } from "@calcom/prisma"; -import prisma from "@calcom/prisma"; +import prisma, { bookEventTypeSelect } from "@calcom/prisma"; import { customInputSchema, EventTypeMetaDataSchema, userMetadata as userMetadataSchema, } from "@calcom/prisma/zod-utils"; -import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull"; import type { GetBookingType } from "@lib/getBooking"; import getBooking from "@lib/getBooking"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -70,11 +70,26 @@ export default function Book(props: BookPageProps) { Book.isThemeSupported = true; +const querySchema = z.object({ + bookingUid: z.string().optional(), + count: z.coerce.number().optional(), + embed: z.string().optional(), + rescheduleUid: z.string().optional(), + slug: z.string().optional(), + /** This is the event "type" ID */ + type: z.coerce.number().optional(), + user: z.string(), + seatReferenceUid: z.string().optional(), + date: z.string().optional(), + duration: z.coerce.number().optional(), +}); + export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); - const usernameList = getUsernameList(asStringOrThrow(context.query.user as string)); - const eventTypeSlug = context.query.slug as string; - const recurringEventCountQuery = asStringOrNull(context.query.count); + const query = querySchema.parse(context.query); + const usernameList = getUsernameList(query.user); + const eventTypeSlug = query.slug; + const recurringEventCountQuery = query.count; const users = await prisma.user.findMany({ where: { username: { @@ -99,24 +114,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!users.length) return { notFound: true }; const [user] = users; - const isDynamicGroupBooking = users.length > 1; + const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug; // Dynamic Group link doesn't need a type but it must have a slug - if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) { + if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) { return { notFound: true }; } - const eventTypeRaw = - usernameList.length > 1 - ? getDefaultEvent(eventTypeSlug) - : await prisma.eventType.findUnique({ - where: { - id: parseInt(asStringOrThrow(context.query.type)), - }, - select: { - ...bookEventTypeSelect, - }, - }); + const eventTypeRaw = isDynamicGroupBooking + ? getDefaultEvent(eventTypeSlug) + : await prisma.eventType.findUnique({ + where: { + id: query.type, + }, + select: { + ...bookEventTypeSelect, + }, + }); if (!eventTypeRaw) return { notFound: true }; const eventType = { @@ -178,22 +192,68 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; })[0]; + // If rescheduleUid and event has seats lets convert Uid to bookingUid + let rescheduleUid = query.rescheduleUid; + const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot; + let attendeeEmail: string; + let bookingUidWithSeats: string | null = null; + + if (rescheduleEventTypeHasSeats) { + const bookingSeat = await prisma.bookingSeat.findFirst({ + where: { + referenceUid: query.rescheduleUid, + }, + select: { + id: true, + attendee: true, + booking: { + select: { + uid: true, + }, + }, + }, + }); + if (bookingSeat) { + rescheduleUid = bookingSeat.booking.uid; + attendeeEmail = bookingSeat.attendee.email; + } + } else if (eventTypeRaw.seatsPerTimeSlot && query.duration && query.date) { + // If it's not reschedule but event Type has seats we should obtain + // the bookingUid regardless and use it to get the booking + const currentSeats = await prisma.booking.findFirst({ + where: { + eventTypeId: eventTypeRaw.id, + startTime: dayjs(query.date).toISOString(), + endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(), + }, + select: { + uid: true, + }, + }); + if (currentSeats && currentSeats) { + bookingUidWithSeats = currentSeats.uid; + } + } + let booking: GetBookingType | null = null; - if (context.query.rescheduleUid || context.query.bookingUid) { + if (rescheduleUid || query.bookingUid || bookingUidWithSeats) { booking = await getBooking( prisma, - context.query.rescheduleUid - ? (context.query.rescheduleUid as string) - : (context.query.bookingUid as string), + rescheduleUid || query.bookingUid || bookingUidWithSeats || "", eventTypeObject.bookingFields ); } - const dynamicNames = isDynamicGroupBooking - ? users.map((user) => { - return user.name || ""; - }) - : []; + if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) { + const currentAttendee = booking?.attendees.find((attendee) => { + return attendee.email === attendeeEmail; + }); + if (currentAttendee) { + booking.attendees = [currentAttendee] || []; + } + } + + const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : []; const profile = isDynamicGroupBooking ? { @@ -203,9 +263,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { theme: null, brandColor: "", darkBrandColor: "", - allowDynamicBooking: !users.some((user) => { - return !user.allowDynamicBooking; - }), + allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking), eventName: getDynamicEventName(dynamicNames, eventTypeSlug), } : { @@ -222,8 +280,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const recurringEventCount = (eventType.recurringEvent?.count && recurringEventCountQuery && - (parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count - ? parseInt(recurringEventCountQuery) + (recurringEventCountQuery <= eventType.recurringEvent.count + ? recurringEventCountQuery : eventType.recurringEvent.count)) || null; @@ -238,7 +296,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { isDynamicGroupBooking, hasHashedBookingLink: false, hashedLink: null, - isEmbed: typeof context.query.embed === "string", + isEmbed: !!query.embed, }, }; } diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index bdb0de015d..756b8e08a3 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -39,6 +39,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; +import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; import { localStorage } from "@calcom/lib/webstorage"; @@ -182,6 +183,7 @@ const querySchema = z.object({ isSuccessBookingPage: z.string().optional(), formerTime: z.string().optional(), email: z.string().optional(), + seatReferenceUid: z.string().optional(), }); export default function Success(props: SuccessProps) { @@ -194,6 +196,7 @@ export default function Success(props: SuccessProps) { changes, formerTime, email, + seatReferenceUid, } = querySchema.parse(router.query); if ((isCancellationMode || changes) && typeof window !== "undefined") { @@ -259,7 +262,16 @@ export default function Success(props: SuccessProps) { const eventName = getEventName(eventNameObject, true); const needsConfirmation = eventType.requiresConfirmation && reschedule != true; - const isCancelled = status === "CANCELLED" || status === "REJECTED"; + const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id); + + const isCancelled = + status === "CANCELLED" || + status === "REJECTED" || + (isCancellationMode && + (!!seatReferenceUid + ? !bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid) + : !userIsOwner)); + const telemetry = useTelemetry(); useEffect(() => { if (top !== window) { @@ -343,7 +355,6 @@ export default function Success(props: SuccessProps) { return t("emailed_you_and_attendees" + titleSuffix); } - const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id); useTheme(isSuccessBookingPage ? props.profile.theme : "light"); const title = t( `booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}` @@ -437,7 +448,9 @@ export default function Success(props: SuccessProps) { ? t("submitted_recurring") : t("submitted") : isCancelled - ? t("event_cancelled") + ? seatReferenceUid + ? t("no_longer_attending") + : t("event_cancelled") : props.recurringBookings ? t("meeting_is_scheduled_recurring") : t("meeting_is_scheduled")} @@ -593,7 +606,9 @@ export default function Success(props: SuccessProps) { {!props.recurringBookings && ( - + {t("reschedule")} @@ -623,6 +638,7 @@ export default function Success(props: SuccessProps) { setIsCancellationMode={setIsCancellationMode} theme={isSuccessBookingPage ? props.profile.theme : "light"} allRemainingBookings={allRemainingBookings} + seatReferenceUid={seatReferenceUid} /> ))} @@ -630,7 +646,7 @@ export default function Success(props: SuccessProps) { !needsConfirmation && !isCancellationMode && !isCancelled && - calculatedDuration && ( + !!calculatedDuration && ( <>
@@ -738,6 +754,7 @@ export default function Success(props: SuccessProps) {
)} + {session === null && !(userIsOwner || props.hideBranding) && ( <>
@@ -1005,7 +1022,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const bookingInfoRaw = await prisma.booking.findFirst({ where: { - uid, + uid: await maybeGetBookingUidFromSeat(prisma, uid), }, select: { title: true, @@ -1047,6 +1064,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { timeZone: true, }, }, + seatsReferences: { + select: { + referenceUid: true, + }, + }, }, }); if (!bookingInfoRaw) { @@ -1116,19 +1138,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { handleSeatsEventTypeOnBooking(eventType, bookingInfo, email); } - let recurringBookings = null; - if (bookingInfo.recurringEventId) { - // We need to get the dates for the bookings to be able to show them in the UI - recurringBookings = await prisma.booking.findMany({ - where: { - recurringEventId: bookingInfo.recurringEventId, - }, - select: { - startTime: true, - }, - }); - } - const payment = await prisma.payment.findFirst({ where: { bookingId: bookingInfo.id, @@ -1138,21 +1147,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { refunded: true, }, }); - const paymentStatus = { - success: payment?.success, - refunded: payment?.refunded, - }; return { props: { hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, profile, eventType, - recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null, + recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), trpcState: ssr.dehydrate(), dynamicEventName: bookingInfo?.eventType?.eventName || "", bookingInfo, - paymentStatus: payment ? paymentStatus : null, + paymentStatus: payment, }, }; } + +async function getRecurringBookings(recurringEventId: string | null) { + if (!recurringEventId) return null; + const recurringBookings = await prisma.booking.findMany({ + where: { + recurringEventId, + }, + select: { + startTime: true, + }, + }); + return recurringBookings.map((obj) => obj.startTime.toString()); +} diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index 8f5193c6d0..3210e2d9fc 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -1,19 +1,28 @@ import type { GetServerSidePropsContext } from "next"; +import { URLSearchParams } from "url"; +import { z } from "zod"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { asStringOrUndefined } from "@lib/asStringOrNull"; - export default function Type() { // Just redirect to the schedule page to reschedule it. return null; } export async function getServerSideProps(context: GetServerSidePropsContext) { + const { uid: bookingId } = z + .object({ uid: z.string(), seatReferenceUid: z.string().optional() }) + .parse(context.query); + let seatReferenceUid; + const uid = await maybeGetBookingUidFromSeat(prisma, bookingId); + if (uid) { + seatReferenceUid = bookingId; + } const booking = await prisma.booking.findUnique({ where: { - uid: asStringOrUndefined(context.query.uid), + uid, }, select: { ...bookingMinimalSelect, @@ -42,9 +51,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!booking) { return { notFound: true, - } as { - notFound: true; - }; + } as const; } if (!booking?.eventType && !booking?.dynamicEventSlugRef) { @@ -66,9 +73,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { : booking.user?.username || "rick") /* This shouldn't happen */ + "/" + eventType?.slug; + const destinationUrl = new URLSearchParams(); + if (seatReferenceUid) { + destinationUrl.set("rescheduleUid", seatReferenceUid); + } else { + destinationUrl.set("rescheduleUid", bookingId); + } + return { redirect: { - destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid, + destination: `/${eventPage}?${destinationUrl.toString()}`, permanent: false, }, }; diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index efe3e21e09..c7e20ea260 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -1,4 +1,5 @@ import type { GetServerSidePropsContext } from "next"; +import { z } from "zod"; import type { LocationObject } from "@calcom/app-store/locations"; import { privacyFilteredLocations } from "@calcom/app-store/locations"; @@ -24,6 +25,11 @@ export default function TeamBookingPage(props: TeamBookingPageProps) { TeamBookingPage.isThemeSupported = true; +const querySchema = z.object({ + rescheduleUid: z.string().optional(), + bookingUid: z.string().optional(), +}); + export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); const eventTypeId = parseInt(asStringOrThrow(context.query.type)); @@ -119,8 +125,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { })[0]; let booking: GetBookingType | null = null; - if (context.query.rescheduleUid) { - booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields); + const { rescheduleUid, bookingUid } = querySchema.parse(context.query); + if (rescheduleUid || bookingUid) { + booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields); } // Checking if number of recurring event ocurrances is valid against event type configuration diff --git a/apps/web/playwright/booking-seats.e2e.ts b/apps/web/playwright/booking-seats.e2e.ts new file mode 100644 index 0000000000..4ca991e501 --- /dev/null +++ b/apps/web/playwright/booking-seats.e2e.ts @@ -0,0 +1,317 @@ +import { expect } from "@playwright/test"; +import type { Prisma } from "@prisma/client"; +import { BookingStatus } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; + +import prisma from "@calcom/prisma"; + +import type { Fixtures } from "./lib/fixtures"; +import { test } from "./lib/fixtures"; +import { + bookTimeSlot, + createNewSeatedEventType, + selectFirstAvailableTimeSlotNextMonth, +} from "./lib/testUtils"; + +test.afterEach(({ users }) => users.deleteAll()); + +async function createUserWithSeatedEvent(users: Fixtures["users"]) { + const slug = "seats"; + const user = await users.create({ + eventTypes: [ + { title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 }, + ], + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const eventType = user.eventTypes.find((e) => e.slug === slug)!; + return { user, eventType }; +} + +async function createUserWithSeatedEventAndAttendees( + fixtures: Pick, + attendees: Prisma.AttendeeCreateManyBookingInput[] +) { + const { user, eventType } = await createUserWithSeatedEvent(fixtures.users); + const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, { + status: BookingStatus.ACCEPTED, + // startTime with 1 day from now and endTime half hour after + startTime: new Date(Date.now() + 24 * 60 * 60 * 1000), + endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000), + attendees: { + createMany: { + data: attendees, + }, + }, + }); + return { user, eventType, booking }; +} + +test.describe("Booking with Seats", () => { + test("User can create a seated event (2 seats as example)", async ({ users, page }) => { + const user = await users.create({ name: "Seated event" }); + await user.login(); + const eventTitle = "My 2-seated event"; + await createNewSeatedEventType(page, { eventTitle }); + await expect(page.locator(`text=${eventTitle} 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}`); + await selectFirstAvailableTimeSlotNextMonth(page); + await page.waitForNavigation({ + url(url) { + return url.pathname.endsWith("/book"); + }, + }); + const bookingUrl = page.url(); + await test.step("Attendee #1 can book a seated event time slot", async () => { + await page.goto(bookingUrl); + 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(bookingUrl); + 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 book the same seated event time slot", async () => { + await page.goto(bookingUrl); + await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" }); + await expect(page.locator("[data-testid=success-page]")).toBeHidden(); + }); + }); + // TODO: Make E2E test: Attendee #1 should be able to cancel his booking + // todo("Attendee #1 should be able to cancel his booking"); + // TODO: Make E2E test: Attendee #1 should be able to reschedule his booking + // todo("Attendee #1 should be able to reschedule his booking"); + // TODO: Make E2E test: All attendees canceling should delete the booking for the User + // todo("All attendees canceling should delete the booking for the User"); + + 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@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" }, + ]); + 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() }, + { 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(); + + await page.waitForLoadState("networkidle"); + + await expect(page).toHaveURL(/.*booking/); + + // 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.waitForNavigation({ url: /.*booking/ }); + + await page.goto(`/reschedule/${references[1].referenceUid}`); + + await selectFirstAvailableTimeSlotNextMonth(page); + + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await page.waitForNavigation({ url: /.*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?.attendees).toBeFalsy(); + }); + + 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.login(); + + 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="cancel"]').click(); + + await page.waitForLoadState("networkidle"); + + 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, + 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( + `/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}` + ); + + await page.locator('[data-testid="cancel"]').click(); + + const oldBooking = await prisma.booking.findFirst({ + where: { uid: booking.uid }, + select: { + id: true, + status: true, + }, + }); + + expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED); + + await page.goto(`/reschedule/${references[1].referenceUid}`); + + await page.click('[data-testid="incrementMonth"]'); + + await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click(); + + // Validate that the number of seats its 10 + expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0); + }); + }); +}); diff --git a/apps/web/playwright/event-types.e2e.ts b/apps/web/playwright/event-types.e2e.ts index 4c652478fe..0dc35f1b9e 100644 --- a/apps/web/playwright/event-types.e2e.ts +++ b/apps/web/playwright/event-types.e2e.ts @@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { randomString } from "@calcom/lib/random"; import { test } from "./lib/fixtures"; -import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils"; +import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); @@ -29,38 +29,17 @@ test.describe("Event Types tests", () => { }); test("can add new event type", async ({ page }) => { - await page.click("[data-testid=new-event-type]"); const nonce = randomString(3); const eventTitle = `hello ${nonce}`; - - await page.fill("[name=title]", eventTitle); - await page.fill("[name=length]", "10"); - await page.click("[type=submit]"); - - await page.waitForNavigation({ - url(url) { - return url.pathname !== "/event-types"; - }, - }); - + await createNewEventType(page, { eventTitle }); await page.goto("/event-types"); await expect(page.locator(`text='${eventTitle}'`)).toBeVisible(); }); test("enabling recurring event comes with default options", async ({ page }) => { - await page.click("[data-testid=new-event-type]"); const nonce = randomString(3); const eventTitle = `my recurring event ${nonce}`; - - await page.fill("[name=title]", eventTitle); - await page.fill("[name=length]", "15"); - await page.click("[type=submit]"); - - await page.waitForNavigation({ - url(url) { - return url.pathname !== "/event-types"; - }, - }); + await createNewEventType(page, { eventTitle }); await page.click("[data-testid=vertical-tab-recurring]"); await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden(); diff --git a/apps/web/playwright/fixtures/bookings.ts b/apps/web/playwright/fixtures/bookings.ts index 444beab0e8..900dece2fc 100644 --- a/apps/web/playwright/fixtures/bookings.ts +++ b/apps/web/playwright/fixtures/bookings.ts @@ -18,7 +18,18 @@ export const createBookingsFixture = (page: Page) => { userId: number, username: string | null, eventTypeId = -1, - { rescheduled = false, paid = false, status = "ACCEPTED" }: Partial = {}, + { + rescheduled = false, + paid = false, + status = "ACCEPTED", + attendees = { + create: { + email: "attendee@example.com", + name: "Attendee Example", + timeZone: "Europe/London", + }, + }, + }: Partial = {}, startDateParam?: Date, endDateParam?: Date ) => { @@ -36,13 +47,7 @@ export const createBookingsFixture = (page: Page) => { id: userId, }, }, - attendees: { - create: { - email: "attendee@example.com", - name: "Attendee Example", - timeZone: "Europe/London", - }, - }, + attendees, eventType: { connect: { id: eventTypeId, diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index f53a4c58fb..68fbeb3175 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -69,61 +69,24 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => { } = {} ) => { const _user = await prisma.user.create({ - data: await createUser(workerInfo, opts), - }); - await prisma.eventType.create({ - data: { - owner: { - connect: { - id: _user.id, - }, - }, - users: { - connect: { - id: _user.id, - }, - }, - title: "30 min", - slug: "30-min", - length: 30, - }, - }); - await prisma.eventType.create({ - data: { - users: { - connect: { - id: _user.id, - }, - }, - owner: { - connect: { - id: _user.id, - }, - }, - title: "Paid", - slug: "paid", - length: 30, - price: 1000, - }, - }); - await prisma.eventType.create({ - data: { - users: { - connect: { - id: _user.id, - }, - }, - owner: { - connect: { - id: _user.id, - }, - }, - title: "Opt in", - slug: "opt-in", - requiresConfirmation: true, - length: 30, - }, + data: createUser(workerInfo, opts), }); + + let defaultEventTypes: SupportedTestEventTypes[] = [ + { title: "30 min", slug: "30-min", length: 30 }, + { title: "Paid", slug: "paid", length: 30, price: 1000 }, + { title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 }, + ]; + + if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes); + for (const eventTypeData of defaultEventTypes) { + eventTypeData.owner = { connect: { id: _user.id } }; + eventTypeData.users = { connect: { id: _user.id } }; + await prisma.eventType.create({ + data: eventTypeData, + }); + } + if (scenario.seedRoutingForms) { await prisma.app_RoutingForms_Form.create({ data: { @@ -314,8 +277,14 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { }; }; +type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & { + _bookings?: PrismaType.BookingCreateInput[]; +}; type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name"; -type CustomUserOpts = Partial> & { timeZone?: TimeZoneEnum }; +type CustomUserOpts = Partial> & { + timeZone?: TimeZoneEnum; + eventTypes?: SupportedTestEventTypes[]; +}; // creates the actual user in the db. const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => { diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index b97f5f29f5..57e9fed6f2 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -133,10 +133,10 @@ export async function bookFirstEvent(page: Page) { await bookEventOnThisPage(page); } -export const bookTimeSlot = async (page: Page) => { +export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => { // --- fill form - await page.fill('[name="name"]', "Test Testson"); - await page.fill('[name="email"]', "test@example.com"); + await page.fill('[name="name"]', opts?.name ?? "Test Testson"); + await page.fill('[name="email"]', opts?.email ?? "test@example.com"); await page.press('[name="email"]', "Enter"); }; // Provide an standalone localize utility not managed by next-i18n @@ -148,3 +148,25 @@ export async function localize(locale: string) { throw "No locale found for the given entry message"; }; } + +export const createNewEventType = async (page: Page, args: { eventTitle: string }) => { + await page.click("[data-testid=new-event-type]"); + const eventTitle = args.eventTitle; + await page.fill("[name=title]", eventTitle); + await page.fill("[name=length]", "10"); + await page.click("[type=submit]"); + + await page.waitForNavigation({ + url(url) { + return url.pathname !== "/event-types"; + }, + }); +}; + +export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => { + const eventTitle = args.eventTitle; + await createNewEventType(page, { eventTitle }); + await page.locator('[data-testid="vertical-tab-event_advanced_tab_title"]').click(); + await page.locator('[data-testid="offer-seats-toggle"]').click(); + await page.locator('[data-testid="update-eventtype"]').click(); +}; diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index bf50b7dabd..2f713a61dc 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -19,7 +19,7 @@ test.afterEach(({ users }) => users.deleteAll()); test.describe("Reschedule Tests", async () => { test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => { const user = await users.create(); - + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { status: BookingStatus.ACCEPTED, }); @@ -45,15 +45,18 @@ test.describe("Reschedule Tests", async () => { }); test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => { - test.skip(true, "TODO: Re-enable after v1.7 launch"); const user = await users.create(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { status: BookingStatus.CANCELLED, rescheduled: true, }); await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`); - const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]'); + + await selectFirstAvailableTimeSlotNextMonth(page); + + const formerTimeElement = page.locator('[data-testid="former_time_p"]'); await expect(formerTimeElement).toBeVisible(); await booking.delete(); }); @@ -90,10 +93,12 @@ test.describe("Reschedule Tests", async () => { await page.locator('[data-testid="confirm-reschedule-button"]').click(); + await page.waitForLoadState("networkidle"); + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); // NOTE: remove if old booking should not be deleted - expect(await booking.self()).toBeNull(); + expect(await (await booking.self()).status).toBe(BookingStatus.CANCELLED); const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } }); expect(newBooking).not.toBeNull(); @@ -106,6 +111,7 @@ test.describe("Reschedule Tests", async () => { const user = await users.create(); await user.login(); await user.getPaymentCredential(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "paid")!; const booking = await bookings.create(user.id, user.username, eventType.id, { rescheduled: true, @@ -150,6 +156,7 @@ test.describe("Reschedule Tests", async () => { await user.login(); await user.getPaymentCredential(); await users.logout(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "paid")!; const booking = await bookings.create(user.id, user.username, eventType.id, { rescheduled: true, @@ -171,6 +178,7 @@ test.describe("Reschedule Tests", async () => { test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => { const user = await users.create(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; const booking = await bookings.create(user.id, user.username, eventType.id, { status: BookingStatus.ACCEPTED, @@ -191,6 +199,7 @@ test.describe("Reschedule Tests", async () => { test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => { const user = await users.create(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; const booking = await bookings.create(user.id, user.username, eventType.id, { status: BookingStatus.ACCEPTED, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e8bb517b6c..553a76ecca 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1597,6 +1597,11 @@ "create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.", "collective_or_roundrobin": "Collective or round-robin", "book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.", + "event_no_longer_attending_subject": "No longer attending {{title}} at {{date}}", + "no_longer_attending": "You are no longer attending this event", + "attendee_no_longer_attending_subject": "An attendee is no longer attending {{title}} at {{date}}", + "attendee_no_longer_attending": "An attendee is no longer attending your event", + "attendee_no_longer_attending_subtitle": "{{name}} has cancelled. This means a seat has opened up for this time slot", "create_event_on": "Create event on", "default_app_link_title": "Set a default app link", "default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.", @@ -1612,6 +1617,7 @@ "booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.", "booking_with_payment_cancelled_refunded": "This booking payment has been refunded.", "booking_confirmation_failed": "Booking confirmation failed", + "not_enough_seats": "Not enough seats", "form_builder_field_already_exists": "A field with this name already exists", "form_builder_field_add_subtitle": "Customize the questions asked on the booking page", "form_builder_system_field_cant_delete": "This system field can't be removed.", diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index ed2eb245f2..f977585342 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -71,6 +71,10 @@ export default class GoogleCalendarService implements Calendar { }; async createEvent(calEventRaw: CalendarEvent): Promise { + const eventAttendees = calEventRaw.attendees.map(({ id, ...rest }) => ({ + ...rest, + responseStatus: "accepted", + })); return new Promise(async (resolve, reject) => { const myGoogleAuth = await this.auth.getToken(); const payload: calendar_v3.Schema$Event = { @@ -94,11 +98,7 @@ export default class GoogleCalendarService implements Calendar { ? calEventRaw.destinationCalendar.externalId : calEventRaw.organizer.email, }, - // eslint-disable-next-line - ...calEventRaw.attendees.map(({ id, ...rest }) => ({ - ...rest, - responseStatus: "accepted", - })), + ...eventAttendees, ], reminders: { useDefault: true, diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index da20dca0c1..7507cf6246 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -236,7 +236,7 @@ export const createEvent = async ( calEvent.additionalNotes = "Notes have been hidden by the organiser"; // TODO: i18n this string? } - // TODO: Surfice success/error messages coming from apps to improve end user visibility + // TODO: Surface success/error messages coming from apps to improve end user visibility const creationResult = calendar ? await calendar.createEvent(calEvent).catch(async (error) => { success = false; diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index ead4cf1cf5..845db40562 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -1,8 +1,10 @@ -import type { DestinationCalendar } from "@prisma/client"; +import type { DestinationCalendar, Booking } from "@prisma/client"; +import { cloneDeep } from "lodash"; import merge from "lodash/merge"; import { v5 as uuidv5 } from "uuid"; import type { z } from "zod"; +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { getEventLocationTypeFromApp } from "@calcom/app-store/locations"; import { MeetLocationType } from "@calcom/app-store/locations"; @@ -119,8 +121,10 @@ export default class EventManager { results.push(result); } + // Some calendar libraries may edit the original event so let's clone it + const clonedCalEvent = cloneDeep(event); // Create the calendar event with the proper video call data - results.push(...(await this.createAllCalendarEvents(evt))); + results.push(...(await this.createAllCalendarEvents(clonedCalEvent))); const referencesToCreate = results.map((result) => { let createdEventObj: createdEventSchema | null = null; @@ -193,11 +197,10 @@ export default class EventManager { public async reschedule( event: CalendarEvent, rescheduleUid: string, - newBookingId?: number, - rescheduleReason?: string + newBookingId?: number ): Promise { - const evt = processLocation(event); - + const originalEvt = processLocation(event); + const evt = cloneDeep(originalEvt); if (!rescheduleUid) { throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen."); } @@ -210,6 +213,7 @@ export default class EventManager { select: { id: true, userId: true, + attendees: true, references: { // NOTE: id field removed from select as we don't require for deletingMany // but was giving error on recreate for reschedule, probably because promise.all() didn't finished @@ -225,6 +229,12 @@ export default class EventManager { }, destinationCalendar: true, payment: true, + eventType: { + select: { + seatsPerTimeSlot: true, + seatsShowAttendees: true, + }, + }, }, }); @@ -232,22 +242,13 @@ export default class EventManager { throw new Error("booking not found"); } - // Add reschedule reason to new booking - await prisma.booking.update({ - where: { - id: newBookingId, - }, - data: { - cancellationReason: rescheduleReason, - }, - }); - const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; const results: Array> = []; // If and only if event type is a dedicated meeting, update the dedicated video meeting. if (isDedicated) { const result = await this.updateVideoEvent(evt, booking); const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent]; + if (updatedEvent) { evt.videoCallData = updatedEvent; evt.location = updatedEvent.url; @@ -255,7 +256,11 @@ export default class EventManager { results.push(result); } - results.push(...(await this.updateAllCalendarEvents(evt, booking))); + // There was a case that booking didn't had any reference and we don't want to throw error on function + if (booking.references.find((reference) => reference.type.includes("_calendar"))) { + // Update all calendar events. + results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); + } const bookingPayment = booking?.payment; @@ -274,27 +279,6 @@ export default class EventManager { }); } - // Now we can delete the old booking and its references. - const bookingReferenceDeletes = prisma.bookingReference.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - const attendeeDeletes = prisma.attendee.deleteMany({ - where: { - bookingId: booking.id, - }, - }); - - const bookingDeletes = prisma.booking.delete({ - where: { - id: booking.id, - }, - }); - - // Wait for all deletions to be applied. - await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]); - return { results, referencesToCreate: [...booking.references], @@ -302,7 +286,10 @@ export default class EventManager { } public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) { - // @NOTE: This function is only used for updating attendees on a calendar event. Can we remove this? + if (booking.references.length === 0) { + console.error("Tried to update references but there wasn't any."); + return; + } await this.updateAllCalendarEvents(event, booking); } @@ -386,7 +373,7 @@ export default class EventManager { let videoCredential = this.videoCredentials // Whenever a new video connection is added, latest credentials are added with the highest ID. - // Because you can't rely on having them in the higgest first order here, ensure this by sorting in DESC order + // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order .sort((a, b) => { return b.id - a.id; }) @@ -433,13 +420,32 @@ export default class EventManager { */ private async updateAllCalendarEvents( event: CalendarEvent, - booking: PartialBooking + booking: PartialBooking, + newBookingId?: number ): Promise>> { let calendarReference: PartialReference | undefined = undefined, credential; try { - // Bookings should only have one calendar reference - calendarReference = booking.references.filter((reference) => reference.type.includes("_calendar"))[0]; + // If a newBookingId is given, update that calendar event + let newBooking; + if (newBookingId) { + newBooking = await prisma.booking.findUnique({ + where: { + id: newBookingId, + }, + select: { + references: true, + }, + }); + } + + if (newBooking) { + calendarReference = newBooking.references.find((reference) => reference.type.includes("_calendar")); + } else { + // Bookings should only have one calendar reference + calendarReference = booking.references.find((reference) => reference.type.includes("_calendar")); + } + if (!calendarReference) { return []; } @@ -464,6 +470,22 @@ export default class EventManager { } } + // If we are merging two calendar events we should delete the old calendar event + if (newBookingId) { + const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar")); + + if (oldCalendarEvent?.credentialId) { + const calendarCredential = await prisma.credential.findUnique({ + where: { + id: oldCalendarEvent.credentialId, + }, + }); + const calendar = getCalendar(calendarCredential); + + await calendar?.deleteEvent(oldCalendarEvent.uid, event, oldCalendarEvent.externalCalendarId); + } + } + // Taking care of non-traditional calendar integrations result = result.concat( this.calendarCredentials @@ -536,4 +558,14 @@ export default class EventManager { public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) { await this.updateAllCalendarEvents(event, booking); } + + public async rescheduleBookingWithSeats( + originalBooking: Booking, + newTimeSlotBooking?: Booking, + owner?: boolean + ) { + // Get originalBooking + // If originalBooking has only one attendee we should do normal reschedule + // Change current event attendees in everyone calendar + } } diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index b6bdc0e463..b4e57e9dff 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -1,3 +1,4 @@ +import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; import type { EventNameObjectType } from "@calcom/core/event"; @@ -7,6 +8,7 @@ import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email"; import AttendeeCancelledEmail from "./templates/attendee-cancelled-email"; +import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email"; import AttendeeDeclinedEmail from "./templates/attendee-declined-email"; import AttendeeLocationChangeEmail from "./templates/attendee-location-change-email"; import AttendeeRequestEmail from "./templates/attendee-request-email"; @@ -19,6 +21,7 @@ import type { Feedback } from "./templates/feedback-email"; import FeedbackEmail from "./templates/feedback-email"; import type { PasswordReset } from "./templates/forgot-password-email"; import ForgotPasswordEmail from "./templates/forgot-password-email"; +import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email"; import OrganizerCancelledEmail from "./templates/organizer-cancelled-email"; import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email"; import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email"; @@ -83,7 +86,6 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { } } - // @TODO: we should obtain who is rescheduling the event and send them a different email emailsToSend.push( ...calEvent.attendees.map((attendee) => { return sendEmail(() => new AttendeeRescheduledEmail(calEvent, attendee)); @@ -93,6 +95,16 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { await Promise.all(emailsToSend); }; +export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => { + const clonedCalEvent = cloneDeep(calEvent); + const emailsToSend: Promise[] = [ + sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)), + sendEmail(() => new OrganizerRescheduledEmail({ calEvent })), + ]; + + await Promise.all(emailsToSend); +}; + export const sendScheduledSeatsEmails = async ( calEvent: CalendarEvent, invitee: Person, @@ -114,6 +126,14 @@ export const sendScheduledSeatsEmails = async ( await Promise.all(emailsToSend); }; +export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => { + const clonedCalEvent = cloneDeep(calEvent); + await Promise.all([ + sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)), + sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent })), + ]); +}; + export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { const emailsToSend: Promise[] = []; diff --git a/packages/emails/src/components/ManageLink.tsx b/packages/emails/src/components/ManageLink.tsx index 7f3051db43..15967077be 100644 --- a/packages/emails/src/components/ManageLink.tsx +++ b/packages/emails/src/components/ManageLink.tsx @@ -6,7 +6,7 @@ export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person }) // Guests cannot const t = props.attendee.language.translate; if ( - props.attendee.email === props.calEvent.attendees[0].email || + props.attendee.email === props.calEvent.attendees[0]?.email || props.calEvent.organizer.email === props.attendee.email ) { return ( diff --git a/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx b/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx new file mode 100644 index 0000000000..fefcdfa2e0 --- /dev/null +++ b/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx @@ -0,0 +1,12 @@ +import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail"; + +export const AttendeeCancelledSeatEmail = (props: React.ComponentProps) => ( + +); diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index 1761ffa3f0..a755e037ef 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -58,13 +58,15 @@ export const BaseScheduledEmail = ( : props.callToAction || } subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}}> - + {props.calEvent.cancellationReason && ( + + )} diff --git a/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx b/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx new file mode 100644 index 0000000000..873230671d --- /dev/null +++ b/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx @@ -0,0 +1,14 @@ +import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; + +export const OrganizerAttendeeCancelledSeatEmail = ( + props: React.ComponentProps +) => ( + +); diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index 1acde1f278..fc3eab435f 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -7,6 +7,7 @@ export const OrganizerScheduledEmail = ( calEvent: CalendarEvent; attendee: Person; newSeat?: boolean; + attendeeCancelled?: boolean; teamMember?: Person; } & Partial> ) => { @@ -35,6 +36,13 @@ export const OrganizerScheduledEmail = ( subject={t(subject)} title={t(title)} includeAppsStatus + subtitle={ + <> + {props.attendeeCancelled + ? t("attendee_no_longer_attending_subtitle", { name: props.attendee.name }) + : ""} + + } {...props} /> ); diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index c9ff822a3f..d1d7d7bcb5 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -1,5 +1,6 @@ export { AttendeeAwaitingPaymentEmail } from "./AttendeeAwaitingPaymentEmail"; export { AttendeeCancelledEmail } from "./AttendeeCancelledEmail"; +export { AttendeeCancelledSeatEmail } from "./AttendeeCancelledSeatEmail"; export { AttendeeDeclinedEmail } from "./AttendeeDeclinedEmail"; export { AttendeeLocationChangeEmail } from "./AttendeeLocationChangeEmail"; export { AttendeeRequestEmail } from "./AttendeeRequestEmail"; @@ -19,4 +20,5 @@ export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail"; export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; export { TeamInviteEmail } from "./TeamInviteEmail"; export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail"; +export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail"; export * from "@calcom/app-store/routing-forms/emails/components"; diff --git a/packages/emails/templates/attendee-cancelled-seat-email.ts b/packages/emails/templates/attendee-cancelled-seat-email.ts new file mode 100644 index 0000000000..396891800c --- /dev/null +++ b/packages/emails/templates/attendee-cancelled-seat-email.ts @@ -0,0 +1,21 @@ +import { renderEmail } from "../"; +import AttendeeScheduledEmail from "./attendee-scheduled-email"; + +export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail { + protected getNodeMailerPayload(): Record { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: this.calEvent.organizer.email, + subject: `${this.t("event_no_longer_attending_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: renderEmail("AttendeeCancelledSeatEmail", { + calEvent: this.calEvent, + attendee: this.attendee, + }), + text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"), + }; + } +} diff --git a/packages/emails/templates/attendee-scheduled-email.ts b/packages/emails/templates/attendee-scheduled-email.ts index d3efdd4c6e..a887c4bd05 100644 --- a/packages/emails/templates/attendee-scheduled-email.ts +++ b/packages/emails/templates/attendee-scheduled-email.ts @@ -1,5 +1,6 @@ import type { DateArray, ParticipationStatus, ParticipationRole } from "ics"; import { createEvent } from "ics"; +import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; import { RRule } from "rrule"; @@ -15,6 +16,7 @@ export default class AttendeeScheduledEmail extends BaseEmail { attendee: Person; showAttendees: boolean | undefined; t: TFunction; + attendees: Person[]; constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) { super(); @@ -23,8 +25,9 @@ export default class AttendeeScheduledEmail extends BaseEmail { this.attendee = attendee; this.showAttendees = showAttendees; this.t = attendee.language.translate; - - if (!this.showAttendees) { + this.attendees = [...this.calEvent.attendees]; + if (!this.showAttendees && this.calEvent.seatsPerTimeSlot) { + this.attendees = [this.attendee]; this.calEvent.attendees = [this.attendee]; } } @@ -50,6 +53,7 @@ export default class AttendeeScheduledEmail extends BaseEmail { description: this.getTextBody(), duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") }, organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email }, + attendees: [ ...this.calEvent.attendees.map((attendee: Person) => ({ name: attendee.name, @@ -79,6 +83,10 @@ export default class AttendeeScheduledEmail extends BaseEmail { } protected getNodeMailerPayload(): Record { + const clonedCalEvent = cloneDeep(this.calEvent); + + this.getiCalEventAsString(); + return { icalEvent: { filename: "event.ics", @@ -90,7 +98,7 @@ export default class AttendeeScheduledEmail extends BaseEmail { replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], subject: `${this.calEvent.title}`, html: renderEmail("AttendeeScheduledEmail", { - calEvent: this.calEvent, + calEvent: clonedCalEvent, attendee: this.attendee, }), text: this.getTextBody(), diff --git a/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts b/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts new file mode 100644 index 0000000000..fe6ce75726 --- /dev/null +++ b/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts @@ -0,0 +1,32 @@ +import { APP_NAME } from "@calcom/lib/constants"; + +import { renderEmail } from "../"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { + protected getNodeMailerPayload(): Record { + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.email === member.email); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } + + return { + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + subject: `${this.t("event_cancelled_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: renderEmail("OrganizerAttendeeCancelledSeatEmail", { + attendee: this.calEvent.organizer, + calEvent: this.calEvent, + }), + text: this.getTextBody("event_request_cancelled"), + }; + } +} diff --git a/packages/emails/templates/organizer-rescheduled-email.ts b/packages/emails/templates/organizer-rescheduled-email.ts index 07808a0863..fe8fcc154e 100644 --- a/packages/emails/templates/organizer-rescheduled-email.ts +++ b/packages/emails/templates/organizer-rescheduled-email.ts @@ -25,7 +25,7 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail { )} ${this.getOrganizerStart("D")}, ${this.getOrganizerStart("YYYY")}`, })}`, html: renderEmail("OrganizerRescheduledEmail", { - calEvent: this.calEvent, + calEvent: { ...this.calEvent, attendeeSeatId: undefined }, attendee: this.calEvent.organizer, }), text: this.getTextBody("event_has_been_rescheduled"), diff --git a/packages/emails/templates/organizer-scheduled-email.ts b/packages/emails/templates/organizer-scheduled-email.ts index eeaa55134d..02828a5992 100644 --- a/packages/emails/templates/organizer-scheduled-email.ts +++ b/packages/emails/templates/organizer-scheduled-email.ts @@ -1,5 +1,6 @@ import type { DateArray } from "ics"; import { createEvent } from "ics"; +import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; import { RRule } from "rrule"; @@ -67,7 +68,17 @@ export default class OrganizerScheduledEmail extends BaseEmail { } protected getNodeMailerPayload(): Record { - const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email]; + const clonedCalEvent = cloneDeep(this.calEvent); + + const toAddresses = [this.calEvent.organizer.email]; + if (this.calEvent.team) { + this.calEvent.team.members.forEach((member) => { + const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.email === member.email); + if (memberAttendee) { + toAddresses.push(memberAttendee.email); + } + }); + } return { icalEvent: { @@ -78,7 +89,7 @@ export default class OrganizerScheduledEmail extends BaseEmail { to: toAddresses.join(","), subject: `${this.newSeat ? this.t("new_attendee") + ":" : ""} ${this.calEvent.title}`, html: renderEmail("OrganizerScheduledEmail", { - calEvent: this.calEvent, + calEvent: clonedCalEvent, attendee: this.calEvent.organizer, teamMember: this.teamMember, newSeat: this.newSeat, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 4ced54cf23..3c91f3b742 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -7,9 +7,9 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { DailyLocationType } from "@calcom/app-store/locations"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; -import { deleteMeeting } from "@calcom/core/videoClient"; +import { deleteMeeting, updateMeeting } from "@calcom/core/videoClient"; import dayjs from "@calcom/dayjs"; -import { sendCancelledEmails } from "@calcom/emails"; +import { sendCancelledEmails, sendCancelledSeatEmails } from "@calcom/emails"; import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; @@ -24,12 +24,8 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; -async function handler(req: NextApiRequest & { userId?: number }) { - const { userId } = req; - - const { id, uid, allRemainingBookings, cancellationReason } = schemaBookingCancelParams.parse(req.body); - - const bookingToDelete = await prisma.booking.findUnique({ +async function getBookingToDelete(id: number | undefined, uid: string | undefined) { + return await prisma.booking.findUnique({ where: { id, uid, @@ -70,6 +66,7 @@ async function handler(req: NextApiRequest & { userId?: number }) { price: true, currency: true, length: true, + seatsPerTimeSlot: true, hosts: { select: { user: true, @@ -92,8 +89,21 @@ async function handler(req: NextApiRequest & { userId?: number }) { smsReminderNumber: true, workflowReminders: true, scheduledJobs: true, + seatsReferences: true, }, }); +} + +type CustomRequest = NextApiRequest & { + userId?: number; + bookingToDelete?: Awaited>; +}; + +async function handler(req: CustomRequest) { + const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } = + schemaBookingCancelParams.parse(req.body); + req.bookingToDelete = await getBookingToDelete(id, uid); + const { bookingToDelete, userId } = req; if (!bookingToDelete || !bookingToDelete.user) { throw new HttpError({ statusCode: 400, message: "Booking not found" }); @@ -107,6 +117,10 @@ async function handler(req: NextApiRequest & { userId?: number }) { throw new HttpError({ statusCode: 400, message: "User not found" }); } + // If it's just an attendee of a booking then just remove them from that booking + const result = await handleSeatedEventCancellation(req); + if (result) return { success: true }; + const organizer = await prisma.user.findFirstOrThrow({ where: { id: bookingToDelete.userId, @@ -178,6 +192,99 @@ async function handler(req: NextApiRequest & { userId?: number }) { ...(teamMembers && { team: { name: "", members: teamMembers } }), }; + // If it's just an attendee of a booking then just remove them from that booking + if (seatReferenceUid && bookingToDelete.attendees.length > 1) { + const seatReference = bookingToDelete.seatsReferences.find( + (reference) => reference.referenceUid === seatReferenceUid + ); + + const attendee = bookingToDelete.attendees.find((attendee) => attendee.id === seatReference?.attendeeId); + + if (!seatReference || !attendee) + throw new HttpError({ statusCode: 400, message: "User not a part of this booking" }); + + await prisma.attendee.delete({ + where: { + id: seatReference.attendeeId, + }, + }); + + /* If there are references then we should update them as well */ + const lastAttendee = + bookingToDelete.attendees.filter((bookingAttendee) => attendee.email !== bookingAttendee.email).length < + 0; + + const integrationsToDelete = []; + + for (const reference of bookingToDelete.references) { + if (reference.credentialId) { + const credential = await prisma.credential.findUnique({ + where: { + id: reference.credentialId, + }, + }); + + if (credential) { + if (lastAttendee) { + if (reference.type.includes("_video")) { + integrationsToDelete.push(deleteMeeting(credential, reference.uid)); + } + if (reference.type.includes("_calendar")) { + const calendar = getCalendar(credential); + if (calendar) { + integrationsToDelete.push( + calendar?.deleteEvent(reference.uid, evt, reference.externalCalendarId) + ); + } + } + } else { + const updatedEvt = { + ...evt, + attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email), + }; + if (reference.type.includes("_video")) { + integrationsToDelete.push( + updateMeeting( + { ...credential, appName: evt.location?.replace("integrations:", "") || "" }, + updatedEvt, + reference + ) + ); + } + if (reference.type.includes("_calendar")) { + const calendar = getCalendar(credential); + if (calendar) { + integrationsToDelete.push( + calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId) + ); + } + } + } + } + } + } + + await Promise.all(integrationsToDelete).then(async () => { + if (lastAttendee) { + await prisma.booking.delete({ + where: { + id: bookingToDelete.id, + }, + }); + } + }); + + const tAttendees = await getTranslation(attendee.locale ?? "en", "common"); + + await sendCancelledSeatEmails(evt, { + ...attendee, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }); + + req.statusCode = 200; + return { message: "No longer attending event" }; + } + // Hook up the webhook logic here const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; // Send Webhook call if hooked to BOOKING.CANCELLED @@ -274,6 +381,13 @@ async function handler(req: NextApiRequest & { userId?: number }) { }); updatedBookings = updatedBookings.concat(allUpdatedBookings); } else { + if (bookingToDelete?.eventType?.seatsPerTimeSlot) { + await prisma.attendee.deleteMany({ + where: { + bookingId: bookingToDelete.id, + }, + }); + } const updatedBooking = await prisma.booking.update({ where: { id, @@ -524,4 +638,31 @@ async function handler(req: NextApiRequest & { userId?: number }) { return { message: "Booking successfully cancelled." }; } +async function handleSeatedEventCancellation(req: CustomRequest) { + const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body); + if (!seatReferenceUid) return; + if (!req.bookingToDelete?.attendees.length || req.bookingToDelete.attendees.length < 2) return; + + const seatReference = req.bookingToDelete.seatsReferences.find( + (reference) => reference.referenceUid === seatReferenceUid + ); + + if (!seatReference) throw new HttpError({ statusCode: 400, message: "User not a part of this booking" }); + + await Promise.all([ + prisma.bookingSeat.delete({ + where: { + referenceUid: seatReferenceUid, + }, + }), + prisma.attendee.delete({ + where: { + id: seatReference.attendeeId, + }, + }), + ]); + req.statusCode = 200; + return { success: true }; +} + export default handler; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 32ba96837a..d6bbe78a5e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,13 +1,14 @@ -import type { App, Credential, EventTypeCustomInput } from "@prisma/client"; +import type { App, Attendee, Credential, EventTypeCustomInput } from "@prisma/client"; import { BookingStatus, SchedulingType, WebhookTriggerEvents, WorkflowMethods, Prisma } from "@prisma/client"; import async from "async"; import { isValidPhoneNumber } from "libphonenumber-js"; import { cloneDeep } from "lodash"; import type { NextApiRequest } from "next"; -import short from "short-uuid"; +import short, { uuid } from "short-uuid"; 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 { getLocationValueForDB } from "@calcom/app-store/locations"; @@ -19,6 +20,7 @@ import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/l 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 type { ConfigType, Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { @@ -26,6 +28,7 @@ import { sendOrganizerRequestEmail, sendRescheduledEmails, sendScheduledEmails, + sendRescheduledSeatEmail, sendScheduledSeatsEmails, } from "@calcom/emails"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; @@ -49,6 +52,7 @@ import { slugify } from "@calcom/lib/slugify"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import { TimeFormat } from "@calcom/lib/timeFormat"; import prisma, { userSelect } from "@calcom/prisma"; +import type { BookingReference } from "@calcom/prisma/client"; import type { bookingCreateSchemaLegacyPropsForApi } from "@calcom/prisma/zod-utils"; import { bookingCreateBodySchemaForApi, @@ -58,7 +62,7 @@ import { userMetadata as userMetadataSchema, } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; -import type { AdditionalInformation, AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; +import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { WorkingHours } from "@calcom/types/schedule"; @@ -326,6 +330,40 @@ async function ensureAvailableUsers( return availableUsers; } +async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boolean) { + return prisma.booking.findFirst({ + where: { + uid: uid, + status: { + in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING], + }, + }, + include: { + attendees: { + select: { + name: true, + email: true, + locale: true, + timeZone: true, + ...(seatsEventType && { bookingSeat: true, id: true }), + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + locale: true, + timeZone: true, + }, + }, + payment: true, + references: true, + workflowReminders: true, + }, + }); +} + function getBookingData({ req, isNotAnApiCall, @@ -638,7 +676,7 @@ async function handler( await handleEthSignature(rainbowAppData, reqBody.ethSignature); const [organizerUser] = users; - const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common"); + const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); const invitee = [ { @@ -758,46 +796,38 @@ async function handler( seatsPerTimeSlot: eventType.seatsPerTimeSlot, }; - // For seats, if the booking already exists then we want to add the new attendee to the existing booking - if (reqBody.bookingUid) { - if (!eventType.seatsPerTimeSlot) { - throw new HttpError({ statusCode: 404, message: "Event type does not have seats" }); - } + let rescheduleUid = reqBody.rescheduleUid; + let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null; + type BookingType = Prisma.PromiseReturnType; + let originalRescheduledBooking: BookingType = null; - const booking = await prisma.booking.findUnique({ + if (rescheduleUid) { + // rescheduleUid can be bookingUid and bookingSeatUid + bookingSeat = await prisma.bookingSeat.findUnique({ where: { - uid: reqBody.bookingUid, + referenceUid: rescheduleUid, }, - select: { - uid: true, - id: true, - attendees: true, - userId: true, - references: true, - startTime: true, - user: true, + include: { + booking: true, + attendee: true, }, }); - if (!booking) { - throw new HttpError({ statusCode: 404, message: "Booking not found" }); + if (bookingSeat) { + bookingSeat = bookingSeat; + rescheduleUid = bookingSeat.booking.uid; } - - // 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: language ?? "en" } }; - }); - - evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] }; - - if (eventType.seatsPerTimeSlot <= booking.attendees.length) { - throw new HttpError({ statusCode: 409, message: "Booking seats are full" }); + originalRescheduledBooking = await getOriginalRescheduledBooking( + rescheduleUid, + !!eventType.seatsPerTimeSlot + ); + if (!originalRescheduledBooking) { + throw new HttpError({ statusCode: 404, message: "Could not find original booking" }); } + } - if (booking.attendees.find((attendee) => attendee.email === invitee[0].email)) { - throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" }); - } - - const videoCallReference = booking.references.find((reference) => reference.type.includes("_video")); + /* Used for seats bookings to update evt object with video data */ + const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => { + const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); if (videoCallReference) { evt.videoCallData = { @@ -807,84 +837,552 @@ async function handler( url: videoCallReference.meetingUrl, }; } - await prisma.booking.update({ - where: { - uid: reqBody.bookingUid, - }, - data: { - attendees: { - create: { - email: invitee[0].email, - name: invitee[0].name, - timeZone: invitee[0].timeZone, - locale: invitee[0].language.locale, + }; + + /* 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, + }, + }); + + if (credential) { + if (reference.type.includes("_video")) { + integrationsToDelete.push(deleteMeeting(credential, reference.uid)); + } + if (reference.type.includes("_calendar") && originalBookingEvt) { + const calendar = getCalendar(credential); + if (calendar) { + integrationsToDelete.push( + calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId) + ); + } + } + } + } + } + + await Promise.all(integrationsToDelete).then(async () => { + await prisma.booking.delete({ + where: { + id: originalRescheduledBooking.id, }, - }, + }); + }); + deletedReferences = true; + } + return deletedReferences; + }; + + const handleSeats = async (): Promise< + | (Partial & { + appsStatus?: AppsStatus[]; + seatReferenceUid?: string; + paymentUid?: string; + message?: string; + }) + | null + > => { + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + uid: rescheduleUid || reqBody.bookingUid, + }, + select: { + uid: true, + id: true, + attendees: { include: { bookingSeat: true } }, + userId: true, + references: true, + startTime: true, + user: true, + status: true, }, }); - - 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 - */ - const copyEvent = cloneDeep(evt); - await sendScheduledSeatsEmails(copyEvent, invitee[0], newSeat, !!eventType.seatsShowAttendees); - - const credentials = await refreshCredentials(organizerUser.credentials); - const eventManager = new EventManager({ ...organizerUser, credentials }); - await eventManager.updateCalendarAttendees(evt, booking); - - if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) { - const credentialPaymentAppCategories = await prisma.credential.findMany({ - where: { - 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 - ); - - req.statusCode = 201; - return { ...booking, message: "Payment required", paymentUid: payment?.uid }; + // 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." }); } - req.statusCode = 201; - return booking; - } + // There are two paths here, reschedule a booking with seats and booking seats without reschedule + if (rescheduleUid) { + const seatAttendee: Partial | null = bookingSeat?.attendee || null; + // Required for Typescript, these should always be set. + if (!seatAttendee || !bookingSeat || !rescheduleUid) { + throw new Error("Internal Error."); + } + seatAttendee.language = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" }; + + // See if the new date has a booking already + const newTimeSlotBooking = await prisma.booking.findFirst({ + where: { + startTime: evt.startTime, + eventTypeId: eventType.id, + }, + select: { + id: true, + uid: true, + attendees: { + include: { + bookingSeat: true, + }, + }, + }, + }); + + const credentials = await refreshCredentials(organizerUser.credentials); + 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 owner reschedules the event we want to update the entire booking + // Also if owner is rescheduling there should be no bookingSeat + if (booking.user?.id === req.userId && !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, + cancellationReason: rescheduleReason, + }, + include: { + user: true, + references: true, + payment: true, + attendees: true, + }, + }); + + addVideoCallDataToEvt(newBooking.references); + + const copyEvent = cloneDeep(evt); + + const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id); + + // @NOTE: This code is duplicated and should be moved to a function + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + + const results = updateManager.results; + + if (results.length > 0 && results.some((res) => !res.success)) { + const error = { + errorCode: "BookingReschedulingMeetingFailed", + message: "Booking Rescheduling failed", + }; + log.error(`Booking ${organizerUser.name} failed`, 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; + handleAppsStatus(results, newBooking); + } + } + } + + if (noEmail !== true) { + const copyEvent = cloneDeep(evt); + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: "$RCH$" + rescheduleReason, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } + const resultBooking = await resultBookingQuery(newBooking.id); + + return { ...resultBooking, appsStatus: newBooking.appsStatus }; + } + + // 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: language ?? "en" } }; + return evtAttendee; + }); + + evt.attendees = updatedBookingAttendees; + + addVideoCallDataToEvt(updatedNewBooking.references); + + const copyEvent = cloneDeep(evt); + + await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + // TODO send reschedule emails to attendees of the old booking + await sendRescheduledEmails({ + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: "$RCH$" + rescheduleReason, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + + // Delete the old booking + await prisma.booking.delete({ + where: { + id: booking.id, + }, + }); + + const resultBooking = await resultBookingQuery(newTimeSlotBooking.id); + + return { ...resultBooking }; + } + + // If there is no booking then remove the attendee from the old booking and create a new one + if (!newTimeSlotBooking) { + await prisma.bookingSeat.delete({ + where: { + id: bookingSeat.id, + }, + }); + + 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 + 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); + + await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id); + + await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person); + const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { + return attendee.email !== bookerEmail; + }); + await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt); + + const resultBooking = await resultBookingQuery(newTimeSlotBooking.id); + + return { ...resultBooking, 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: language ?? "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 bookingUpdated = 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, + }, + }, + ...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }), + }, + }); + + // Add entry to bookingSeat table + const attendeeUniqueId = uuid(); + await prisma.bookingSeat.create({ + data: { + data: { + description: additionalNotes, + }, + booking: { + connect: { + id: booking.id, + }, + }, + referenceUid: attendeeUniqueId, + attendee: { + connect: { + id: bookingUpdated.attendees[bookingUpdated.attendees.length - 1].id, + }, + }, + }, + }); + 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 + */ + const copyEvent = cloneDeep(evt); + await sendScheduledSeatsEmails(copyEvent, invitee[0], newSeat, !!eventType.seatsShowAttendees); + + const credentials = await refreshCredentials(organizerUser.credentials); + const eventManager = new EventManager({ ...organizerUser, credentials }); + await eventManager.updateCalendarAttendees(evt, booking); + + const resultBooking = await resultBookingQuery(booking.id); + + if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) { + const credentialPaymentAppCategories = await prisma.credential.findMany({ + where: { + 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 + ); + + return { + ...resultBooking, + message: "Payment required", + paymentUid: payment?.uid, + seatReferenceUid: bookingSeat?.referenceUid, + }; + } + + return { ...resultBooking, seatReferenceUid: evt.attendeeSeatId }; + } + }; + // 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(); + if (newBooking) { + req.statusCode = 201; + return newBooking; + } + } if (isTeamEventType) { evt.team = { members: teamMembers, @@ -899,45 +1397,6 @@ async function handler( evt.recurringEvent = eventType.recurringEvent; } - // Initialize EventManager with credentials - const rescheduleUid = reqBody.rescheduleUid; - async function getOriginalRescheduledBooking(uid: string) { - return prisma.booking.findFirst({ - where: { - uid, - status: { - in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING], - }, - }, - include: { - attendees: { - select: { - name: true, - email: true, - locale: true, - timeZone: true, - }, - }, - user: { - select: { - id: true, - name: true, - email: true, - locale: true, - timeZone: true, - }, - }, - payment: true, - workflowReminders: true, - }, - }); - } - - type BookingType = Prisma.PromiseReturnType; - let originalRescheduledBooking: BookingType = null; - if (rescheduleUid) { - originalRescheduledBooking = await getOriginalRescheduledBooking(rescheduleUid); - } // If the user is not the owner of the event, new booking should be always pending. // Otherwise, an owner rescheduling should be always accepted. // Before comparing make sure that userId is set, otherwise undefined === undefined @@ -948,7 +1407,7 @@ async function handler( if (originalRescheduledBooking) { evt.title = originalRescheduledBooking?.title || evt.title; evt.description = originalRescheduledBooking?.description || evt.additionalNotes; - evt.location = originalRescheduledBooking?.location; + evt.location = originalRescheduledBooking?.location || evt.location; } const eventTypeRel = !eventTypeId @@ -962,6 +1421,24 @@ async function handler( const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; + // If the user is not the owner of the event, new booking should be always pending. + // Otherwise, an owner rescheduling should be always accepted. + // Before comparing make sure that userId is set, otherwise undefined === undefined + const userReschedulingIsOwner = userId && originalRescheduledBooking?.user?.id === userId; + const isConfirmedByDefault = + (!eventType.requiresConfirmation && !paymentAppData.price) || userReschedulingIsOwner; + + const attendeesData = evt.attendees.map((attendee) => { + //if attendee is team member, it should fetch their locale not booker's locale + //perhaps make email fetch request to see if his locale is stored, else + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + locale: attendee.language.locale, + }; + }); + const newBookingData: Prisma.BookingCreateInput = { uid, responses: responses === null ? Prisma.JsonNull : responses, @@ -977,30 +1454,7 @@ async function handler( metadata: reqBody.metadata, attendees: { createMany: { - data: [ - ...evt.attendees.map((attendee) => { - //if attendee is team member, it should fetch their locale not booker's locale - //perhaps make email fetch request to see if his locale is stored, else - const retObj = { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - locale: attendee.language.locale, - }; - return retObj; - }), - // Have this for now until we change the relationship between bookings & team members - ...(evt.team?.members - ? evt.team.members.map((member) => { - return { - email: member.email, - name: member.name, - timeZone: member.timeZone, - locale: member.language.locale, - }; - }) - : []), - ], + data: attendeesData, }, }, dynamicEventSlugRef, @@ -1016,6 +1470,7 @@ async function handler( } : undefined, }; + if (reqBody.recurringEventId) { newBookingData.recurringEventId = reqBody.recurringEventId; } @@ -1023,7 +1478,14 @@ async function handler( newBookingData["paid"] = originalRescheduledBooking.paid; newBookingData["fromReschedule"] = originalRescheduledBooking.uid; if (newBookingData.attendees?.createMany?.data) { - newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees; + // Reschedule logic with booking with seats + if (eventType?.seatsPerTimeSlot && bookerEmail) { + newBookingData.attendees.createMany.data = attendeesData.filter( + (attendee) => attendee.email === bookerEmail + ); + } else { + newBookingData.attendees.createMany.data = originalRescheduledBooking.attendees; + } } if (originalRescheduledBooking.recurringEventId) { newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; @@ -1036,6 +1498,7 @@ async function handler( }, attendees: true, payment: true, + references: true, }, data: newBookingData, }; @@ -1074,6 +1537,8 @@ async function handler( let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null; try { booking = await createBooking(); + + // @NOTE: Add specific try catch for all subsequent async calls to avoid error // Sync Services await syncServicesUpdateWebUser( await prisma.user.findFirst({ @@ -1082,6 +1547,34 @@ async function handler( }) ); evt.uid = booking?.uid ?? null; + + if (booking && booking.id && eventType.seatsPerTimeSlot) { + const currentAttendee = booking.attendees.find( + (attendee) => attendee.email === req.body.responses.email + )!; + + // Save description to bookingSeat + const uniqueAttendeeId = uuid(); + await prisma.bookingSeat.create({ + data: { + referenceUid: uniqueAttendeeId, + data: { + description: evt.additionalNotes, + }, + booking: { + connect: { + id: booking.id, + }, + }, + attendee: { + connect: { + id: currentAttendee?.id, + }, + }, + }, + }); + evt.attendeeSeatId = uniqueAttendeeId; + } } catch (_err) { const err = getErrorFromUnknown(_err); log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message); @@ -1148,12 +1641,8 @@ async function handler( } // Use EventManager to conditionally use all needed integrations. - const updateManager = await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - booking?.id, - rescheduleReason - ); + + const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid, booking?.id); // 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; @@ -1184,8 +1673,9 @@ async function handler( } } if (noEmail !== true) { + const copyEvent = cloneDeep(evt); await sendRescheduledEmails({ - ...evt, + ...copyEvent, additionalInformation: metadata, additionalNotes, // Resets back to the additionalNote input and not the override value cancellationReason: "$RCH$" + rescheduleReason, // Removable code prefix to differentiate cancellation from rescheduling for email @@ -1461,7 +1951,10 @@ async function handler( // booking successful req.statusCode = 201; - return booking; + return { + ...booking, + seatReferenceUid: evt.attendeeSeatId, + }; } export default handler; @@ -1501,3 +1994,46 @@ function handleCustomInputs( } }); } + +const resultBookingQuery = async (bookingId: number) => { + const foundBooking = await prisma.booking.findUnique({ + where: { + id: bookingId, + }, + select: { + uid: true, + location: true, + startTime: true, + endTime: true, + title: true, + description: true, + status: true, + responses: true, + user: { + select: { + name: true, + email: true, + timeZone: true, + }, + }, + eventType: { + select: { + title: true, + description: true, + currency: true, + length: true, + requiresConfirmation: true, + price: true, + }, + }, + }, + }); + + // This should never happen but it's just typescript safe + if (!foundBooking) { + throw new Error("Internal Error."); + } + + // Don't leak any sensitive data + return foundBooking; +}; diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 2a8f95f710..227d2be471 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -30,10 +30,11 @@ ${calEvent.attendees[0].timeZone} }; export const getWho = (calEvent: CalendarEvent) => { + let attendeesFromCalEvent = [...calEvent.attendees]; if (calEvent.seatsPerTimeSlot && !calEvent.seatsShowAttendees) { - calEvent.attendees = []; + attendeesFromCalEvent = []; } - const attendees = calEvent.attendees + const attendees = attendeesFromCalEvent .map((attendee) => { return ` ${attendee?.name || calEvent.organizer.language.translate("guest")} @@ -144,7 +145,12 @@ export const getProviderName = (calEvent: CalendarEvent): string => { }; export const getUid = (calEvent: CalendarEvent): string => { - return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); + const uid = calEvent.uid; + return uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL)); +}; + +const getSeatReferenceId = (calEvent: CalendarEvent): string => { + return calEvent.attendeeSeatId ? `seatReferenceUid=${calEvent.attendeeSeatId}` : ""; }; export const getManageLink = (calEvent: CalendarEvent) => { @@ -156,12 +162,18 @@ ${WEBAPP_URL + "/booking/" + getUid(calEvent) + "?changes=true"} export const getCancelLink = (calEvent: CalendarEvent): string => { return ( - WEBAPP_URL + `/booking/${getUid(calEvent)}?cancel=true&allRemainingBookings=${!!calEvent.recurringEvent}` + WEBAPP_URL + + `/booking/${getUid( + calEvent + )}?cancel=true&allRemainingBookings=${!!calEvent.recurringEvent}&${getSeatReferenceId}` ); }; export const getRescheduleLink = (calEvent: CalendarEvent): string => { - return WEBAPP_URL + "/reschedule/" + getUid(calEvent); + const Uid = getUid(calEvent); + const seatUid = getSeatReferenceId(calEvent); + + return `${WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`; }; export const getRichDescription = (calEvent: CalendarEvent /*, attendee?: Person*/) => { @@ -179,7 +191,7 @@ ${getAppsStatus(calEvent)} ${ // TODO: Only the original attendee can make changes to the event // Guests cannot - getManageLink(calEvent) + !calEvent.seatsPerTimeSlot && getManageLink(calEvent) } ${ calEvent.paymentInfo diff --git a/packages/lib/server/maybeGetBookingUidFromSeat.ts b/packages/lib/server/maybeGetBookingUidFromSeat.ts new file mode 100644 index 0000000000..20d6e72a31 --- /dev/null +++ b/packages/lib/server/maybeGetBookingUidFromSeat.ts @@ -0,0 +1,20 @@ +import type { PrismaClient } from "@prisma/client"; + +export async function maybeGetBookingUidFromSeat(prisma: PrismaClient, uid: string) { + // Look bookingUid in bookingSeat + const bookingSeat = await prisma.bookingSeat.findUnique({ + where: { + referenceUid: uid, + }, + select: { + booking: { + select: { + id: true, + uid: true, + }, + }, + }, + }); + if (bookingSeat) return bookingSeat.booking.uid; + return uid; +} diff --git a/packages/prisma/migrations/20230303162003_add_booking_seat_reference/migration.sql b/packages/prisma/migrations/20230303162003_add_booking_seat_reference/migration.sql new file mode 100644 index 0000000000..3768f5d7e9 --- /dev/null +++ b/packages/prisma/migrations/20230303162003_add_booking_seat_reference/migration.sql @@ -0,0 +1,34 @@ +-- DropForeignKey +ALTER TABLE "Attendee" DROP CONSTRAINT IF EXISTS "Attendee_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_userId_fkey"; + +-- CreateTable +CREATE TABLE "BookingSeat" ( + "id" SERIAL NOT NULL, + "referenceUid" TEXT NOT NULL, + "bookingId" INTEGER NOT NULL, + "attendeeId" INTEGER NOT NULL, + "data" JSONB, + + CONSTRAINT "BookingSeat_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "BookingSeat_referenceUid_key" ON "BookingSeat"("referenceUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "BookingSeat_attendeeId_key" ON "BookingSeat"("attendeeId"); + +-- AddForeignKey +ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d3bdca4e86..4be22eff60 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -275,13 +275,14 @@ model BookingReference { } model Attendee { - id Int @id @default(autoincrement()) - email String - name String - timeZone String - locale String? @default("en") - booking Booking? @relation(fields: [bookingId], references: [id]) - bookingId Int? + id Int @id @default(autoincrement()) + email String + name String + timeZone String + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + bookingSeat BookingSeat? } enum BookingStatus { @@ -294,7 +295,7 @@ enum BookingStatus { model Booking { id Int @id @default(autoincrement()) uid String @unique - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? references BookingReference[] eventType EventType? @relation(fields: [eventTypeId], references: [id]) @@ -325,6 +326,7 @@ model Booking { smsReminderNumber String? workflowReminders WorkflowReminder[] scheduledJobs String[] + seatsReferences BookingSeat[] /// @zod.custom(imports.bookingMetadataSchema) metadata Json? } @@ -665,6 +667,16 @@ enum WorkflowMethods { SMS } +model BookingSeat { + id Int @id @default(autoincrement()) + referenceUid String @unique + bookingId Int + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + attendeeId Int @unique + attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade) + data Json? +} + model VerifiedNumber { id Int @id @default(autoincrement()) userId Int? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 9a9ab6e742..730e83817d 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -158,6 +158,7 @@ export const bookingCreateBodySchema = z.object({ hasHashedBookingLink: z.boolean().optional(), hashedLink: z.string().nullish(), ethSignature: z.string().optional(), + seatReferenceUid: z.string().optional(), }); export const requiredCustomInputSchema = z.union([ @@ -220,6 +221,7 @@ export const schemaBookingCancelParams = z.object({ uid: z.string().optional(), allRemainingBookings: z.boolean().optional(), cancellationReason: z.string().optional(), + seatReferenceUid: z.string().optional(), }); export const vitalSettingsUpdateSchema = z.object({ diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index 48387f7eb7..be7a92fd7f 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -234,6 +234,15 @@ export const bookingsRouter = router({ }, }, }, + { + seatsReferences: { + some: { + attendee: { + email: user.email, + }, + }, + }, + }, ], AND: [passedBookingsStatusFilter, ...(filtersCombined ?? [])], }, @@ -267,6 +276,21 @@ export const bookingsRouter = router({ }, rescheduled: true, references: true, + seatsReferences: { + where: { + attendee: { + email: user.email, + }, + }, + select: { + referenceUid: true, + attendee: { + select: { + email: true, + }, + }, + }, + }, }, orderBy, take: take + 1, @@ -956,4 +980,29 @@ export const bookingsRouter = router({ return { message, status }; }), + getBookingAttendees: authedProcedure + .input(z.object({ seatReferenceUid: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const bookingSeat = await ctx.prisma.bookingSeat.findUniqueOrThrow({ + where: { + referenceUid: input.seatReferenceUid, + }, + select: { + booking: { + select: { + _count: { + select: { + seatsReferences: true, + }, + }, + }, + }, + }, + }); + + if (!bookingSeat) { + throw new Error("Booking not found"); + } + return bookingSeat.booking._count.seatsReferences; + }), }); diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 5e5e231a13..51c9548b2e 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -1,4 +1,4 @@ -import type { Prisma, DestinationCalendar, SelectedCalendar } from "@prisma/client"; +import type { Prisma, DestinationCalendar, SelectedCalendar, BookingSeat } from "@prisma/client"; import type { Dayjs } from "dayjs"; import type { calendar_v3 } from "googleapis"; import type { Time } from "ical.js"; @@ -25,9 +25,10 @@ export type Person = { language: { translate: TFunction; locale: string }; username?: string; id?: number; - bookingId?: number; - locale?: string; + bookingId?: number | null; + locale?: string | null; timeFormat?: TimeFormat; + bookingSeat?: BookingSeat | null; }; export type TeamMember = { @@ -165,6 +166,7 @@ export interface CalendarEvent { eventTypeId?: number | null; appsStatus?: AppsStatus[]; seatsShowAttendees?: boolean | null; + attendeeSeatId?: string; seatsPerTimeSlot?: number | null; // It has responses to all the fields(system + user)