diff --git a/apps/web/components/booking/RemoveBookingSeatsDialog.tsx b/apps/web/components/booking/RemoveBookingSeatsDialog.tsx new file mode 100644 index 00000000000000..f146be7e9c47cf --- /dev/null +++ b/apps/web/components/booking/RemoveBookingSeatsDialog.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; +import { Form, TextAreaField, MultiSelectCheckbox } from "@calcom/ui/components/form"; +import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +import type { BookingItemProps } from "./types"; + +interface RemoveBookingSeatsDialogProps { + booking: BookingItemProps; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface FormValues { + cancellationReason: string; +} + +export function RemoveBookingSeatsDialog({ + booking, + isOpen, + onClose, + onSuccess, +}: RemoveBookingSeatsDialogProps) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + + const form = useForm({ + defaultValues: { + cancellationReason: "", + }, + }); + + const userEmail = booking.loggedInUser?.userEmail; + const isUserOrganizer = userEmail === booking.user?.email; + + const userSeat = userEmail + ? booking.seatsReferences?.find((seat) => seat.attendee?.email === userEmail) + : null; + const isJustAttendee = !!userSeat && !isUserOrganizer; + + const allSeatOptions = (booking.seatsReferences || []) + .map((seatRef) => { + if (!seatRef?.referenceUid) return null; + + const attendee = seatRef.attendee; + const attendeeName = attendee?.name; + const attendeeEmail = attendee?.email; + + if (isJustAttendee) { + if (seatRef.referenceUid !== userSeat?.referenceUid) { + return null; + } + if (attendeeName && attendeeEmail) { + return { + value: seatRef.referenceUid, + label: `${attendeeName} (${attendeeEmail})`, + }; + } else if (attendeeEmail) { + return { + value: seatRef.referenceUid, + label: attendeeEmail, + }; + } else if (attendeeName) { + return { + value: seatRef.referenceUid, + label: attendeeName, + }; + } + return null; + } + + let label: string; + if (attendeeName && attendeeEmail) { + label = `${attendeeName} (${attendeeEmail})`; + } else if (attendeeEmail) { + label = attendeeEmail; + } else if (attendeeName) { + label = attendeeName; + } else { + label = `Seat ${seatRef.referenceUid.slice(0, 8)}...`; + } + + return { + value: seatRef.referenceUid, + label: label, + }; + }) + .filter(Boolean) as Option[]; + + const seatOptions = allSeatOptions; + + const onSubmit = async (data: FormValues) => { + if (selected.length === 0) { + showToast(t("please_select_at_least_one_seat"), "error"); + return; + } + + setLoading(true); + + try { + const seatReferenceUids = selected.map((option) => option.value); + + const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" }); + const { csrfToken } = await response.json(); + + const res = await fetch("/api/cancel", { + body: JSON.stringify({ + uid: booking.uid, + seatReferenceUids: seatReferenceUids, + cancellationReason: data.cancellationReason, + csrfToken, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (res.status >= 200 && res.status < 300) { + showToast(t("seats_removed_successfully"), "success"); + await utils.viewer.bookings.invalidate(); + onSuccess(); + onClose(); + form.reset(); + setSelected([]); + } else { + let errorMessage = t("error_removing_seats"); + try { + const responseText = await res.text(); + try { + const error = JSON.parse(responseText); + errorMessage = error.message || errorMessage; + } catch { + console.error("Failed to parse error response as JSON. Raw response:", responseText); + errorMessage = responseText.trim() || errorMessage; + } + } catch { + console.error("Failed to read error response. Status:", res.status); + } + showToast(errorMessage, "error"); + } + } catch { + showToast(t("error_removing_seats"), "error"); + } finally { + setLoading(false); + } + }; + + return ( + + +
+
+
+ + {seatOptions.length === 0 ? ( +
{t("no_seats_available_to_remove")}
+ ) : ( + { + setSelected(options); + }} + countText="count_selected" + className="w-full text-sm" + /> + )} +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 13c04b89152fb0..ee030feb84d56b 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -31,6 +31,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; +import { RemoveBookingSeatsDialog } from "../RemoveBookingSeatsDialog"; import type { BookingItemProps } from "../types"; import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider"; import { @@ -38,6 +39,7 @@ import { getEditEventActions, getAfterEventActions, getReportAction, + getRemoveSeatsAction, shouldShowEditActions, type BookingActionContext, } from "./bookingActions"; @@ -85,6 +87,10 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog); const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen); const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen); + const isRemoveSeatsDialogOpen = useBookingActionsStoreContext((state) => state.isRemoveSeatsDialogOpen); + const setIsRemoveSeatsDialogOpen = useBookingActionsStoreContext( + (state) => state.setIsRemoveSeatsDialogOpen + ); const cardCharged = booking?.payment[0]?.success; @@ -237,6 +243,7 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book } as BookingActionContext; const cancelEventAction = getCancelEventAction(actionContext); + const removeSeatsAction = getRemoveSeatsAction(actionContext); const shouldShowEdit = shouldShowEditActions(actionContext); const baseEditEventActions = getEditEventActions(actionContext); @@ -409,6 +416,13 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book isRecurring={isRecurring} status={getBookingStatus()} /> + setIsRemoveSeatsDialogOpen(false)} + onSuccess={() => { + }} + /> {booking.paid && booking.payment[0] && ( + {removeSeatsAction && ( + + setIsRemoveSeatsDialogOpen(true)} + disabled={removeSeatsAction.disabled} + data-testid={removeSeatsAction.id} + className={removeSeatsAction.disabled ? "text-muted" : undefined}> + {removeSeatsAction.label} + + + )} >; @@ -29,6 +30,7 @@ export type BookingActionsStore = { setIsOpenAddGuestsDialog: React.Dispatch>; setIsOpenReportDialog: React.Dispatch>; setRerouteDialogIsOpen: React.Dispatch>; + setIsRemoveSeatsDialogOpen: React.Dispatch>; // Rejection reason state rejectionReason: string; @@ -49,6 +51,7 @@ export const createBookingActionsStore = () => { isOpenAddGuestsDialog: false, isOpenReportDialog: false, rerouteDialogIsOpen: false, + isRemoveSeatsDialogOpen: false, // Dialog setters setRejectionDialogIsOpen: (isOpen) => @@ -98,6 +101,11 @@ export const createBookingActionsStore = () => { set((state) => ({ rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen, })), + setIsRemoveSeatsDialogOpen: (isOpen) => + set((state) => ({ + isRemoveSeatsDialogOpen: + typeof isOpen === "function" ? isOpen(state.isRemoveSeatsDialogOpen) : isOpen, + })), // Rejection reason state rejectionReason: "", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 75493666bc0e23..9f996e2da8ea62 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -71,6 +71,7 @@ "require_email_for_guests_description": "When enabled, guests must provide their email address to join the call", "event_request_declined_recurring": "Your recurring event request has been declined", "event_request_cancelled": "Your scheduled event was canceled", + "event_request_cancelled_by_host": "An attendee was removed from your event", "event_request_reassigned": "Your scheduled event was reassigned", "event_reassigned_subtitle": "You will no longer have the event on your calendar and your round robin likelihood will not be negatively impacted", "organizer": "Organizer", @@ -774,6 +775,17 @@ "cancel_all_remaining": "Cancel all remaining", "apply": "Apply", "cancel_event": "Cancel event", + "remove_seats": "Remove seats", + "remove_seats_description": "Select the seats you want to remove from this booking. The selected attendees will be notified.", + "select_seats_to_remove": "Select seats to remove", + "removal_reason_optional": "Reason for removal (optional)", + "removal_reason_placeholder": "Why are you removing these seats?", + "remove_selected_seats": "Remove selected seats", + "seats_removed_successfully": "Seats removed successfully", + "error_removing_seats": "Error removing seats", + "please_select_at_least_one_seat": "Please select at least one seat to remove", + "no_seats_available_to_remove": "No seats available to remove", + "count_selected": "{{count}} selected", "report_booking": "Report booking", "report_booking_will_cancel_description": "Reporting this booking will automatically cancel it", "add_to_report": "Add to report", @@ -2236,7 +2248,20 @@ "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_was_removed": "An attendee was removed from your event", "attendee_no_longer_attending_subtitle": "{{name}} has canceled. This means a seat has opened up for this time slot", + "attendee_has_cancelled_subtitle": "{{attendees}} has canceled. This means a seat has opened up for this time slot", + "attendee_was_removed_subtitle": "{{attendees}} was removed. This means a seat has opened up for this time slot", + "some_attendees_no_longer_attending_subject": "Some attendees are no longer attending {{title}} at {{date}}", + "some_attendees_no_longer_attending": "Some attendees are no longer attending your event", + "some_attendees_were_removed": "Some attendees were removed from your event", + "were_removed": "were removed", + "was_removed": "was removed", + "have_cancelled": "have cancelled", + "has_cancelled": "has cancelled", + "a_seat": "a seat", + "this_means_seat_opened": "This means a seat has opened up for this time slot", + "this_means_seats_opened": "This means seats have opened up for this time slot", "create_event_on": "Create event on", "create_routing_form_on": "Create routing form on", "default_app_link_title": "Set a default app link", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 36b7086fb38e7d..754201fb1a8931 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -36,6 +36,7 @@ import OrganizerAddGuestsEmail from "./templates/organizer-add-guests-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 OrganizerMultipleAttendeesCancelledSeatEmail from "./templates/organizer-multiple-attendees-cancelled-seat-email"; import OrganizerReassignedEmail from "./templates/organizer-reassigned-email"; import OrganizerRequestEmail from "./templates/organizer-request-email"; import OrganizerRequestReminderEmail from "./templates/organizer-request-reminder-email"; @@ -56,11 +57,11 @@ const sendEmail = (prepare: () => BaseEmail) => { }); }; -const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => { +export const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => { return !!metadata?.disableStandardEmails?.all?.attendee; }; -const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => { +export const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => { return !!metadata?.disableStandardEmails?.all?.host; }; @@ -350,21 +351,29 @@ export const sendScheduledSeatsEmailsAndSMS = async ( export const sendCancelledSeatEmailsAndSMS = async ( calEvent: CalendarEvent, cancelledAttendee: Person, - eventTypeMetadata?: EventTypeMetadata + eventTypeMetadata?: EventTypeMetadata, + options?: { isCancelledByHost?: boolean; sendToHost?: boolean } ) => { const formattedCalEvent = formatCalEvent(calEvent); const clonedCalEvent = cloneDeep(formattedCalEvent); const emailsToSend: Promise[] = []; if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) - emailsToSend.push(sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee))); - if (!eventTypeDisableHostEmail(eventTypeMetadata)) + emailsToSend.push( + sendEmail( + () => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee, options?.isCancelledByHost) + ) + ); + + const shouldSendToHost = options?.sendToHost !== false; + if (shouldSendToHost && !eventTypeDisableHostEmail(eventTypeMetadata)) emailsToSend.push( sendEmail( () => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent, attendee: cancelledAttendee, + isCancelledByHost: options?.isCancelledByHost, }) ) ); @@ -374,6 +383,26 @@ export const sendCancelledSeatEmailsAndSMS = async ( await cancelledSeatSMS.sendSMSToAttendee(cancelledAttendee); }; +export const sendCancelledSeatsEmailToHost = async ( + calEvent: CalendarEvent, + cancelledAttendees: Person[], + eventTypeMetadata?: EventTypeMetadata, + options?: { isCancelledByHost?: boolean } +) => { + const formattedCalEvent = formatCalEvent(calEvent); + + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; + + await sendEmail( + () => + new OrganizerMultipleAttendeesCancelledSeatEmail({ + calEvent: formattedCalEvent, + attendees: cancelledAttendees, + isCancelledByHost: options?.isCancelledByHost, + }) + ); +}; + const _sendOrganizerRequestEmail = async (calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata) => { if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); @@ -531,7 +560,6 @@ export const sendAwaitingPaymentEmailAndSMS = async ( await awaitingPaymentSMS.sendSMSToAttendees(); }; - export const sendRequestRescheduleEmailAndSMS = async ( calEvent: CalendarEvent, metadata: { rescheduleLink: string }, diff --git a/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx b/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx index fefcdfa2e0f0af..33d830a45ae6bf 100644 --- a/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx +++ b/packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx @@ -1,12 +1,24 @@ import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail"; -export const AttendeeCancelledSeatEmail = (props: React.ComponentProps) => ( - -); +interface AttendeeCancelledSeatEmailProps extends React.ComponentProps { + isCancelledByHost?: boolean; +} + +export const AttendeeCancelledSeatEmail = ({ + isCancelledByHost, + ...props +}: AttendeeCancelledSeatEmailProps) => { + const title = isCancelledByHost ? "event_cancelled" : "no_longer_attending"; + const subject = isCancelledByHost ? "event_cancelled_subject" : "event_no_longer_attending_subject"; + + return ( + + ); +}; diff --git a/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx b/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx index 873230671d232f..5aba7ee844acda 100644 --- a/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx +++ b/packages/emails/src/templates/OrganizerAttendeeCancelledSeatEmail.tsx @@ -1,14 +1,30 @@ import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; -export const OrganizerAttendeeCancelledSeatEmail = ( - props: React.ComponentProps -) => ( - -); +interface OrganizerAttendeeCancelledSeatEmailProps + extends React.ComponentProps { + isCancelledByHost?: boolean; +} + +export const OrganizerAttendeeCancelledSeatEmail = ({ + isCancelledByHost, + ...props +}: OrganizerAttendeeCancelledSeatEmailProps) => { + const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate; + + const title = isCancelledByHost ? "attendee_was_removed" : "attendee_no_longer_attending"; + const subtitleKey = isCancelledByHost ? "attendee_was_removed_subtitle" : "attendee_has_cancelled_subtitle"; + + const attendeeName = props.attendee?.name || t("guest"); + + return ( + {t(subtitleKey, { attendees: attendeeName })}} + callToAction={null} + attendeeCancelled + /> + ); +}; diff --git a/packages/emails/src/templates/OrganizerMultipleAttendeesCancelledSeatEmail.tsx b/packages/emails/src/templates/OrganizerMultipleAttendeesCancelledSeatEmail.tsx new file mode 100644 index 00000000000000..9ed34b64e9a11b --- /dev/null +++ b/packages/emails/src/templates/OrganizerMultipleAttendeesCancelledSeatEmail.tsx @@ -0,0 +1,61 @@ +import type { TFunction } from "next-i18next"; + +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail"; + +interface OrganizerMultipleAttendeesCancelledSeatEmailProps { + calEvent: CalendarEvent; + attendees: [Person, ...Person[]]; + attendeeCount: number; + attendeeNames: string; + isCancelledByHost?: boolean; + t?: TFunction; +} + +export const OrganizerMultipleAttendeesCancelledSeatEmail = ({ + attendeeCount, + attendeeNames, + isCancelledByHost, + ...props +}: OrganizerMultipleAttendeesCancelledSeatEmailProps) => { + const t = props.t || props.calEvent.organizer.language.translate; + + if (!props.attendees || props.attendees.length === 0) { + throw new Error( + "OrganizerMultipleAttendeesCancelledSeatEmail requires at least one attendee. Cannot render email without attendees." + ); + } + + let titleKey = ""; + if (attendeeCount === 1) { + titleKey = isCancelledByHost ? "attendee_was_removed" : "attendee_no_longer_attending"; + } else { + titleKey = isCancelledByHost ? "some_attendees_were_removed" : "some_attendees_no_longer_attending"; + } + + const titleText = attendeeCount === 1 ? t(titleKey) : t(titleKey); + const action = isCancelledByHost + ? attendeeCount === 1 + ? t("was_removed") + : t("were_removed") + : attendeeCount === 1 + ? t("has_cancelled") + : t("have_cancelled"); + + const seatsMessage = attendeeCount === 1 ? t("this_means_seat_opened") : t("this_means_seats_opened"); + const subtitle = `${attendeeNames} ${action}. ${seatsMessage}`; + + return ( + {subtitle}} + callToAction={null} + attendeeCancelled + attendee={props.attendees[0]} + {...props} + /> + ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 3fb98750ebfe56..bc2caae7a78a65 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -27,6 +27,7 @@ export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail"; export { CreditBalanceLowWarningEmail } from "./CreditBalanceLowWarningEmail"; export { CreditBalanceLimitReachedEmail } from "./CreditBalanceLimitReachedEmail"; export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail"; +export { OrganizerMultipleAttendeesCancelledSeatEmail } from "./OrganizerMultipleAttendeesCancelledSeatEmail"; export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail"; export { VerifyAccountEmail } from "./VerifyAccountEmail"; export { VerifyEmailByCode } from "./VerifyEmailByCode"; diff --git a/packages/emails/templates/attendee-cancelled-seat-email.ts b/packages/emails/templates/attendee-cancelled-seat-email.ts index c316c8ff2c2f17..d187b23313d3f9 100644 --- a/packages/emails/templates/attendee-cancelled-seat-email.ts +++ b/packages/emails/templates/attendee-cancelled-seat-email.ts @@ -1,21 +1,34 @@ import { getReplyToHeader } from "@calcom/lib/getReplyToHeader"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import renderEmail from "../src/renderEmail"; import AttendeeScheduledEmail from "./attendee-scheduled-email"; export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail { + private isCancelledByHost?: boolean; + + constructor(calEvent: CalendarEvent, attendee: Person, isCancelledByHost?: boolean) { + super(calEvent, attendee); + this.isCancelledByHost = isCancelledByHost; + } + protected async getNodeMailerPayload(): Promise> { + const subjectKey = this.isCancelledByHost + ? "event_cancelled_subject" + : "event_no_longer_attending_subject"; + return { to: `${this.attendee.name} <${this.attendee.email}>`, from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, ...getReplyToHeader(this.calEvent), - subject: `${this.t("event_no_longer_attending_subject", { + subject: `${this.t(subjectKey, { title: this.calEvent.title, date: this.getFormattedDate(), })}`, html: await renderEmail("AttendeeCancelledSeatEmail", { calEvent: this.calEvent, attendee: this.attendee, + isCancelledByHost: this.isCancelledByHost, }), text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"), }; diff --git a/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts b/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts index e1292eab7622a0..e5a107cfae220b 100644 --- a/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts +++ b/packages/emails/templates/organizer-attendee-cancelled-seat-email.ts @@ -1,9 +1,17 @@ import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import renderEmail from "../src/renderEmail"; import OrganizerScheduledEmail from "./organizer-scheduled-email"; export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { + private isCancelledByHost?: boolean; + + constructor(input: { calEvent: CalendarEvent; attendee?: Person; isCancelledByHost?: boolean }) { + super(input); + this.isCancelledByHost = input.isCancelledByHost; + } + protected async getNodeMailerPayload(): Promise> { const toAddresses = [this.calEvent.organizer.email]; if (this.calEvent.team) { @@ -15,6 +23,10 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { }); } + const textBodyKey = this.isCancelledByHost + ? "event_request_cancelled_by_host" + : "event_request_cancelled"; + return { from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, to: toAddresses.join(","), @@ -25,8 +37,9 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail { html: await renderEmail("OrganizerAttendeeCancelledSeatEmail", { attendee: this.attendee || this.calEvent.organizer, calEvent: this.calEvent, + isCancelledByHost: this.isCancelledByHost, }), - text: this.getTextBody("event_request_cancelled"), + text: this.getTextBody(textBodyKey), }; } } diff --git a/packages/emails/templates/organizer-multiple-attendees-cancelled-seat-email.ts b/packages/emails/templates/organizer-multiple-attendees-cancelled-seat-email.ts new file mode 100644 index 00000000000000..9b038e8c1fd805 --- /dev/null +++ b/packages/emails/templates/organizer-multiple-attendees-cancelled-seat-email.ts @@ -0,0 +1,73 @@ +import { getReplyToHeader } from "@calcom/lib/getReplyToHeader"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import renderEmail from "../src/renderEmail"; +import OrganizerScheduledEmail from "./organizer-scheduled-email"; + +export default class OrganizerMultipleAttendeesCancelledSeatEmail extends OrganizerScheduledEmail { + private attendees: [Person, ...Person[]]; + private isCancelledByHost?: boolean; + + constructor(input: { calEvent: CalendarEvent; attendees: Person[]; isCancelledByHost?: boolean }) { + super(input); + + if (!input.attendees || input.attendees.length === 0) { + throw new Error( + "OrganizerMultipleAttendeesCancelledSeatEmail requires at least one attendee. Cannot create email without attendees." + ); + } + + this.attendees = input.attendees as [Person, ...Person[]]; + this.isCancelledByHost = input.isCancelledByHost; + } + + protected async getNodeMailerPayload(): Promise> { + const attendeeCount = this.attendees.length; + const attendeeNames = this.getFormattedAttendeeName(); + + const subjectKey = + attendeeCount === 1 + ? "attendee_no_longer_attending_subject" + : "some_attendees_no_longer_attending_subject"; + + return { + to: `${this.calEvent.organizer.name} <${this.calEvent.organizer.email}>`, + from: `Cal.com <${this.getMailerOptions().from}>`, + ...getReplyToHeader(this.calEvent), + subject: `${this.t(subjectKey, { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: await renderEmail("OrganizerMultipleAttendeesCancelledSeatEmail", { + calEvent: this.calEvent, + attendees: this.attendees, + attendeeCount, + attendeeNames, + isCancelledByHost: this.isCancelledByHost, + }), + text: this.getTextBody(), + }; + } + + private getFormattedAttendeeName(): string { + const count = this.attendees.length; + if (count === 1) { + return this.attendees[0].name; + } else if (count === 2) { + return `${this.attendees[0].name} ${this.t("and")} ${this.attendees[1].name}`; + } else { + const names = this.attendees.map((a) => a.name); + const lastTwo = names.slice(-2).join(` ${this.t("and")} `); + const rest = names.slice(0, -2); + return rest.length > 0 ? `${rest.join(", ")}, ${lastTwo}` : lastTwo; + } + } + + protected getTextBody(): string { + const textBodyKey = this.isCancelledByHost + ? "event_request_cancelled_by_host" + : "event_request_cancelled"; + + return super.getTextBody(textBodyKey); + } +} diff --git a/packages/features/bookings/lib/dto/SeatCancellation.ts b/packages/features/bookings/lib/dto/SeatCancellation.ts new file mode 100644 index 00000000000000..6ce9bdbc0cf17c --- /dev/null +++ b/packages/features/bookings/lib/dto/SeatCancellation.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** + * DTO for seat cancellation input + * Validates and transforms seat cancellation requests + */ +export const SeatCancellationInputSchema = z.object({ + seatReferenceUids: z.array(z.string().min(1)).min(1, "At least one seat must be selected"), + userId: z.number().optional(), + bookingUid: z.string().min(1), +}); + +export type SeatCancellationInput = z.infer; + +/** + * DTO for seat cancellation options + */ +export const SeatCancellationOptionsSchema = z.object({ + isCancelledByHost: z.boolean().optional().default(false), +}); + +export type SeatCancellationOptions = z.infer; + +/** + * DTO for seat reference data + */ +export const SeatReferenceSchema = z.object({ + referenceUid: z.string(), + attendeeId: z.number(), +}); + +export type SeatReference = z.infer; + +/** + * DTO for seat cancellation result + */ +export const SeatCancellationResultSchema = z.object({ + success: z.boolean(), + removedSeatCount: z.number(), + removedAttendeeCount: z.number(), +}); + +export type SeatCancellationResult = z.infer; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index e3944ee8516f7b..cb54cdf30aefab 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -9,6 +9,7 @@ import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation"; import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund"; +import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; @@ -28,7 +29,6 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { PrismaOrgMembershipRepository } from "@calcom/lib/server/repository/PrismaOrgMembershipRepository"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; // TODO: Prisma import would be used from DI in a followup PR when we remove `handler` export import prisma from "@calcom/prisma"; @@ -76,11 +76,15 @@ async function handler(input: CancelBookingInput) { allRemainingBookings, cancellationReason, seatReferenceUid, + seatReferenceUids: rawSeatReferenceUids, cancelledBy, cancelSubsequentBookings, internalNote, skipCancellationReasonValidation = false, } = bookingCancelInput.parse(body); + + const seatReferenceUids = seatReferenceUid ? [seatReferenceUid] : rawSeatReferenceUids || []; + const bookingToDelete = await getBookingToDelete(id, uid); const { userId, @@ -118,15 +122,25 @@ async function handler(input: CancelBookingInput) { const isCancellationUserHost = bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy; + let isUserAdminOrOwner = false; + if (userId && !isCancellationUserHost) { + const bookingAccessService = new BookingAccessService(prisma); + isUserAdminOrOwner = await bookingAccessService.doesUserIdHaveAccessToBooking({ + userId, + bookingUid: bookingToDelete.uid, + }); + } + + const requiresCancellationReason = isCancellationUserHost || isUserAdminOrOwner; if ( !platformClientId && !cancellationReason?.trim() && - isCancellationUserHost && + requiresCancellationReason && !skipCancellationReasonValidation ) { throw new HttpError({ statusCode: 400, - message: "Cancellation reason is required when you are the host", + message: "Cancellation reason is required when you are the host, admin, or owner", }); } @@ -137,29 +151,6 @@ async function handler(input: CancelBookingInput) { }); } - // If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host - if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) { - const userIsHost = bookingToDelete.eventType.hosts.find((host) => { - if (host.user.id === userId) return true; - }); - - const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId; - - const userIsOrgAdminOfBookingUser = - userId && - (await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost( - userId, - bookingToDelete.userId - )); - - if (!userIsHost && !userIsOwnerOfEventType && !userIsOrgAdminOfBookingUser) { - throw new HttpError({ - statusCode: 401, - message: "User not a host of this event or an admin of the booking user", - }); - } - } - // get webhooks const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; @@ -316,14 +307,18 @@ async function handler(input: CancelBookingInput) { const dataForWebhooks = { evt, webhooks, eventTypeInfo }; + const isCancelledByHostOrAdmin = isCancellationUserHost || isUserAdminOrOwner; + // If it's just an attendee of a booking then just remove them from that booking const result = await cancelAttendeeSeat( { - seatReferenceUid: seatReferenceUid, + seatReferenceUids: seatReferenceUids, bookingToDelete, + userId, }, dataForWebhooks, - bookingToDelete?.eventType?.metadata as EventTypeMetadata + bookingToDelete?.eventType?.metadata as EventTypeMetadata, + { isCancelledByHost: isCancelledByHostOrAdmin } ); if (result) return { @@ -334,6 +329,13 @@ async function handler(input: CancelBookingInput) { message: "Attendee successfully removed.", } satisfies HandleCancelBookingResponse; + if (!isCancelledByHostOrAdmin && seatReferenceUids.length === 0) { + throw new HttpError({ + statusCode: 403, + message: "You are not authorized to cancel this booking", + }); + } + const promises = webhooks.map((webhook) => sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { ...evt, diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index d5c4b9a54df851..1032ce3dab9f12 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -1,7 +1,20 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-store/delegationCredential"; -import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails/email-manager"; +import { + sendCancelledSeatEmailsAndSMS, + sendCancelledSeatsEmailToHost, + eventTypeDisableAttendeeEmail, + eventTypeDisableHostEmail, +} from "@calcom/emails/email-manager"; +import { + SeatCancellationInputSchema, + SeatCancellationOptionsSchema, + type SeatCancellationInput, + type SeatCancellationOptions, +} from "@calcom/features/bookings/lib/dto/SeatCancellation"; +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import { updateMeeting } from "@calcom/features/conferencing/lib/videoClient"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; @@ -13,7 +26,6 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { bookingCancelAttendeeSeatSchema } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -21,8 +33,9 @@ import type { BookingToDelete } from "../../handleCancelBooking"; async function cancelAttendeeSeat( data: { - seatReferenceUid?: string; + seatReferenceUids: string[]; bookingToDelete: BookingToDelete; + userId?: number; }, dataForWebhooks: { webhooks: { @@ -35,56 +48,122 @@ async function cancelAttendeeSeat( evt: CalendarEvent; eventTypeInfo: EventTypeInfo; }, - eventTypeMetadata: EventTypeMetadata + eventTypeMetadata: EventTypeMetadata, + options?: { isCancelledByHost?: boolean } ) { - const input = bookingCancelAttendeeSeatSchema.safeParse({ - seatReferenceUid: data.seatReferenceUid, - }); const { webhooks, evt, eventTypeInfo } = dataForWebhooks; - if (!input.success) return; - const { seatReferenceUid } = input.data; const bookingToDelete = data.bookingToDelete; - if (!bookingToDelete?.attendees.length || bookingToDelete.attendees.length < 2) return; - if (!bookingToDelete.userId) { + const INVALID_SEAT_REFERENCE_ALL = "all"; + const DEFAULT_LOCALE = "en"; + + const filteredSeatReferenceUids = data.seatReferenceUids.filter( + (uid) => uid && uid !== INVALID_SEAT_REFERENCE_ALL && typeof uid === "string" + ); + + if (filteredSeatReferenceUids.length === 0) { + if (!bookingToDelete?.attendees.length || bookingToDelete.attendees.length < 2) { + return; + } + return; + } + + const inputDto: SeatCancellationInput = { + seatReferenceUids: filteredSeatReferenceUids, + userId: data.userId, + bookingUid: bookingToDelete.uid, + }; + + const validatedInput = SeatCancellationInputSchema.safeParse(inputDto); + if (!validatedInput.success) { + throw new HttpError({ + statusCode: 400, + message: validatedInput.error.errors[0]?.message || "Invalid seat cancellation input", + }); + } + + const { seatReferenceUids } = validatedInput.data; + const { userId } = validatedInput.data; + + const validatedOptions: SeatCancellationOptions = SeatCancellationOptionsSchema.parse( + options || { isCancelledByHost: false } + ); + + const hasNoAttendees = !bookingToDelete?.attendees.length; + if (hasNoAttendees) { + throw new HttpError({ statusCode: 400, message: "No attendees found in this booking" }); + } + + const isBookingUserMissing = !bookingToDelete.userId; + if (isBookingUserMissing) { throw new HttpError({ statusCode: 400, message: "User not found" }); } - const seatReference = bookingToDelete.seatsReferences.find( - (reference) => reference.referenceUid === seatReferenceUid + const seatReferences = bookingToDelete.seatsReferences.filter((reference) => + seatReferenceUids.includes(reference.referenceUid) ); - if (!seatReference) throw new HttpError({ statusCode: 400, message: "User not a part of this booking" }); + const areSomeSeatsNotFound = seatReferences.length !== seatReferenceUids.length; + if (areSomeSeatsNotFound) { + throw new HttpError({ statusCode: 400, message: "One or more seats not found in this booking" }); + } + + const bookingRepository = new BookingRepository(prisma); + + if (userId) { + const bookingAccessService = new BookingAccessService(prisma); + const hasFullAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({ + userId, + bookingUid: bookingToDelete.uid, + }); + if (!hasFullAccess) { + const userEmail = await bookingRepository.getUserEmailById(userId); + + const userSeats = seatReferences.filter((ref) => { + const attendee = bookingToDelete.attendees.find((a) => a.id === ref.attendeeId); + return attendee?.email === userEmail; + }); + + const areNotAllUserSeats = userSeats.length !== seatReferences.length; + if (areNotAllUserSeats) { + throw new HttpError({ + statusCode: 403, + message: "You can only cancel your own seats", + }); + } + } + } + + const attendeeIds = seatReferences.map((ref) => ref.attendeeId); await Promise.all([ - prisma.bookingSeat.delete({ - where: { - referenceUid: seatReferenceUid, - }, - }), - prisma.attendee.delete({ - where: { - id: seatReference.attendeeId, - }, - }), + bookingRepository.deleteBookingSeatsByReferenceUids(seatReferenceUids), + bookingRepository.deleteAttendeesByIds(attendeeIds), ]); - const attendee = bookingToDelete?.attendees.find((attendee) => attendee.id === seatReference.attendeeId); + const attendees = bookingToDelete.attendees.filter((attendee) => + seatReferences.some((ref) => ref.attendeeId === attendee.id) + ); const bookingToDeleteUser = bookingToDelete.user ?? null; const delegationCredentials = bookingToDeleteUser - ? // We fetch delegation credentials with ServiceAccount key as CalendarService instance created later in the flow needs it - await getAllDelegationCredentialsForUserIncludeServiceAccountKey({ + ? await getAllDelegationCredentialsForUserIncludeServiceAccountKey({ user: { email: bookingToDeleteUser.email, id: bookingToDeleteUser.id }, }) : []; - if (attendee) { - /* If there are references then we should update them as well */ - + const hasAttendeesToNotify = attendees.length > 0; + if (hasAttendeesToNotify) { const integrationsToUpdate = []; + const updatedEvt = { + ...evt, + attendees: evt.attendees.filter((evtAttendee) => !attendees.some((a) => a.email === evtAttendee.email)), + calendarDescription: getRichDescription(evt), + }; + for (const reference of bookingToDelete.references) { - if (reference.credentialId || reference.delegationCredentialId) { + const hasCredential = reference.credentialId || reference.delegationCredentialId; + if (hasCredential) { const credential = await getDelegationCredentialOrFindRegularCredential({ id: { credentialId: reference.credentialId, @@ -94,19 +173,16 @@ async function cancelAttendeeSeat( }); if (credential) { - const updatedEvt = { - ...evt, - attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email), - calendarDescription: getRichDescription(evt), - }; - if (reference.type.includes("_video")) { + const isVideoReference = reference.type.includes("_video"); + if (isVideoReference) { integrationsToUpdate.push(updateMeeting(credential, updatedEvt, reference)); } - if (reference.type.includes("_calendar")) { + const isCalendarReference = reference.type.includes("_calendar"); + if (isCalendarReference) { const calendar = await getCalendar(credential); if (calendar) { integrationsToUpdate.push( - calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId) + calendar.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId) ); } } @@ -116,34 +192,62 @@ async function cancelAttendeeSeat( try { await Promise.all(integrationsToUpdate); - } catch { - // Shouldn't stop code execution if integrations fail - // as integrations was already updated + } catch (error) { + logger.error("Failed to update some calendar integrations", error); } - const tAttendees = await getTranslation(attendee.locale ?? "en", "common"); + // Send emails to each canceled attendee with their own locale + const shouldSendAttendeeEmails = !eventTypeDisableAttendeeEmail(eventTypeMetadata); + if (shouldSendAttendeeEmails) { + for (const attendee of attendees) { + const attendeeLocale = attendee.locale ?? DEFAULT_LOCALE; + const tAttendee = await getTranslation(attendeeLocale, "common"); + await sendCancelledSeatEmailsAndSMS( + evt, + { + ...attendee, + language: { translate: tAttendee, locale: attendeeLocale }, + }, + eventTypeMetadata, + { isCancelledByHost: validatedOptions.isCancelledByHost, sendToHost: false } + ); + } + } - await sendCancelledSeatEmailsAndSMS( - evt, - { - ...attendee, - language: { translate: tAttendees, locale: attendee.locale ?? "en" }, - }, - eventTypeMetadata - ); - } + // Send ONE email to host with info about ALL cancelled/removed attendees + const shouldSendHostEmail = !eventTypeDisableHostEmail(eventTypeMetadata); + if (shouldSendHostEmail) { + const hostLocale = evt.organizer.language.locale ?? DEFAULT_LOCALE; + const tHost = await getTranslation(hostLocale, "common"); - evt.attendees = attendee - ? [ - { + const attendeesWithLanguage = await Promise.all( + attendees.map(async (attendee) => ({ ...attendee, - language: { - translate: await getTranslation(attendee.locale ?? "en", "common"), - locale: attendee.locale ?? "en", - }, + language: { translate: tHost, locale: hostLocale }, + })) + ); + + await sendCancelledSeatsEmailToHost( + { ...evt, attendees: attendeesWithLanguage }, + attendeesWithLanguage, + eventTypeMetadata, + { isCancelledByHost: validatedOptions.isCancelledByHost } + ); + } + } + + evt.attendees = await Promise.all( + attendees.map(async (attendee) => { + const attendeeLocale = attendee.locale ?? DEFAULT_LOCALE; + return { + ...attendee, + language: { + translate: await getTranslation(attendeeLocale, "common"), + locale: attendeeLocale, }, - ] - : []; + }; + }) + ); const payload: EventPayloadType = { ...evt, @@ -168,11 +272,16 @@ async function cancelAttendeeSeat( ); await Promise.all(promises); - const workflowRemindersForAttendee = - bookingToDelete?.workflowReminders.filter((reminder) => reminder.seatReferenceId === seatReferenceUid) ?? - null; + const workflowRemindersToDelete = bookingToDelete.workflowReminders.filter((reminder) => + seatReferenceUids.includes(reminder.seatReferenceId || "") + ); - await WorkflowRepository.deleteAllWorkflowReminders(workflowRemindersForAttendee); + await WorkflowRepository.deleteAllWorkflowReminders(workflowRemindersToDelete); + + const allAttendeesRemoved = attendees.length === bookingToDelete.attendees.length; + if (allAttendeesRemoved) { + return; + } return { success: true }; } diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index b78d568cd95e71..146a83ced0785d 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -131,6 +131,47 @@ export class BookingRepository { }); } + /** + * Delete booking seats by reference UIDs + * @param seatReferenceUids Array of seat reference UIDs to delete + * @returns Number of deleted seats + */ + async deleteBookingSeatsByReferenceUids(seatReferenceUids: string[]): Promise { + const result = await this.prismaClient.bookingSeat.deleteMany({ + where: { + referenceUid: { in: seatReferenceUids }, + }, + }); + return result.count; + } + + /** + * Delete attendees by their IDs + * @param attendeeIds Array of attendee IDs to delete + * @returns Number of deleted attendees + */ + async deleteAttendeesByIds(attendeeIds: number[]): Promise { + const result = await this.prismaClient.attendee.deleteMany({ + where: { + id: { in: attendeeIds }, + }, + }); + return result.count; + } + + /** + * Get user by ID (for permission checks) + * @param userId User ID + * @returns User email or null + */ + async getUserEmailById(userId: number): Promise { + const user = await this.prismaClient.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + return user?.email ?? null; + } + async getBookingWithEventTypeTeamId({ bookingId }: { bookingId: number }) { return await this.prismaClient.booking.findUnique({ where: { diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 87224533f31d41..525f815f827a97 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -15,7 +15,6 @@ import { EventTypeCustomInputType } from "@calcom/prisma/enums"; import type { Prisma } from "./client"; - /** @see https://github.com/colinhacks/zod/issues/3155#issuecomment-2060045794 */ export const emailRegex = /* eslint-disable-next-line no-useless-escape */ @@ -123,46 +122,44 @@ const raqbChildSchema = z.object({ .optional(), }); -const raqbChildren1Schema = z - .record(raqbChildSchema) - .superRefine((children1, ctx) => { - if (!children1) return; - const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - Object.entries(children1).forEach(([, _rule]) => { - const rule = _rule as unknown; - if (!isObject(rule) || rule.type !== "rule") return; - if (!isObject(rule.properties)) return; - - const value = rule.properties.value || []; - const valueSrc = rule.properties.valueSrc; - if (!(value instanceof Array) || !(valueSrc instanceof Array)) { - return; - } - - if (!valueSrc.length) { - // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty - return; - } +const raqbChildren1Schema = z.record(raqbChildSchema).superRefine((children1, ctx) => { + if (!children1) return; + const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + Object.entries(children1).forEach(([, _rule]) => { + const rule = _rule as unknown; + if (!isObject(rule) || rule.type !== "rule") return; + if (!isObject(rule.properties)) return; + + const value = rule.properties.value || []; + const valueSrc = rule.properties.valueSrc; + if (!(value instanceof Array) || !(valueSrc instanceof Array)) { + return; + } - // MultiSelect array can be 2D array - const flattenedValues = value.flat(); + if (!valueSrc.length) { + // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty + return; + } - const validValues = flattenedValues.filter((value: unknown) => { - // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field - // Also, it is possible that RAQB has some requirements to support null or empty string values. - if (value === undefined) return false; - return true; - }); + // MultiSelect array can be 2D array + const flattenedValues = value.flat(); - if (!validValues.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Looks like you are trying to create a rule with no value", - }); - } + const validValues = flattenedValues.filter((value: unknown) => { + // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field + // Also, it is possible that RAQB has some requirements to support null or empty string values. + if (value === undefined) return false; + return true; }); + + if (!validValues.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Looks like you are trying to create a rule with no value", + }); + } }); +}); const raqbQueryValueSchema = z.union([ z.object({ @@ -181,7 +178,6 @@ const raqbQueryValueSchema = z.union([ const zodAttributesQueryValue = raqbQueryValueSchema; - // Let's not import 118kb just to get an enum export enum Frequency { YEARLY = 0, @@ -442,6 +438,7 @@ export const bookingCancelSchema = z.object({ cancellationReason: z.string().optional(), skipCancellationReasonValidation: z.boolean().optional(), seatReferenceUid: z.string().optional(), + seatReferenceUids: z.array(z.string()).optional(), cancelledBy: z.string().email({ message: "Invalid email" }).optional(), internalNote: z .object({ diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 5a81a91a0f675a..8b4371a3a87db8 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -582,7 +582,7 @@ export async function getBookings({ jsonObjectFrom( eb .selectFrom("Attendee") - .select(["Attendee.email"]) + .select(["Attendee.email", "Attendee.name"]) .whereRef("BookingSeat.attendeeId", "=", "Attendee.id") ).as("attendee"), ])