From e83c83d951eb2ebe28e0bb3d4bbe084547556380 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Mon, 6 Feb 2023 09:41:43 +0530 Subject: [PATCH] Implement Add Guests and other fixes --- .../components/booking/pages/BookingPage.tsx | 70 +++-------- .../components/eventtype/EventAdvancedTab.tsx | 1 + apps/web/pages/booking/[uid].tsx | 1 + apps/web/pages/event-types/[type]/index.tsx | 5 +- .../embeds/embed-core/src/embed-iframe.ts | 3 +- .../bookings/lib/getBookingResponsesSchema.ts | 67 ++++++---- .../features/bookings/lib/handleNewBooking.ts | 2 +- packages/features/form-builder/Components.tsx | 115 ++++++++++++++++-- .../features/form-builder/FormBuilder.tsx | 91 ++++++++++---- packages/lib/defaultEvents.ts | 16 ++- packages/lib/getEventTypeById.ts | 39 ++++-- packages/prisma/zod-utils.ts | 4 +- 12 files changed, 287 insertions(+), 127 deletions(-) diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index bbb201357a..91a7090653 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -33,25 +33,8 @@ import useTheme from "@calcom/lib/hooks/useTheme"; import { HttpError } from "@calcom/lib/http-error"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; -import { - AddressInput, - Button, - EmailField, - EmailInput, - Form, - Group, - PhoneInput, - RadioField, - Tooltip, -} from "@calcom/ui"; -import { - FiUserPlus, - FiCalendar, - FiCreditCard, - FiRefreshCw, - FiUser, - FiAlertTriangle, -} from "@calcom/ui/components/icon"; +import { Button, Form, Tooltip } from "@calcom/ui"; +import { FiCalendar, FiCreditCard, FiRefreshCw, FiUser, FiAlertTriangle } from "@calcom/ui/components/icon"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; @@ -87,6 +70,7 @@ const BookingFields = ({ if (field.hidden) return null; let readOnly = (field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid; + //TODO: `rescheduleReason` should be an enum or similar to avoid typos if (field.name === "rescheduleReason") { if (!rescheduleUid) { return null; @@ -120,6 +104,8 @@ const BookingFields = ({ ); } + field.label = t(field.label); + return ; })} @@ -222,19 +208,26 @@ const BookingPage = ({ const rescheduleUid = router.query.rescheduleUid as string; useTheme(profile.theme); const date = asStringOrNull(router.query.date); + const querySchema = getBookingResponsesSchema( + { + bookingFields: eventType.bookingFields, + }, + true + ); + // string value for - text, textarea, select, radio, + // string value with , for checkbox and multiselect + // Object {value:"", optionValue:""} for radioInput + const parsedQuery = querySchema.parse({ + ...router.query, + guests: router.query.guest, + }); - const [guestToggle, setGuestToggle] = useState(booking && booking.attendees.length > 1); // it would be nice if Prisma at some point in the future allowed for Json; as of now this is not the case. const locations: LocationObject[] = useMemo( () => (eventType.locations as LocationObject[]) || [], [eventType.locations] ); - useEffect(() => { - if (router.query.guest) { - setGuestToggle(true); - } - }, [router.query.guest]); const [isClientTimezoneAvailable, setIsClientTimezoneAvailable] = useState(false); useEffect(() => { // THis is to fix hydration error that comes because of different timezone on server and client @@ -242,24 +235,9 @@ const BookingPage = ({ }, []); const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id; - const guestListEmails = !isDynamicGroupBooking - ? booking?.attendees.slice(1).map((attendee) => { - return { email: attendee.email }; - }) - : []; - //FIXME: We need to be backward compatible in terms of pre-filling the form const getFormBuilderFieldValueFromQuery = (paramName: string) => { - const schema = getBookingResponsesSchema( - { - bookingFields: eventType.bookingFields, - }, - true - ); - // string value for - text, textarea, select, radio, - // string value with , for checkbox and multiselect - // Object {value:"", optionValue:""} for radioInput - return schema.parse(router.query)[paramName]; + return parsedQuery[paramName]; }; // There should only exists one default userData variable for primaryAttendee. @@ -323,17 +301,7 @@ const BookingPage = ({ .passthrough(); type BookingFormValues = { - name: string; - email: string; - notes?: string; locationType?: EventLocationType["type"]; - guests?: string[]; - address?: string; - attendeeAddress?: string; - phone?: string; - hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers - rescheduleReason?: string; - smsReminderNumber?: string; responses: z.infer["responses"]; }; diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index db725cadea..eb959f58d3 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -41,6 +41,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick(); const { t } = useLocale(); + const [showEventNameTip, setShowEventNameTip] = useState(false); const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl); diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 825547b8cc..c8420f91fc 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -1050,6 +1050,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { notFound: true, }; } + const bookingInfo = { ...bookingInfoRaw, responses: getBookingResponsesSchema(eventTypeRaw).parse(bookingInfoRaw.responses), diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 4044af0fb5..22319616d5 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -190,7 +190,10 @@ const EventTypePage = (props: EventTypeSetupProps) => { startDate: periodDates.startDate, endDate: periodDates.endDate, }, - bookingFields: eventType.bookingFields, + bookingFields: eventType.bookingFields.map((field) => ({ + ...field, + label: t(field.label), + })), periodType: eventType.periodType, periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0", schedulingType: eventType.schedulingType, diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 24e4dd017e..fd64824026 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -231,7 +231,8 @@ export const useIsEmbed = (embedSsr?: boolean) => { const _isValidNamespace = isValidNamespace(namespace); if (parent !== window && !_isValidNamespace) { log( - "Looks like you have iframed cal.com but not using Embed Snippet. Directly using an iframe isn't recommended." + `Looks like you have iframed cal.com but not using Embed Snippet. + Directly using an iframe isn't recommended.` ); } setIsEmbed(window?.isEmbed?.() || false); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 00d231b592..8614a376f7 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -5,11 +5,13 @@ import { bookingResponses, eventTypeBookingFields } from "@calcom/prisma/zod-uti export default function getBookingResponsesSchema( eventType: { - bookingFields: z.infer; + bookingFields: z.infer & + z.infer & + z.BRAND<"HAS_SYSTEM_FIELDS">; }, - partial = false + forgiving = false ) { - const schema = partial + const schema = forgiving ? bookingResponses.partial().and(z.record(z.any())) : bookingResponses.and(z.record(z.any())); @@ -32,53 +34,64 @@ export default function getBookingResponsesSchema( }); return newResponses; }, - schema.superRefine((response, ctx) => { - eventType.bookingFields.forEach((input) => { - const value = response[input.name]; + schema.superRefine((responses, ctx) => { + eventType.bookingFields.forEach((bookingField) => { + const value = responses[bookingField.name]; + const emailSchema = forgiving ? z.string() : z.string().email(); + const phoneSchema = forgiving ? z.string() : z.string().refine((val) => isValidPhoneNumber(val)); // Tag the message with the input name so that the message can be shown at appropriate plae - const m = (message: string) => `{${input.name}}${message}`; - if ((partial || !input.required) && value === undefined) { + const m = (message: string) => `{${bookingField.name}}${message}`; + if ((forgiving || !bookingField.required) && value === undefined) { return; } - if (input.required && !partial && !value) + if (bookingField.required && !forgiving && !value) ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`Required`) }); - if (input.type === "email") { + if (bookingField.type === "email") { // Email RegExp to validate if the input is a valid email - if (!z.string().email().safeParse(value).success) + if (!emailSchema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, //TODO: How to do translation in booker language here? message: m("That doesn't look like an email address"), }); + return; + } + } + if (bookingField.type === "multiemail") { + if (!emailSchema.array().safeParse(value).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + //TODO: How to do translation in booker language here? + message: m("That doesn't look like an email address"), + }); + return; + } + return; } - if (input.type === "phone") { - if ( - !z - .string() - .refine((val) => isValidPhoneNumber(val)) - .optional() - .nullable() - .safeParse(value).success - ) { + if (bookingField.type === "phone") { + if (!phoneSchema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Phone") }); } } - if (input.type === "boolean") { - const schema = z.preprocess((val) => { - return val === "true"; - }, z.boolean()); + if (bookingField.type === "boolean") { + const schema = z.boolean(); if (!schema.safeParse(value).success) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") }); } } - if (input.type === "radioInput" && input.optionsInputs) { + + if (bookingField.type === "radioInput" && bookingField.optionsInputs) { //FIXME: ManageBookings: If there is just one option then it is not required to show the radio options // Also, if the option is there with one input, we need to show just the input and not radio - if (input.required && input.optionsInputs[value?.value]?.required && !value?.optionValue) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Required") }); + if ( + bookingField.required && + bookingField.optionsInputs[value?.value]?.required && + !value?.optionValue + ) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Required Option Value") }); } } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index afe8f27e8c..0a4a0cecbe 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -328,7 +328,7 @@ function getBookingData({ ...reqBody, name: responses.name, email: responses.email, - guests: responses.guests ? responses.guests.split(",") : [], + guests: responses.guests ? responses.guests : [], location: responses.location?.optionValue || responses.location?.value || "", smsReminderNumber: responses.smsReminderNumber, notes: responses.notes, diff --git a/packages/features/form-builder/Components.tsx b/packages/features/form-builder/Components.tsx index 5ad43fd733..7f2bdc4b1b 100644 --- a/packages/features/form-builder/Components.tsx +++ b/packages/features/form-builder/Components.tsx @@ -1,11 +1,14 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { z } from "zod"; import Widgets, { TextLikeComponentProps, SelectLikeComponentProps, } from "@calcom/app-store/ee/routing-forms/components/react-awesome-query-builder/widgets"; -import { PhoneInput, AddressInput, Label, Group, RadioField } from "@calcom/ui"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { PhoneInput, AddressInput, Button, Label, Group, RadioField, EmailField, Tooltip } from "@calcom/ui"; +import { FiInfo, FiUserPlus, FiX } from "@calcom/ui/components/icon"; import { ComponentForField } from "./FormBuilder"; import { fieldsSchema } from "./FormBuilderFieldsSchema"; @@ -71,12 +74,110 @@ export const Components = { }, }, multiemail: { - propsType: "text", - factory: (props: TProps) => { - //TODO: ManageBookings: Make it use multiemail - return ; + propsType: "textList", + factory: >({ + value, + label, + setValue, + ...props + }: TProps) => { + const placeholder = props.placeholder; + const { t } = useLocale(); + value = value || []; + const inputClassName = + "dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500"; + return ( + <> + {value.length ? ( +
+
+ +
    + {value.map((field, index) => ( +
  • + { + value[index] = e.target.value; + setValue(value); + }} + className={classNames( + inputClassName, + // bookingForm.formState.errors.guests?.[index] && + // "!focus:ring-red-700 !border-red-700", + "border-r-0" + )} + addOnClassname={classNames( + "border-gray-300 border block border-l-0 disabled:bg-gray-200 disabled:hover:cursor-not-allowed bg-transparent disabled:text-gray-500 dark:border-darkgray-300 " + // bookingForm.formState.errors.guests?.[index] && + // "!focus:ring-red-700 !border-red-700" + )} + placeholder={placeholder} + label={<>} + required + addOnSuffix={ + + + + } + /> + {/* {bookingForm.formState.errors.guests?.[index] && ( +
    + +

    + {bookingForm.formState.errors.guests?.[index]?.message} +

    +
    + )} */} +
  • + ))} +
+ +
+
+ ) : ( + <> + )} + + {!value.length && ( + + )} + + ); }, - valuePlaceholder: "Enter Email Addresses", }, multiselect: { propsType: "multiselect", diff --git a/packages/features/form-builder/FormBuilder.tsx b/packages/features/form-builder/FormBuilder.tsx index a79bc4aed6..5d0a24d307 100644 --- a/packages/features/form-builder/FormBuilder.tsx +++ b/packages/features/form-builder/FormBuilder.tsx @@ -33,6 +33,10 @@ type RhfForm = { type RhfFormFields = RhfForm["fields"]; type RhfFormField = RhfFormFields[number]; +/** + * It works with a react-hook-form only. + * `formProp` specifies the name of the property in the react-hook-form that has the fields. This is where fields would be updated. + */ export const FormBuilder = function FormBuilder({ title, description, @@ -49,62 +53,76 @@ export const FormBuilder = function FormBuilder({ label: string; needsOptions?: boolean; systemOnly?: boolean; + isTextType?: boolean; }[] = [ { label: "Name", value: "name", + isTextType: true, }, { label: "Email", value: "email", + isTextType: true, }, { label: "Phone", value: "phone", + isTextType: true, }, { label: "Short Text", value: "text", + isTextType: true, }, { label: "Number", value: "number", + isTextType: true, }, { label: "Long Text", value: "textarea", + isTextType: true, }, { label: "Select", value: "select", needsOptions: true, + isTextType: true, }, { label: "MultiSelect", value: "multiselect", needsOptions: true, + isTextType: false, }, { label: "Multiple Emails", value: "multiemail", + isTextType: true, }, { label: "Radio Input", value: "radioInput", + isTextType: false, }, { label: "Checkbox Group", value: "checkbox", needsOptions: true, + isTextType: false, }, { label: "Radio Group", value: "radio", needsOptions: true, + isTextType: false, }, { label: "Checkbox", value: "boolean", + isTextType: false, }, ]; // I would have like to give Form Builder it's own Form but nested Forms aren't something that browsers support. @@ -143,7 +161,6 @@ export const FormBuilder = function FormBuilder({ value: "2", }, ]; - // const [optionsState, setOptionsState] = useState(options); return (
@@ -363,7 +380,6 @@ export const FormBuilder = function FormBuilder({ options={FieldTypes.filter((f) => !f.systemOnly)} label="Input Type" /> - + + {fieldType?.isTextType ? ( + + ) : null} + {fieldType?.needsOptions ? ( { - const { t } = useLocale(); return (
-
- {field.type !== "boolean" && } - {!readOnly && !field.required && ( - - {t("optional")} - - )} -
+ {field.type !== "boolean" && field.type !== "multiemail" && ( +
+ + + {!readOnly && field.required ? "*" : ""} + +
+ )} {children}
); @@ -463,21 +487,16 @@ export const ComponentForField = ({ setValue, readOnly, }: { - field: { + field: RhfFormField & { // Label is optional because radioInput doesn't have a label label?: string; - required?: boolean; - name?: string; - options?: RhfFormField["options"]; - optionsInputs?: RhfFormField["optionsInputs"]; - type: RhfFormField["type"]; }; readOnly: boolean; } & ValueProps) => { const fieldType = field.type; const componentConfig = Components[fieldType]; const isObjectiveWithInputValue = (value: any): value is { value: string } => { - return typeof value === "object" ? "value" in value : false; + return typeof value === "object" && value !== null ? "value" in value : false; }; if (componentConfig.propsType === "text" || componentConfig.propsType === "boolean") { @@ -499,6 +518,7 @@ export const ComponentForField = ({ readOnly={readOnly} value={value} setValue={setValue} + placeholder={field.placeholder} /> ); @@ -507,9 +527,33 @@ export const ComponentForField = ({ if (value !== undefined && typeof value !== "string") { throw new Error(`${value}: Value is not of type string for field ${field.name}`); } + return ( - + + + ); + } + + if (componentConfig.propsType === "textList") { + if (value !== undefined && !(value instanceof Array)) { + throw new Error(`${value}: Value is not of type array for ${field.name}`); + } + return ( + + ); } @@ -527,6 +571,7 @@ export const ComponentForField = ({ ({ ...o, title: o.label }))} /> @@ -544,6 +589,7 @@ export const ComponentForField = ({ return ( void} @@ -566,6 +612,7 @@ export const ComponentForField = ({ return field.options.length ? ( +
{ + setValue={(val: unknown) => { onChange(val); }} /> @@ -620,7 +666,6 @@ export const FormBuilderField = ({ if (name !== field.name) { return null; } - // console.error(name, field.name, message, "ErrorMesg"); message = message.replace(/\{[^}]+\}(.*)/, "$1"); return ( diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index e60c7fca8f..7534ae7c71 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -2,7 +2,14 @@ import { PeriodType, Prisma, SchedulingType } from "@prisma/client"; import { DailyLocationType } from "@calcom/app-store/locations"; import { userSelect } from "@calcom/prisma/selects"; -import { CustomInputSchema, EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import { + CustomInputSchema, + EventTypeMetaDataSchema, + customInputSchema, + eventTypeBookingFields, +} from "@calcom/prisma/zod-utils"; + +import { ensureBookingInputsHaveSystemFields } from "./getEventTypeById"; type User = Prisma.UserGetPayload; @@ -87,7 +94,12 @@ const commons = { users: [user], hosts: [], metadata: EventTypeMetaDataSchema.parse({}), - bookingFields: eventTypeBookingFields.parse([]), + bookingFields: ensureBookingInputsHaveSystemFields({ + bookingFields: eventTypeBookingFields.parse([]), + disableGuests: true, + additionalNotesRequired: false, + customInputs: customInputSchema.array().parse([]), + }), }; const min15Event = { diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 2d88475c73..fd0db78df7 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -49,9 +49,10 @@ export const ensureBookingInputsHaveSystemFields = ({ [EventTypeCustomInputType.PHONE]: BookingFieldType.phone, }; - const systemFields: typeof bookingFields = [ + // These fields should be added before other user fields + const systemBeforeFields: typeof bookingFields = [ { - label: "Your name", + label: "your_name", type: "name", name: "name", required: true, @@ -65,7 +66,7 @@ export const ensureBookingInputsHaveSystemFields = ({ ], }, { - label: "Your email", + label: "email_address", type: "email", name: "email", required: true, @@ -79,7 +80,7 @@ export const ensureBookingInputsHaveSystemFields = ({ ], }, { - label: "Location", + label: "location", type: "radioInput", name: "location", editable: "system", @@ -106,8 +107,12 @@ export const ensureBookingInputsHaveSystemFields = ({ }, ], }, + ]; + + // These fields should be added after other user fields + const systemAfterFields: typeof bookingFields = [ { - label: "Additional Notes", + label: "additional_notes", type: "textarea", name: "notes", editable: "system-but-optional", @@ -121,7 +126,7 @@ export const ensureBookingInputsHaveSystemFields = ({ ], }, { - label: "Add guests", + label: "additional_guests", type: "multiemail", name: "guests", editable: "system-but-optional", @@ -137,7 +142,7 @@ export const ensureBookingInputsHaveSystemFields = ({ }, { //TODO: How to translate in user language? - label: "Reschedule Reason", + label: "reschedule_reason", type: "textarea", name: "rescheduleReason", editable: "system", @@ -154,16 +159,16 @@ export const ensureBookingInputsHaveSystemFields = ({ }, ]; - const missingSystemFields = []; - // Push system fields first if any of them don't exist. We can't simply live without system fields - for (const field of systemFields) { + const missingSystemBeforeFields = []; + for (const field of systemBeforeFields) { // Only do a push, we must not update existing system fields as user could have modified any property in it, if (!bookingFields.find((f) => f.name === field.name)) { - missingSystemFields.push(field); + missingSystemBeforeFields.push(field); } } - bookingFields = missingSystemFields.concat(bookingFields); + bookingFields = missingSystemBeforeFields.concat(bookingFields); + // If we are migrating from old system, we need to add custom inputs to the end of the list if (handleMigration) { customInputs.forEach((input) => { @@ -188,6 +193,16 @@ export const ensureBookingInputsHaveSystemFields = ({ }); } + const missingSystemAfterFields = []; + for (const field of systemAfterFields) { + // Only do a push, we must not update existing system fields as user could have modified any property in it, + if (!bookingFields.find((f) => f.name === field.name)) { + missingSystemAfterFields.push(field); + } + } + + bookingFields = bookingFields.concat(missingSystemAfterFields); + return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields); }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index dfc8f43521..6263ca709f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -55,11 +55,11 @@ export const EventTypeMetaDataSchema = z export const eventTypeBookingFields = formBuilderFieldsSchema; -// Real validation happens using getBookingResponsesSchema which requires eventType. Is there a better way to do it? +// Validation of user added bookingFields's responses happen using getBookingResponsesSchema which requires eventType. export const bookingResponses = z.object({ email: z.string(), name: z.string(), - guests: z.string().optional(), + guests: z.array(z.string()).optional(), notes: z.string().optional(), location: z .object({