diff --git a/apps/web/components/booking/actions/bookingActions.ts b/apps/web/components/booking/actions/bookingActions.ts index 5cc5794ba77e9d..c07a8ef659b31c 100644 --- a/apps/web/components/booking/actions/bookingActions.ts +++ b/apps/web/components/booking/actions/bookingActions.ts @@ -101,6 +101,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] isDisabledRescheduling, getSeatReferenceUid, isAttendee, + isPending, t, } = context; const seatReferenceUid = getSeatReferenceUid(); @@ -211,8 +212,8 @@ export function shouldShowPendingActions(context: BookingActionContext): boolean } export function shouldShowEditActions(context: BookingActionContext): boolean { - const { isPending, isTabRecurring, isRecurring, isCancelled } = context; - return !isPending && !(isTabRecurring && isRecurring) && !isCancelled; + const { isTabRecurring, isRecurring, isCancelled } = context; + return !(isTabRecurring && isRecurring) && !isCancelled; } export function shouldShowRecurringCancelAction(context: BookingActionContext): boolean { @@ -235,31 +236,43 @@ export function isActionDisabled(actionId: string, context: BookingActionContext isAttendee, isCancelled, isRejected, + isPending, } = context; switch (actionId) { case "reschedule": - case "reschedule_request": - // Only apply minimum reschedule notice restriction if user is NOT the organizer - // If user is an attendee (or not authenticated), apply the restriction const isUserOrganizer = - !isAttendee && - booking.loggedInUser?.userId && - booking.user?.id && - booking.loggedInUser.userId === booking.user.id; - const isWithinMinimumNotice = - !isUserOrganizer && - isWithinMinimumRescheduleNotice( - new Date(booking.startTime), + !isAttendee && + booking.loggedInUser?.userId && + booking.user?.id && + booking.loggedInUser.userId === booking.user.id; + const isWithinMinimumNotice = + !isUserOrganizer && + isWithinMinimumRescheduleNotice( + new Date(booking.startTime), booking.eventType.minimumRescheduleNotice ?? null ); - return ( - isCancelled || - isRejected || - (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || - isDisabledRescheduling || - isWithinMinimumNotice + return ( + isCancelled || + isRejected || + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + isDisabledRescheduling || + isWithinMinimumNotice + ); + case "reschedule_request": + const isWithinMinimumNoticeForRequest = + isWithinMinimumRescheduleNotice( + new Date(booking.startTime), + booking.eventType.minimumRescheduleNotice ?? null ); + return ( + isPending || + isCancelled || + isRejected || + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + isDisabledRescheduling || + isWithinMinimumNoticeForRequest + ); case "cancel": return isDisabledCancelling || isBookingInPast || isCancelled || isRejected; case "view_recordings": @@ -271,7 +284,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext case "reassign": case "change_location": case "add_members": - return isBookingInPast || isCancelled || isRejected; + return isBookingInPast || isCancelled || isRejected || isPending; default: return false; } diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index b2e424936741e3..27dce45b2f7f7e 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -44,6 +44,9 @@ import OrganizerRequestReminderEmail from "./templates/organizer-request-reminde import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email"; import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email"; import OrganizerScheduledEmail from "./templates/organizer-scheduled-email"; +import AttendeePendingRescheduledEmail from "./templates/attendee-pending-rescheduled-email"; +import OrganizerPendingRescheduledEmail from "./templates/organizer-pending-rescheduled-email"; +import { BookingStatus } from "@calcom/prisma/enums"; type EventTypeMetadata = z.infer; @@ -282,12 +285,45 @@ export const sendReassignedEmailsAndSMS = async (args: { const _sendRescheduledEmailsAndSMS = async ( calEvent: CalendarEvent, - eventTypeMetadata?: EventTypeMetadata + eventTypeMetadata?: EventTypeMetadata, + originalBookingStatus?: BookingStatus, ) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId); + const wasPending = originalBookingStatus === BookingStatus.PENDING; + + if (wasPending) { + const attendeeCalEvent = { + ...calendarEvent, + ...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }), + }; + + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerPendingRescheduledEmail({ calEvent: calendarEvent }))); + + if (calendarEvent.team) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerPendingRescheduledEmail({ calEvent: calendarEvent, teamMember })) + ); + } + } + } + + if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => + sendEmail(() => new AttendeePendingRescheduledEmail(attendeeCalEvent, attendee)) + ) + ); + } + + await Promise.all(emailsToSend); + return; + } + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); diff --git a/packages/emails/templates/attendee-pending-rescheduled-email.ts b/packages/emails/templates/attendee-pending-rescheduled-email.ts new file mode 100644 index 00000000000000..f1bf2fd538913f --- /dev/null +++ b/packages/emails/templates/attendee-pending-rescheduled-email.ts @@ -0,0 +1,38 @@ +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; +import renderEmail from "../src/renderEmail"; +import AttendeeScheduledEmail from "./attendee-scheduled-email"; + +export default class AttendeePendingRescheduledEmail extends AttendeeScheduledEmail { + constructor(calEvent: CalendarEvent, attendee: Person) { + super(calEvent, attendee); + this.name = "SEND_PENDING_RESCHEDULE_NOTIFICATION"; + } + + protected async getNodeMailerPayload(): Promise> { + // No ICS attachment: booking is still PENDING, no calendar event should be + // created yet. We deliberately omit the icalEvent field here. + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + subject: this.attendee.language.translate( + "rescheduled_pending_event_type_subject", + { + title: this.calEvent.title, + date: this.getFormattedDate(), + } + ), + html: await this.getHtml(this.calEvent, this.attendee), + text: this.getTextBody( + "rescheduled_pending_attendee_email_title", + "rescheduled_pending_attendee_email_subtitle" + ), + }; + } + + async getHtml(calEvent: CalendarEvent, attendee: Person) { + return await renderEmail("AttendeeRescheduledEmail", { + calEvent, + attendee, + }); + } +} \ No newline at end of file diff --git a/packages/emails/templates/organizer-pending-rescheduled-email.ts b/packages/emails/templates/organizer-pending-rescheduled-email.ts new file mode 100644 index 00000000000000..d8f3daec059e0f --- /dev/null +++ b/packages/emails/templates/organizer-pending-rescheduled-email.ts @@ -0,0 +1,46 @@ +import { EMAIL_FROM_NAME } from "@calcom/lib/constants"; +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 OrganizerPendingRescheduledEmail extends OrganizerScheduledEmail { + protected async getNodeMailerPayload(): Promise> { + const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email]; + + return { + from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, + to: toAddresses.join(","), + ...getReplyToHeader( + this.calEvent, + this.calEvent.attendees.map(({ email }) => email), + true + ), + subject: this.calEvent.organizer.language.translate( + "rescheduled_pending_event_type_subject", + { + title: this.calEvent.title, + date: this.getFormattedDate(), + } + ), + html: await this.getHtml( + { ...this.calEvent, attendeeSeatId: undefined }, + this.calEvent.organizer, + this.teamMember + ), + + text: this.getTextBody( + "rescheduled_pending_organizer_email_title", + "rescheduled_pending_organizer_email_subtitle" + ), + }; + } + + async getHtml(calEvent: CalendarEvent, attendee: Person, teamMember?: Person) { + return await renderEmail("OrganizerRescheduledEmail", { + calEvent, + attendee, + teamMember, + }); + } +} \ No newline at end of file diff --git a/packages/features/bookings/lib/BookingEmailSmsHandler.ts b/packages/features/bookings/lib/BookingEmailSmsHandler.ts index 12247507580e38..b08ef2801ad1c8 100644 --- a/packages/features/bookings/lib/BookingEmailSmsHandler.ts +++ b/packages/features/bookings/lib/BookingEmailSmsHandler.ts @@ -11,6 +11,7 @@ import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; import { default as cloneDeep } from "lodash/cloneDeep"; import type { Logger } from "tslog"; +import { BookingStatus } from "@calcom/prisma/enums"; export const BookingActionMap = { confirmed: "BOOKING_CONFIRMED", @@ -54,6 +55,8 @@ type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & { type RequestedEmailAndSmsPayload = EmailAndSmsPayload & { attendees?: Person[]; additionalNotes?: string | null; + originalRescheduledBooking?: NonNullable; + rescheduleReason?: string; }; type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & { @@ -115,6 +118,7 @@ export class BookingEmailSmsHandler { rescheduleReason, additionalNotes, additionalInformation, + originalRescheduledBooking, } = data; const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager"); @@ -125,7 +129,8 @@ export class BookingEmailSmsHandler { additionalNotes, cancellationReason: `$RCH$${rescheduleReason || ""}`, }, - metadata + metadata, + originalRescheduledBooking.status, ); } @@ -294,11 +299,38 @@ export class BookingEmailSmsHandler { eventType: { metadata }, attendees, additionalNotes, + originalRescheduledBooking, + rescheduleReason, } = data; if (!attendees?.length) { this.log.error("Requested action called without attendee details."); return; } + + if (originalRescheduledBooking?.status === BookingStatus.PENDING) { + this.log.debug( + "Action: BOOKING_REQUESTED via pending reschedule. Sending rescheduled pending emails.", + safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) + ); + + const { sendRescheduledEmailsAndSMS } = await import("@calcom/emails/email-manager"); + + try { + await sendRescheduledEmailsAndSMS( + { + ...evt, + additionalNotes, + cancellationReason: `$RCH$${rescheduleReason || ""}`, + }, + metadata, + originalRescheduledBooking.status, + ); + } catch (err) { + this.log.error("Failed to send pending reschedule emails", err); + } + return; + } + this.log.debug( "Action: BOOKING_REQUESTED. Sending request emails.", safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) diff --git a/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts b/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts index c3c354209c13f1..bbab01d3ce5645 100644 --- a/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts +++ b/packages/features/bookings/lib/handleNewBooking/getRequiresConfirmationFlags.ts @@ -1,6 +1,8 @@ import dayjs from "@calcom/dayjs"; import { checkIfFreeEmailDomain } from "@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { OriginalRescheduledBooking } from "./originalRescheduledBookingUtils"; import type { getEventTypeResponse } from "./getEventTypesFromDB"; @@ -15,22 +17,26 @@ export async function getRequiresConfirmationFlags({ bookingStartTime, userId, paymentAppData, - originalRescheduledBookingOrganizerId, + originalRescheduledBooking, bookerEmail, }: { eventType: EventType; bookingStartTime: string; userId: number | undefined; paymentAppData: PaymentAppData; - originalRescheduledBookingOrganizerId: number | undefined; + originalRescheduledBooking: OriginalRescheduledBooking | null, bookerEmail: string; }) { const requiresConfirmation = await determineRequiresConfirmation(eventType, bookingStartTime, bookerEmail); + const originalRescheduledBookingOrganizerId = originalRescheduledBooking?.user?.id const userReschedulingIsOwner = isUserReschedulingOwner(userId, originalRescheduledBookingOrganizerId); + const originalWasPending = originalRescheduledBooking?.status === BookingStatus.PENDING + const isConfirmedByDefault = determineIsConfirmedByDefault( requiresConfirmation, paymentAppData.price, - userReschedulingIsOwner + userReschedulingIsOwner, + originalWasPending ); return { @@ -87,7 +93,8 @@ function isUserReschedulingOwner( function determineIsConfirmedByDefault( requiresConfirmation: boolean, price: number, - userReschedulingIsOwner: boolean + userReschedulingIsOwner: boolean, + originalWasPending: boolean, ): boolean { - return (!requiresConfirmation && price === 0) || userReschedulingIsOwner; + return (!requiresConfirmation && price === 0) || userReschedulingIsOwner && !originalWasPending; } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 644a80a3e3e1bf..596af38ec985a9 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -669,8 +669,8 @@ async function handler( eventType, bookingStartTime: reqBody.start, userId, - originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id, paymentAppData, + originalRescheduledBooking, bookerEmail, }); @@ -2187,11 +2187,31 @@ async function handler( !!booking; if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) { + + if (!!originalRescheduledBooking && originalRescheduledBooking.status === BookingStatus.PENDING) { + tracingLogger.debug( + `Emails: Pending reschedule by owner, sending rescheduled pending emails`, + safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) + ); + + if (!isDryRun) { + await emailsAndSmsHandler.send({ + action: BookingActionMap.requested, + data: { + evt, + eventType, + attendees: attendeesList, + additionalNotes, + originalRescheduledBooking, + rescheduleReason + }, + }); + bookingEmailsAndSmsTaskerAction = BookingActionMap.requested; + } + } else { tracingLogger.debug( `Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`, - safeStringify({ - calEvent: getPiiFreeCalendarEvent(evt), - }) + safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) ); if (!isDryRun) { await emailsAndSmsHandler.send({ @@ -2201,6 +2221,7 @@ async function handler( bookingEmailsAndSmsTaskerAction = BookingActionMap.requested; } } + } if (booking.location?.startsWith("http")) { videoCallUrl = booking.location; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index ce7460a76d96e3..3cf575da9bc765 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -4768,5 +4768,10 @@ "user_updated_successfully": "User updated successfully.", "error_updating_user": "There was an error updating this user.", "please_reschedule": "Please reschedule.", + "rescheduled_pending_event_type_subject": "New time proposed for your pending booking - {{title}} on {{date}}", +"rescheduled_pending_attendee_email_title": "A new time has been proposed for your booking", +"rescheduled_pending_attendee_email_subtitle": "This booking still requires confirmation from the organizer. You will receive a separate email once it is confirmed or declined.", +"rescheduled_pending_organizer_email_title": "You have proposed a new time for a pending booking", +"rescheduled_pending_organizer_email_subtitle": "This booking still requires your confirmation. The attendee has been notified of the proposed new time.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" }