From a8825badec0a54d4ec0696b445894ce96de884cf Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 27 Mar 2023 13:57:10 +0530 Subject: [PATCH] Fix: Send responses in confirm booking flow (#7830) --- apps/web/lib/getBooking.tsx | 28 ++------- apps/web/pages/[user]/book.tsx | 6 +- apps/web/pages/booking/[uid].tsx | 3 +- apps/web/pages/team/[slug]/[type].tsx | 3 +- apps/web/pages/team/[slug]/book.tsx | 2 +- .../bookings/lib/getBookingResponsesSchema.ts | 27 ++++++++- .../bookings/lib/getCalEventResponses.ts | 59 +++++++++++++++++++ .../features/bookings/lib/handleNewBooking.ts | 21 ++----- packages/lib/getLabelValueMapFromResponses.ts | 8 ++- .../trpc/server/routers/viewer/bookings.tsx | 34 ++++++++++- packages/types/Calendar.d.ts | 26 ++++---- 11 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 packages/features/bookings/lib/getCalEventResponses.ts diff --git a/apps/web/lib/getBooking.tsx b/apps/web/lib/getBooking.tsx index 8659dc25f4..77654a54d0 100644 --- a/apps/web/lib/getBooking.tsx +++ b/apps/web/lib/getBooking.tsx @@ -1,9 +1,8 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import type { z } from "zod"; -import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; +import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import slugify from "@calcom/lib/slugify"; -import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; type BookingSelect = { description: true; @@ -45,11 +44,7 @@ function getResponsesFromOldBooking( }; } -async function getBooking( - prisma: PrismaClient, - uid: string, - bookingFields: z.infer & z.BRAND<"HAS_SYSTEM_FIELDS"> -) { +async function getBooking(prisma: PrismaClient, uid: string) { const rawBooking = await prisma.booking.findFirst({ where: { uid, @@ -82,9 +77,7 @@ async function getBooking( return rawBooking; } - const booking = getBookingWithResponses(rawBooking, { - bookingFields, - }); + const booking = getBookingWithResponses(rawBooking); if (booking) { // @NOTE: had to do this because Server side cant return [Object objects] @@ -104,20 +97,11 @@ export const getBookingWithResponses = < }; }> >( - booking: T, - eventType: { - bookingFields: z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">; - } + booking: T ) => { return { ...booking, - responses: getBookingResponsesPartialSchema({ - eventType: { - bookingFields: eventType.bookingFields, - }, - // An existing booking can have data from any number of views, so the schema should consider ALL_VIEWS - view: "ALL_VIEWS", - }).parse(booking.responses || getResponsesFromOldBooking(booking)), - }; + responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)), + } as Omit & { responses: z.infer }; }; export default getBooking; diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 189f46e836..b44d6d6dec 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -237,11 +237,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { let booking: GetBookingType | null = null; if (rescheduleUid || query.bookingUid || bookingUidWithSeats) { - booking = await getBooking( - prisma, - rescheduleUid || query.bookingUid || bookingUidWithSeats || "", - eventTypeObject.bookingFields - ); + booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || ""); } if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) { diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index b509f1e333..fa605999fe 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -1091,8 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } - const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw); - + const bookingInfo = getBookingWithResponses(bookingInfoRaw); // @NOTE: had to do this because Server side cant return [Object objects] // probably fixable with json.stringify -> json.parse bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date; diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 785c78fe97..455e6e4de0 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next"; import type { LocationObject } from "@calcom/core/location"; import { privacyFilteredLocations } from "@calcom/core/location"; -import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; import { parseRecurringEvent } from "@calcom/lib"; import { getWorkingHours } from "@calcom/lib/availability"; import prisma from "@calcom/prisma"; @@ -177,7 +176,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => let booking: GetBookingType | null = null; if (rescheduleUid) { - booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject)); + booking = await getBooking(prisma, rescheduleUid); } const weekStart = eventType.team?.members?.[0]?.user?.weekStart; diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index c7e20ea260..7730003919 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -127,7 +127,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { let booking: GetBookingType | null = null; const { rescheduleUid, bookingUid } = querySchema.parse(context.query); if (rescheduleUid || bookingUid) { - booking = await getBooking(prisma, rescheduleUid || bookingUid || "", eventTypeObject.bookingFields); + booking = await getBooking(prisma, rescheduleUid || bookingUid || ""); } // Checking if number of recurring event ocurrances is valid against event type configuration diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 2f84d87ecc..95789243ca 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -9,6 +9,20 @@ type EventType = Parameters[0]["eventType"]; // eslint-disable-next-line @typescript-eslint/ban-types type View = ALL_VIEWS | (string & {}); +export const bookingResponse = z.union([ + z.string(), + z.boolean(), + z.string().array(), + z.object({ + optionValue: z.string(), + value: z.string(), + }), +]); + +export const bookingResponsesDbSchema = z.record(bookingResponse); + +const catchAllSchema = bookingResponsesDbSchema; + export const getBookingResponsesPartialSchema = ({ eventType, view, @@ -16,7 +30,7 @@ export const getBookingResponsesPartialSchema = ({ eventType: EventType; view: View; }) => { - const schema = bookingResponses.unwrap().partial().and(z.record(z.any())); + const schema = bookingResponses.unwrap().partial().and(catchAllSchema); return preprocess({ schema, eventType, isPartialSchema: true, view }); }; @@ -38,9 +52,12 @@ function preprocess({ view: currentView, }: { schema: T; + // It is useful when we want to prefill the responses with the partial values. Partial can be in 2 ways + // - Not all required fields are need to be provided for prefill. + // - Even a field response itself can be partial so the content isn't validated e.g. a field with type="phone" can be given a partial phone number(e.g. Specifying the country code like +91) isPartialSchema: boolean; eventType: { - bookingFields: z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">; + bookingFields: (z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">) | null; }; view: View; }): z.ZodType, z.infer, z.infer> { @@ -48,6 +65,8 @@ function preprocess({ (responses) => { const parsedResponses = z.record(z.any()).nullable().parse(responses) || {}; const newResponses = {} as typeof parsedResponses; + // if eventType has been deleted, we won't have bookingFields and thus we can't preprocess or validate them. + if (!eventType.bookingFields) return parsedResponses; eventType.bookingFields.forEach((field) => { const value = parsedResponses[field.name]; if (value === undefined) { @@ -86,6 +105,10 @@ function preprocess({ return newResponses; }, schema.superRefine((responses, ctx) => { + if (!eventType.bookingFields) { + // if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses. + return; + } eventType.bookingFields.forEach((bookingField) => { const value = responses[bookingField.name]; const stringSchema = z.string(); diff --git a/packages/features/bookings/lib/getCalEventResponses.ts b/packages/features/bookings/lib/getCalEventResponses.ts new file mode 100644 index 0000000000..50c92b60f9 --- /dev/null +++ b/packages/features/bookings/lib/getCalEventResponses.ts @@ -0,0 +1,59 @@ +import type z from "zod"; + +import { SystemField } from "@calcom/features/bookings/lib/getBookingFields"; +import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; +import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +export const getCalEventResponses = ({ + bookingFields, + responses, +}: { + // If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB + // Also, it is no longer straightforward to identify if a field is system field or not + bookingFields: z.infer | null; + responses: z.infer; +}) => { + const calEventUserFieldsResponses = {} as NonNullable; + const calEventResponses = {} as NonNullable; + + if (bookingFields) { + bookingFields.forEach((field) => { + const label = field.label || field.defaultLabel; + if (!label) { + throw new Error('Missing label for booking field "' + field.name + '"'); + } + if (field.editable === "user" || field.editable === "user-readonly") { + calEventUserFieldsResponses[field.name] = { + label, + value: responses[field.name], + }; + } + calEventResponses[field.name] = { + label, + value: responses[field.name], + }; + }); + } else { + // Alternative way to generate for a booking of whose eventType has been deleted + for (const [name, value] of Object.entries(responses)) { + const isSystemField = SystemField.safeParse(name); + + // Use name for Label because we don't have access to the label. This will not be needed once we start storing the label along with the response + const label = name; + + if (!isSystemField.success) { + calEventUserFieldsResponses[name] = { + label, + value, + }; + } + + calEventResponses[name] = { + label, + value, + }; + } + } + return { calEventUserFieldsResponses, calEventResponses }; +}; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2979663201..a8647a0ee6 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -32,6 +32,7 @@ import { sendScheduledSeatsEmails, } from "@calcom/emails"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager"; @@ -403,23 +404,9 @@ function getBookingData({ const reqBody = bookingDataSchema.parse(req.body); if ("responses" in reqBody) { const responses = reqBody.responses; - const calEventResponses = {} as NonNullable; - const calEventUserFieldsResponses = {} as NonNullable; - eventType.bookingFields.forEach((field) => { - const label = field.label || field.defaultLabel; - if (!label) { - throw new Error('Missing label for booking field "' + field.name + '"'); - } - if (field.editable === "user" || field.editable === "user-readonly") { - calEventUserFieldsResponses[field.name] = { - label, - value: responses[field.name], - }; - } - calEventResponses[field.name] = { - label, - value: responses[field.name], - }; + const { calEventUserFieldsResponses, calEventResponses } = getCalEventResponses({ + bookingFields: eventType.bookingFields, + responses, }); return { ...reqBody, diff --git a/packages/lib/getLabelValueMapFromResponses.ts b/packages/lib/getLabelValueMapFromResponses.ts index 124c8af35c..5897fc077c 100644 --- a/packages/lib/getLabelValueMapFromResponses.ts +++ b/packages/lib/getLabelValueMapFromResponses.ts @@ -1,11 +1,17 @@ +import type z from "zod"; + +import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import type { CalendarEvent } from "@calcom/types/Calendar"; export default function getLabelValueMapFromResponses(calEvent: CalendarEvent) { const { customInputs, userFieldsResponses } = calEvent; - let labelValueMap: Record = {}; + let labelValueMap: Record> = {}; if (userFieldsResponses) { for (const [, value] of Object.entries(userFieldsResponses)) { + if (!value.label) { + continue; + } labelValueMap[value.label] = value.value; } } else { diff --git a/packages/trpc/server/routers/viewer/bookings.tsx b/packages/trpc/server/routers/viewer/bookings.tsx index be7a92fd7f..ed8bd8d5e0 100644 --- a/packages/trpc/server/routers/viewer/bookings.tsx +++ b/packages/trpc/server/routers/viewer/bookings.tsx @@ -15,6 +15,9 @@ import dayjs from "@calcom/dayjs"; import { deleteScheduledEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager"; import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; import { sendDeclinedEmails, sendLocationChangeEmails, sendRequestRescheduleEmail } from "@calcom/emails"; +import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; +import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; +import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; @@ -717,7 +720,7 @@ export const bookingsRouter = router({ const tOrganizer = await getTranslation(user.locale ?? "en", "common"); - const booking = await prisma.booking.findUniqueOrThrow({ + const bookingRaw = await prisma.booking.findUniqueOrThrow({ where: { id: bookingId, }, @@ -729,6 +732,7 @@ export const bookingsRouter = router({ endTime: true, attendees: true, eventTypeId: true, + responses: true, eventType: { select: { id: true, @@ -741,6 +745,9 @@ export const bookingsRouter = router({ length: true, description: true, price: true, + bookingFields: true, + disableGuests: true, + metadata: true, workflows: { include: { workflow: { @@ -750,6 +757,7 @@ export const bookingsRouter = router({ }, }, }, + customInputs: true, }, }, location: true, @@ -765,6 +773,22 @@ export const bookingsRouter = router({ scheduledJobs: true, }, }); + + const bookingFields = bookingRaw.eventType + ? getBookingFieldsWithSystemFields(bookingRaw.eventType) + : null; + + const booking = { + ...bookingRaw, + responses: bookingResponsesDbSchema.parse(bookingRaw.responses), + eventType: bookingRaw.eventType + ? { + ...bookingRaw.eventType, + bookingFields, + } + : null, + }; + const authorized = async () => { // if the organizer if (booking.userId === user.id) { @@ -822,10 +846,18 @@ export const bookingsRouter = router({ const attendeesList = await Promise.all(attendeesListPromises); + // TODO: Remove the usage of `bookingFields` in computing responses. We can do that by storing `label` with the response. Also, this would allow us to correctly show the label for a field even after the Event Type has been deleted. + const { calEventUserFieldsResponses, calEventResponses } = getCalEventResponses({ + bookingFields: booking.eventType?.bookingFields ?? null, + responses: booking.responses, + }); + const evt: CalendarEvent = { type: booking.eventType?.title || booking.title, title: booking.title, description: booking.description, + responses: calEventResponses, + userFieldsResponses: calEventUserFieldsResponses, customInputs: isPrismaObjOrUndefined(booking.customInputs), startTime: booking.startTime.toISOString(), endTime: booking.endTime.toISOString(), diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 1e20cf0e8c..f97079faa8 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -3,7 +3,9 @@ import type { Dayjs } from "dayjs"; import type { calendar_v3 } from "googleapis"; import type { Time } from "ical.js"; import type { TFunction } from "next-i18next"; +import type z from "zod"; +import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import type { Calendar } from "@calcom/features/calendars/weeklyview"; import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { Frequency } from "@calcom/prisma/zod-utils"; @@ -129,6 +131,14 @@ export type AppsStatus = { warnings?: string[]; }; +type CalEventResponses = Record< + string, + { + label: string; + value: z.infer; + } +>; + // If modifying this interface, probably should update builders/calendarEvent files export interface CalendarEvent { type: string; @@ -164,22 +174,10 @@ export interface CalendarEvent { seatsPerTimeSlot?: number | null; // It has responses to all the fields(system + user) - responses?: Record< - string, - { - value: string | string[]; - label: string; - } - > | null; + responses?: CalEventResponses | null; // It just has responses to only the user fields. It allows to easily iterate over to show only user fields - userFieldsResponses?: Record< - string, - { - value: string | string[]; - label: string; - } - > | null; + userFieldsResponses?: CalEventResponses | null; } export interface EntryPoint {