From b6d31129a37d4651f9b07e486df1bd53cefea866 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Sun, 25 Aug 2024 15:01:46 +0530 Subject: [PATCH] feat: Add rescheduledBy & canceledBy fields in the DB (#15337) * feat: Add rescheduledBy & canceledBy fields in the DB * fix: type check * fix: type check * fix: use session user email for reschedule * fix: unit test * feat: db field email validation * feat: rescheduledBy and cancelledBy in webhooks * revert unrelated changes. * make session user secondary, default to Anonymous * if condition not required * Make cancelledBy optional Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> * update cancel booking type * fix: update cancelledBy in db when requesting reschedule * remove default value for fields * fix: type check * feat: manage fields via api v1 * fix: add fields in booking read api v1 * test: expand to cover new fields * fix: use cancelledBy param on booking page --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/api/v1/lib/validations/booking.ts | 6 ++++ apps/web/components/booking/CancelBooking.tsx | 5 +++- .../bookings/views/bookings-single-view.tsx | 7 ++++- apps/web/pages/reschedule/[uid].tsx | 19 +++++++++++-- .../web/test/utils/bookingScenario/expects.ts | 28 +++++++++++++++---- .../getMockRequestDataForBooking.ts | 2 ++ packages/emails/src/components/ManageLink.tsx | 7 +++-- .../src/templates/OrganizerScheduledEmail.tsx | 5 ++++ packages/features/bookings/Booker/store.ts | 13 +++++++-- packages/features/bookings/Booker/types.ts | 1 + .../booking-to-mutation-input-mapper.tsx | 3 ++ .../bookings/lib/handleCancelBooking.ts | 5 +++- .../handleCancelBooking/test/webhook.test.ts | 2 ++ .../features/bookings/lib/handleNewBooking.ts | 3 ++ .../handleNewBooking/test/reschedule.test.ts | 7 +++++ .../bookings/lib/handleSeats/handleSeats.ts | 2 ++ .../lib/handleSeats/test/handleSeats.test.ts | 3 ++ .../bookings/lib/handleSeats/types.d.ts | 1 + packages/features/webhooks/lib/sendPayload.ts | 2 ++ packages/lib/CalEventParser.ts | 17 ++++++++--- packages/lib/test/builder.ts | 2 ++ .../atoms/booker/BookerPlatformWrapper.tsx | 2 ++ .../atoms/booker/BookerWebWrapper.tsx | 4 +++ .../atoms/hooks/useHandleBookEvent.ts | 2 ++ .../migration.sql | 3 ++ packages/prisma/schema.prisma | 4 +++ packages/prisma/zod-utils.ts | 2 ++ .../bookings/requestReschedule.handler.ts | 2 ++ 28 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 packages/prisma/migrations/20240605135455_rescheduled_by_and_cancelled_by_for_bookings/migration.sql diff --git a/apps/api/v1/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts index d326ce8233..f09e805423 100644 --- a/apps/api/v1/lib/validations/booking.ts +++ b/apps/api/v1/lib/validations/booking.ts @@ -14,6 +14,8 @@ const schemaBookingBaseBodyParams = Booking.pick({ startTime: true, endTime: true, status: true, + rescheduledBy: true, + cancelledBy: true, }).partial(); export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial()); @@ -33,6 +35,8 @@ const schemaBookingEditParams = z title: z.string().optional(), startTime: iso8601.optional(), endTime: iso8601.optional(), + cancelledBy: z.string().email({ message: "Invalid Email" }).optional(), + rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(), // Not supporting responses in edit as that might require re-triggering emails // responses }) @@ -88,4 +92,6 @@ export const schemaBookingReadPublic = Booking.extend({ status: true, responses: true, fromReschedule: true, + cancelledBy: true, + rescheduledBy: true, }); diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index 53300f305d..e9399d5b24 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -23,6 +23,7 @@ type Props = { theme: string | null; allRemainingBookings: boolean; seatReferenceUid?: string; + currentUserEmail?: string; bookingCancelledEventProps: { booking: unknown; organizer: { @@ -38,7 +39,8 @@ export default function CancelBooking(props: Props) { const [cancellationReason, setCancellationReason] = useState(""); const { t } = useLocale(); const router = useRouter(); - const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps } = props; + const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps, currentUserEmail } = + props; const [loading, setLoading] = useState(false); const telemetry = useTelemetry(); const [error, setError] = useState(booking ? null : t("booking_already_cancelled")); @@ -99,6 +101,7 @@ export default function CancelBooking(props: Props) { allRemainingBookings, // @NOTE: very important this shouldn't cancel with number ID use uid instead seatReferenceUid, + cancelledBy: currentUserEmail, }), headers: { "Content-Type": "application/json", diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 88c4251c46..d0f2c42acd 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -163,6 +163,8 @@ export default function Success(props: PageProps) { const [calculatedDuration, setCalculatedDuration] = useState(undefined); const [comment, setComment] = useState(""); const parsedRating = rating ? parseInt(rating, 10) : 3; + const currentUserEmail = + searchParams?.get("cancelledBy") ?? searchParams?.get("email") ?? session?.user?.email ?? undefined; const defaultRating = isNaN(parsedRating) ? 3 : parsedRating > 5 ? 5 : parsedRating < 1 ? 1 : parsedRating; const [rateValue, setRateValue] = useState(defaultRating); @@ -700,7 +702,9 @@ export default function Success(props: PageProps) { {t("reschedule")} @@ -738,6 +742,7 @@ export default function Success(props: PageProps) { allRemainingBookings={allRemainingBookings} seatReferenceUid={seatReferenceUid} bookingCancelledEventProps={bookingCancelledEventProps} + currentUserEmail={currentUserEmail} /> ))} diff --git a/apps/web/pages/reschedule/[uid].tsx b/apps/web/pages/reschedule/[uid].tsx index 3bbd499836..71a91ecb27 100644 --- a/apps/web/pages/reschedule/[uid].tsx +++ b/apps/web/pages/reschedule/[uid].tsx @@ -19,8 +19,16 @@ export default function Type() { export async function getServerSideProps(context: GetServerSidePropsContext) { const session = await getServerSession(context); - const { uid: bookingUid, seatReferenceUid } = z - .object({ uid: z.string(), seatReferenceUid: z.string().optional() }) + const { + uid: bookingUid, + seatReferenceUid, + rescheduledBy, + } = z + .object({ + uid: z.string(), + seatReferenceUid: z.string().optional(), + rescheduledBy: z.string().optional(), + }) .parse(context.query); const coepFlag = context.query["flag.coep"]; const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat( @@ -147,6 +155,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (coepFlag) { destinationUrlSearchParams.set("flag.coep", coepFlag as string); } + + const currentUserEmail = rescheduledBy ?? session?.user?.email; + + if (currentUserEmail) { + destinationUrlSearchParams.set("rescheduledBy", currentUserEmail); + } + return { redirect: { destination: `${eventUrl}?${destinationUrlSearchParams.toString()}${ diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 3a5aefb68c..3390540df6 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -461,13 +461,19 @@ export function expectSuccessfulBookingCreationEmails({ links: recurrence ? [ { - href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=true`, + href: `${bookingUrlOrigin}/booking/${ + booking.uid + }?cancel=true&allRemainingBookings=true&cancelledBy=${encodeURIComponent( + destinationEmail ?? organizer.email + )}`, text: "cancel", }, ] : [ { - href: `${bookingUrlOrigin}/reschedule/${booking.uid}`, + href: `${bookingUrlOrigin}/reschedule/${booking.uid}?rescheduledBy=${encodeURIComponent( + destinationEmail ?? organizer.email + )}`, text: "reschedule", }, ], @@ -514,13 +520,17 @@ export function expectSuccessfulBookingCreationEmails({ links: recurrence ? [ { - href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=true`, + href: `${bookingUrlOrigin}/booking/${ + booking.uid + }?cancel=true&allRemainingBookings=true&cancelledBy=${encodeURIComponent(booker.email)}`, text: "cancel", }, ] : [ { - href: `${bookingUrlOrigin}/reschedule/${booking.uid}`, + href: `${bookingUrlOrigin}/reschedule/${booking.uid}?rescheduledBy=${encodeURIComponent( + booker.email + )}`, text: "reschedule", }, ], @@ -552,11 +562,17 @@ export function expectSuccessfulBookingCreationEmails({ }, links: [ { - href: `${bookingUrlOrigin}/reschedule/${booking.uid}`, + href: `${bookingUrlOrigin}/reschedule/${booking.uid}?rescheduledBy=${encodeURIComponent( + otherTeamMember.email + )}`, text: "reschedule", }, { - href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`, + href: `${bookingUrlOrigin}/booking/${ + booking.uid + }?cancel=true&allRemainingBookings=false&cancelledBy=${encodeURIComponent( + otherTeamMember.email + )}`, text: "cancel", }, ], diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts index ac0a0e00f9..9a5581c6c8 100644 --- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts +++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts @@ -26,6 +26,8 @@ export function getMockRequestDataForBooking({ bookingUid?: string; recurringEventId?: string; recurringCount?: number; + rescheduledBy?: string; + cancelledBy?: string; schedulingType?: SchedulingType; responses: { email: string; diff --git a/packages/emails/src/components/ManageLink.tsx b/packages/emails/src/components/ManageLink.tsx index 4db512107a..1fde126135 100644 --- a/packages/emails/src/components/ManageLink.tsx +++ b/packages/emails/src/components/ManageLink.tsx @@ -5,8 +5,8 @@ export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person }) // Only the original attendee can make changes to the event // Guests cannot const t = props.attendee.language.translate; - const cancelLink = getCancelLink(props.calEvent); - const rescheduleLink = getRescheduleLink(props.calEvent); + const cancelLink = getCancelLink(props.calEvent, props.attendee); + const rescheduleLink = getRescheduleLink(props.calEvent, props.attendee); const bookingLink = getBookingUrl(props.calEvent); const isOriginalAttendee = props.attendee.email === props.calEvent.attendees[0]?.email; @@ -16,9 +16,10 @@ export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person }) const hasBookingLink = Boolean(bookingLink); const isRecurringEvent = props.calEvent.recurringEvent; const shouldDisplayRescheduleLink = Boolean(hasRescheduleLink && !isRecurringEvent); + const isTeamMember = props.calEvent.team?.members.some((member) => props.attendee.email === member.email); if ( - (isOriginalAttendee || isOrganizer) && + (isOriginalAttendee || isOrganizer || isTeamMember) && (hasCancelLink || (!isRecurringEvent && hasRescheduleLink) || hasBookingLink) ) { return ( diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index 67590251fe..7bbd7a0a26 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -1,3 +1,4 @@ +import { SchedulingType } from "@prisma/client"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { BaseScheduledEmail } from "./BaseScheduledEmail"; @@ -32,6 +33,9 @@ export const OrganizerScheduledEmail = ( const locale = props.teamMember?.language.locale || props.calEvent.organizer.language.locale; const timeFormat = props.teamMember?.timeFormat || props.calEvent.organizer?.timeFormat; + const isTeamEvent = props.calEvent.schedulingType === SchedulingType.ROUND_ROBIN || props.calEvent.schedulingType === SchedulingType.COLLECTIVE; + const attendee = isTeamEvent && props.teamMember ? props.teamMember : props.attendee; + return ( } {...props} + attendee={attendee} /> ); }; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index 836f61a3b3..27db6dede5 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -27,6 +27,7 @@ type StoreInitializeType = { bookingData?: GetBookingType | null | undefined; verifiedEmail?: string | null; rescheduleUid?: string | null; + rescheduledBy?: string | null; seatReferenceUid?: string; durationConfig?: number[] | null; org?: string | null; @@ -113,11 +114,12 @@ export type BookerStore = { dayCount: number | null; setDayCount: (dayCount: number | null) => void; /** - * If booking is being rescheduled or it has seats, it receives a rescheduleUid or bookingUid + * If booking is being rescheduled or it has seats, it receives a rescheduleUid with rescheduledBy or bookingUid * the current booking details are passed in. The `bookingData` * object is something that's fetched server side. */ rescheduleUid: string | null; + rescheduledBy: string | null; bookingUid: string | null; bookingData: GetBookingType | null; setBookingData: (bookingData: GetBookingType | null | undefined) => void; @@ -207,6 +209,7 @@ export const useBookerStore = create((set, get) => ({ username: null, eventSlug: null, eventId: null, + rescheduledBy: null, verifiedEmail: null, setVerifiedEmail: (email: string | null) => { set({ verifiedEmail: email }); @@ -242,6 +245,7 @@ export const useBookerStore = create((set, get) => ({ month, eventId, rescheduleUid = null, + rescheduledBy = null, bookingUid = null, bookingData = null, layout, @@ -260,7 +264,8 @@ export const useBookerStore = create((set, get) => ({ get().rescheduleUid === rescheduleUid && get().bookingUid === bookingUid && get().bookingData?.responses.email === bookingData?.responses.email && - get().layout === layout + get().layout === layout && + get().rescheduledBy === rescheduledBy ) return; set({ @@ -269,6 +274,7 @@ export const useBookerStore = create((set, get) => ({ eventId, org, rescheduleUid, + rescheduledBy, bookingUid, bookingData, layout: layout || BookerLayouts.MONTH_VIEW, @@ -358,6 +364,7 @@ export const useInitializeBookerStore = ({ month, eventId, rescheduleUid = null, + rescheduledBy = null, bookingData = null, verifiedEmail = null, layout, @@ -374,6 +381,7 @@ export const useInitializeBookerStore = ({ month, eventId, rescheduleUid, + rescheduledBy, bookingData, layout, isTeamEvent, @@ -390,6 +398,7 @@ export const useInitializeBookerStore = ({ month, eventId, rescheduleUid, + rescheduledBy, bookingData, layout, isTeamEvent, diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index be0dc82cdd..71460e61e5 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -90,6 +90,7 @@ export interface BookerProps { export type WrappedBookerPropsMain = { sessionUsername?: string | null; rescheduleUid: string | null; + rescheduledBy: string | null; bookingUid: string | null; isRedirect: boolean; fromUserNameRedirected: string; diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 54c0aac930..2b0a859e4b 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -14,6 +14,7 @@ export type BookingOptions = { timeZone: string; language: string; rescheduleUid: string | undefined; + rescheduledBy: string | undefined; username: string; metadata?: Record; bookingUid?: string; @@ -31,6 +32,7 @@ export const mapBookingToMutationInput = ({ timeZone, language, rescheduleUid, + rescheduledBy, username, metadata, bookingUid, @@ -52,6 +54,7 @@ export const mapBookingToMutationInput = ({ timeZone: timeZone, language: language, rescheduleUid, + rescheduledBy, metadata: metadata || {}, hasHashedBookingLink: hashedLink ? true : false, bookingUid, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 20eb6f7761..ba4b60b22a 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -155,7 +155,7 @@ export type HandleCancelBookingResponse = { }; async function handler(req: CustomRequest) { - const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid } = + const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid, cancelledBy } = schemaBookingCancelParams.parse(req.body); req.bookingToDelete = await getBookingToDelete(id, uid); const { @@ -342,6 +342,7 @@ async function handler(req: CustomRequest) { ...eventTypeInfo, status: "CANCELLED", smsReminderNumber: bookingToDelete.smsReminderNumber || undefined, + cancelledBy: cancelledBy, }).catch((e) => { logger.error( `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`, @@ -398,6 +399,7 @@ async function handler(req: CustomRequest) { data: { status: BookingStatus.CANCELLED, cancellationReason: cancellationReason, + cancelledBy: cancelledBy, }, }); const allUpdatedBookings = await prisma.booking.findMany({ @@ -440,6 +442,7 @@ async function handler(req: CustomRequest) { data: { status: BookingStatus.CANCELLED, cancellationReason: cancellationReason, + cancelledBy: cancelledBy, // Assume that canceling the booking is the last action iCalSequence: evt.iCalSequence || 100, }, diff --git a/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts index f7df0792de..88a87aa9cd 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/webhook.test.ts @@ -110,6 +110,7 @@ describe("Cancel Booking", () => { body: { id: idOfBookingToBeCancelled, uid: uidOfBookingToBeCancelled, + cancelledBy: organizer.email, }, }); @@ -121,6 +122,7 @@ describe("Cancel Booking", () => { location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", payload: { + cancelledBy: organizer.email, organizer: { id: organizer.id, username: organizer.username, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 29bcb1a9f5..2f7f876086 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -996,6 +996,7 @@ async function handler( eventTrigger, responses, workflows, + rescheduledBy: reqBody.rescheduledBy, }); if (newBooking) { @@ -1066,6 +1067,7 @@ async function handler( data: { rescheduled: true, status: BookingStatus.CANCELLED, + rescheduledBy: reqBody.rescheduledBy, }, }); } @@ -1561,6 +1563,7 @@ async function handler( eventTypeId, status: "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, + rescheduledBy: reqBody.rescheduledBy, }; if (bookingRequiresPayment) { diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index d6ef4cf726..e6b106662d 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -173,6 +173,7 @@ describe("handleNewBooking", () => { name: booker.name, location: { optionValue: "", value: BookingLocations.CalVideo }, }, + rescheduledBy: organizer.email, }, }); @@ -199,6 +200,7 @@ describe("handleNewBooking", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: uidOfBookingToBeRescheduled, status: BookingStatus.CANCELLED, + rescheduledBy: organizer.email, }); expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); @@ -284,6 +286,9 @@ describe("handleNewBooking", () => { location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + payload: { + rescheduledBy: organizer.email, + }, }); }, timeout @@ -1957,6 +1962,7 @@ describe("handleNewBooking", () => { name: booker.name, location: { optionValue: "", value: BookingLocations.CalVideo }, }, + rescheduledBy: booker.email, }, }); const { req } = createMockNextJsRequest({ @@ -1982,6 +1988,7 @@ describe("handleNewBooking", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: uidOfBookingToBeRescheduled, status: BookingStatus.CANCELLED, + rescheduledBy: booker.email, }); expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index f12f11946c..4ceb30650d 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -30,6 +30,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { eventTrigger, evt, workflows, + rescheduledBy, } = newSeatedBookingObject; const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug); @@ -141,6 +142,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { eventTypeId, status: "ACCEPTED", smsReminderNumber: seatedBooking?.smsReminderNumber || undefined, + rescheduledBy, }; await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index 41de622871..74fecd8a16 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -2671,6 +2671,7 @@ describe("handleSeats", () => { location: { optionValue: "", value: BookingLocations.CalVideo }, }, rescheduleUid: firstBookingUid, + cancelledBy: organizer.email, }, }); @@ -2690,10 +2691,12 @@ describe("handleSeats", () => { }, select: { status: true, + cancelledBy: true, }, }); expect(cancelledBooking?.status).toEqual(BookingStatus.CANCELLED); + expect(cancelledBooking?.cancelledBy).toEqual(organizer.email); }); }); }); diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index e4497d39d1..4390ff7414 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -38,6 +38,7 @@ export type NewSeatedBookingObject = { subscriberOptions: GetSubscriberOptions; eventTrigger: WebhookTriggerEvents; responses: z.infer>["responses"] | null; + rescheduledBy?: string; workflows: Workflow[]; }; diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index 86249e0095..304639e9d0 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -60,6 +60,8 @@ export type WebhookDataType = CalendarEvent & createdAt: string; downloadLink?: string; paymentId?: number; + rescheduledBy?: string; + cancelledBy?: string; paymentData?: Payment; }; diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 2a655fa9b6..cbb620ae20 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -2,7 +2,7 @@ import type { TFunction } from "next-i18next"; import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; -import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { WEBAPP_URL } from "./constants"; import getLabelValueMapFromResponses from "./getLabelValueMapFromResponses"; @@ -217,7 +217,7 @@ export const getPlatformCancelLink = ( return ""; }; -export const getCancelLink = (calEvent: CalendarEvent): string => { +export const getCancelLink = (calEvent: CalendarEvent, attendee?: Person): string => { const Uid = getUid(calEvent); const seatReferenceUid = getSeatReferenceId(calEvent); if (calEvent.platformClientId) { @@ -227,6 +227,9 @@ export const getCancelLink = (calEvent: CalendarEvent): string => { const cancelLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${Uid}`); cancelLink.searchParams.append("cancel", "true"); cancelLink.searchParams.append("allRemainingBookings", String(!!calEvent.recurringEvent)); + if (attendee?.email) { + cancelLink.searchParams.append("cancelledBy", attendee.email); + } if (seatReferenceUid) cancelLink.searchParams.append("seatReferenceUid", seatReferenceUid); return cancelLink.toString(); }; @@ -249,7 +252,7 @@ export const getPlatformRescheduleLink = ( return ""; }; -export const getRescheduleLink = (calEvent: CalendarEvent): string => { +export const getRescheduleLink = (calEvent: CalendarEvent, attendee?: Person): string => { const Uid = getUid(calEvent); const seatUid = getSeatReferenceId(calEvent); @@ -257,7 +260,13 @@ export const getRescheduleLink = (calEvent: CalendarEvent): string => { return getPlatformRescheduleLink(calEvent, Uid, seatUid); } - return `${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`; + const rescheduleLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`); + + if (attendee?.email) { + rescheduleLink.searchParams.append("rescheduledBy", attendee.email); + } + + return rescheduleLink.toString(); }; export const getRichDescription = ( diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 48e8053276..ed70759dce 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -53,6 +53,8 @@ export const buildBooking = ( status: BookingStatus.ACCEPTED, paid: false, destinationCalendarId: null, + cancelledBy: null, + rescheduledBy: null, cancellationReason: null, rejectionReason: null, dynamicEventSlugRef: null, diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 2df13a5e52..9a0a59e813 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -47,6 +47,7 @@ import { AtomsWrapper } from "../src/components/atoms-wrapper"; export type BookerPlatformWrapperAtomProps = Omit & { rescheduleUid?: string; + rescheduledBy?: string; bookingUid?: string; username: string | string[] | undefined; entity?: BookerProps["entity"]; @@ -357,6 +358,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => } } rescheduleUid={props.rescheduleUid ?? null} + rescheduledBy={null} bookingUid={props.bookingUid ?? null} isRedirect={false} fromUserNameRedirected="" diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index bcdb05e41a..2b20f5574e 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -39,6 +39,8 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { const fromUserNameRedirected = searchParams?.get("username") || ""; const rescheduleUid = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduleUid") : null; + const rescheduledBy = + typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("rescheduledBy") : null; const bookingUid = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("bookingUid") : null; const date = dayjs(selectedDate).format("YYYY-MM-DD"); @@ -52,6 +54,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { ...props, eventId: event?.data?.id, rescheduleUid, + rescheduledBy, bookingUid: bookingUid, layout: bookerLayout.defaultLayout, org: props.entity.orgSlug, @@ -196,6 +199,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { isRedirect={isRedirect} fromUserNameRedirected={fromUserNameRedirected} rescheduleUid={rescheduleUid} + rescheduledBy={rescheduledBy} bookingUid={bookingUid} hasSession={hasSession} extraOptions={routerQuery} diff --git a/packages/platform/atoms/hooks/useHandleBookEvent.ts b/packages/platform/atoms/hooks/useHandleBookEvent.ts index fd6952548e..48256cb09a 100644 --- a/packages/platform/atoms/hooks/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/useHandleBookEvent.ts @@ -44,6 +44,7 @@ export const useHandleBookEvent = ({ const duration = useBookerStore((state) => state.selectedDuration); const { timezone } = useTimePreferences(); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); + const rescheduledBy = useBookerStore((state) => state.rescheduledBy); const { t, i18n } = useLocale(); const username = useBookerStore((state) => state.username); const recurringEventCount = useBookerStore((state) => state.recurringEventCount); @@ -82,6 +83,7 @@ export const useHandleBookEvent = ({ timeZone: timezone, language: i18n.language, rescheduleUid: rescheduleUid || undefined, + rescheduledBy: rescheduledBy || undefined, bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined, username: username || "", metadata: metadata, diff --git a/packages/prisma/migrations/20240605135455_rescheduled_by_and_cancelled_by_for_bookings/migration.sql b/packages/prisma/migrations/20240605135455_rescheduled_by_and_cancelled_by_for_bookings/migration.sql new file mode 100644 index 0000000000..eaf7b6b5b7 --- /dev/null +++ b/packages/prisma/migrations/20240605135455_rescheduled_by_and_cancelled_by_for_bookings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "cancelledBy" TEXT, +ADD COLUMN "rescheduledBy" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3a79e90a18..f31380402d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -596,6 +596,10 @@ model Booking { ratingFeedback String? noShowHost Boolean? @default(false) scheduledTriggers WebhookScheduledTriggers[] + /// @zod.email() + cancelledBy String? + /// @zod.email() + rescheduledBy String? @@index([eventTypeId]) @@index([userId]) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 78b249c1d6..ee5e1dd2bb 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -235,6 +235,7 @@ export const bookingCreateBodySchema = z.object({ eventTypeSlug: z.string().optional(), rescheduleUid: z.string().optional(), recurringEventId: z.string().optional(), + rescheduledBy: z.string().email({ message: "Invalid email" }).optional(), start: z.string(), timeZone: z.string().refine((value: string) => isSupportedTimeZone(value), { message: "Invalid timezone" }), user: z.union([z.string(), z.array(z.string())]).optional(), @@ -319,6 +320,7 @@ export const schemaBookingCancelParams = z.object({ allRemainingBookings: z.boolean().optional(), cancellationReason: z.string().optional(), seatReferenceUid: z.string().optional(), + cancelledBy: z.string().email({ message: "Invalid email" }).optional(), }); export const vitalSettingsUpdateSchema = z.object({ diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 392a34215f..c0dd0035a6 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -144,6 +144,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule cancellationReason, status: BookingStatus.CANCELLED, updatedAt: dayjs().toISOString(), + cancelledBy: user.email, }, }); @@ -302,6 +303,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, { ...evt, smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined, + cancelledBy: user.email, }).catch((e) => { log.error( `Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,