diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index 71a91ecb27..371afa924d 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -16,6 +16,16 @@ export default function Type() { return null; } +const querySchema = z.object({ + uid: z.string(), + seatReferenceUid: z.string().optional(), + rescheduledBy: z.string().optional(), + allowRescheduleForCancelledBooking: z + .string() + .transform((value) => value === "true") + .optional(), +}); + export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context); @@ -23,13 +33,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { uid: bookingUid, seatReferenceUid, rescheduledBy, - } = z - .object({ - uid: z.string(), - seatReferenceUid: z.string().optional(), - rescheduledBy: z.string().optional(), - }) - .parse(context.query); + /** + * This is for the case of request-reschedule where the booking is cancelled + */ + allowRescheduleForCancelledBooking, + } = querySchema.parse(context.query); + const coepFlag = context.query["flag.coep"]; const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat( prisma, @@ -90,7 +99,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // If booking is already CANCELLED or REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details. // A booking that has been rescheduled to a new booking will also have a status of CANCELLED - if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) { + if ( + !allowRescheduleForCancelledBooking && + (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) + ) { return { redirect: { destination: `/booking/${uid}`, diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 59ab19bb54..5a98727ae0 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -34,7 +34,7 @@ import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; import { getMockPaymentService } from "./MockPaymentService"; import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking"; -logger.settings.minLevel = 0; +logger.settings.minLevel = 1; const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] }); type InputWebhook = { diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 3390540df6..61568a76fe 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -143,10 +143,9 @@ expect.extend({ if (!isEmailContentMatched) { logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); - return { pass: false, - message: () => `Email content ${isNot ? "is" : "is not"} matching. ${JSON.stringify(emailsToLog)}`, + message: () => `Email content ${isNot ? "is" : "is not"} matching.`, actual: actualEmailContent, expected: expectedEmailContent, }; @@ -818,8 +817,6 @@ export function expectBookingRequestRescheduledEmails({ loggedInUser, booker, booking, - bookNewTimePath, - organizer, }: { emails: Fixtures["emails"]; organizer: ReturnType; @@ -840,7 +837,7 @@ export function expectBookingRequestRescheduledEmails({ subHeading: "request_reschedule_subtitle", links: [ { - href: `${bookingUrlOrigin}${bookNewTimePath}?rescheduleUid=${booking.uid}`, + href: `${bookingUrlOrigin}/reschedule/${booking.uid}?allowRescheduleForCancelledBooking=true`, text: "Book a new time", }, ], diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts index 1dbf090138..08daeadd7c 100644 --- a/packages/core/builders/CalendarEvent/builder.ts +++ b/packages/core/builders/CalendarEvent/builder.ts @@ -1,10 +1,9 @@ -import type { Booking } from "@prisma/client"; import { Prisma } from "@prisma/client"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; import dayjs from "@calcom/dayjs"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getRescheduleLink } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -238,32 +237,16 @@ export class CalendarEventBuilder implements ICalendarEventBuilder { } } - public buildRescheduleLink(booking: Partial, eventType?: CalendarEventBuilder["eventType"]) { + public buildRescheduleLink({ + allowRescheduleForCancelledBooking = false, + }: { + allowRescheduleForCancelledBooking?: boolean; + } = {}) { try { - if (!booking) { - throw new Error("Parameter booking is required to build reschedule link"); - } - const isTeam = !!eventType && !!eventType.teamId; - const isDynamic = booking?.dynamicEventSlugRef && booking?.dynamicGroupSlugRef; - - let slug = ""; - if (isTeam && eventType?.team?.slug) { - slug = `team/${eventType.team?.slug}/${eventType.slug}`; - } else if (isDynamic) { - const dynamicSlug = isDynamic ? `${booking.dynamicGroupSlugRef}/${booking.dynamicEventSlugRef}` : ""; - slug = dynamicSlug; - } else if (eventType?.slug) { - slug = `${this.users[0].username}/${eventType.slug}`; - } - - const queryParams = new URLSearchParams(); - queryParams.set("rescheduleUid", `${booking.uid}`); - slug = `${slug}`; - - const rescheduleLink = `${ - this.calendarEvent.bookerUrl ?? WEBAPP_URL - }/${slug}?${queryParams.toString()}`; - this.rescheduleLink = rescheduleLink; + this.rescheduleLink = getRescheduleLink({ + calEvent: this.calendarEvent, + allowRescheduleForCancelledBooking, + }); } catch (error) { if (error instanceof Error) { throw new Error(`buildRescheduleLink.error: ${error.message}`); diff --git a/packages/core/builders/CalendarEvent/director.ts b/packages/core/builders/CalendarEvent/director.ts index 3de91c5589..4fed7ede9d 100644 --- a/packages/core/builders/CalendarEvent/director.ts +++ b/packages/core/builders/CalendarEvent/director.ts @@ -37,7 +37,14 @@ export class CalendarEventDirector { this.cancellationReason = reason; } - public async buildForRescheduleEmail(): Promise { + public async buildForRescheduleEmail({ + allowRescheduleForCancelledBooking = false, + }: { + /** + * By default we don't want to allow reschedule for cancelled bookings. + */ + allowRescheduleForCancelledBooking?: boolean; + } = {}): Promise { if (this.existingBooking && this.existingBooking.eventTypeId && this.existingBooking.uid) { await this.builder.buildEventObjectFromInnerClass(this.existingBooking.eventTypeId); await this.builder.buildUsersFromInnerClass(); @@ -47,7 +54,7 @@ export class CalendarEventDirector { this.builder.setCancellationReason(this.cancellationReason); this.builder.setDescription(this.builder.eventType.description); this.builder.setNotes(this.existingBooking.description); - this.builder.buildRescheduleLink(this.existingBooking, this.builder.eventType); + this.builder.buildRescheduleLink({ allowRescheduleForCancelledBooking }); log.debug( "buildForRescheduleEmail", safeStringify({ existingBooking: this.existingBooking, builder: this.builder }) @@ -65,7 +72,7 @@ export class CalendarEventDirector { this.builder.setUId(this.existingBooking.uid); this.builder.setCancellationReason(this.cancellationReason); this.builder.setDescription(this.existingBooking.description); - await this.builder.buildRescheduleLink(this.existingBooking); + await this.builder.buildRescheduleLink(); } else { throw new Error("buildWithoutEventTypeForRescheduleEmail.missing.params.required"); } diff --git a/packages/emails/src/components/ManageLink.tsx b/packages/emails/src/components/ManageLink.tsx index 1fde126135..7a42314724 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; const cancelLink = getCancelLink(props.calEvent, props.attendee); - const rescheduleLink = getRescheduleLink(props.calEvent, props.attendee); + const rescheduleLink = getRescheduleLink({ calEvent: props.calEvent, attendee: props.attendee }); const bookingLink = getBookingUrl(props.calEvent); const isOriginalAttendee = props.attendee.email === props.calEvent.attendees[0]?.email; diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index cbb620ae20..9dfdea42ee 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -183,7 +183,7 @@ export const getPlatformManageLink = (calEvent: CalendarEvent, t: TFunction) => if (!calEvent.recurringEvent && calEvent.platformRescheduleUrl) { res += `${calEvent.platformCancelUrl ? ` ${t("or_lowercase")} ` : ""}${t( "reschedule" - )}: ${getRescheduleLink(calEvent)}`; + )}: ${getRescheduleLink({ calEvent })}`; } return res; @@ -252,7 +252,15 @@ export const getPlatformRescheduleLink = ( return ""; }; -export const getRescheduleLink = (calEvent: CalendarEvent, attendee?: Person): string => { +export const getRescheduleLink = ({ + calEvent, + allowRescheduleForCancelledBooking = false, + attendee, +}: { + calEvent: CalendarEvent; + allowRescheduleForCancelledBooking?: boolean; + attendee?: Person; +}): string => { const Uid = getUid(calEvent); const seatUid = getSeatReferenceId(calEvent); @@ -260,13 +268,15 @@ export const getRescheduleLink = (calEvent: CalendarEvent, attendee?: Person): s return getPlatformRescheduleLink(calEvent, Uid, seatUid); } - const rescheduleLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`); - + const url = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`); + if (allowRescheduleForCancelledBooking) { + url.searchParams.append("allowRescheduleForCancelledBooking", "true"); + } if (attendee?.email) { - rescheduleLink.searchParams.append("rescheduledBy", attendee.email); + url.searchParams.append("rescheduledBy", attendee.email); } - return rescheduleLink.toString(); + return url.toString(); }; export const getRichDescription = ( diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index c0dd0035a6..6d5540a0e8 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -205,7 +205,8 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule director.setExistingBooking(bookingToReschedule); cancellationReason && director.setCancellationReason(cancellationReason); if (Object.keys(event).length) { - await director.buildForRescheduleEmail(); + // Request Reschedule flow first cancels the booking and then reschedule email is sent. So, we need to allow reschedule for cancelled booking + await director.buildForRescheduleEmail({ allowRescheduleForCancelledBooking: true }); } else { await director.buildWithoutEventTypeForRescheduleEmail(); }