import type { ALL_VIEWS } from "@calcom/features/form-builder/schema"; import { type FieldZodCtx, fieldTypesSchemaMap } from "@calcom/features/form-builder/schema"; import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema"; import logger from "@calcom/lib/logger"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils"; import { isValidPhoneNumber } from "libphonenumber-js/max"; import z from "zod"; type View = ALL_VIEWS | (string & {}); type BookingFields = (z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">) | null; type TranslationFunction = (key: string, options?: Record) => string; type CommonParams = { bookingFields: BookingFields; view: View; translateFn?: TranslationFunction }; export const bookingResponse = dbReadResponseSchema; export const bookingResponsesDbSchema = z.record(dbReadResponseSchema); const catchAllSchema = bookingResponsesDbSchema; const ensureValidPhoneNumber = (value: string) => { if (!value) return ""; // + in URL could be replaced with space, so we need to replace it back // Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only return value.replace(/^ +/, "+"); }; /** * Processes a single field's response value based on its type. * Returns the processed value that should be stored in newResponses[field.name]. */ function preprocessField({ field, value, isPartialSchema, log, }: { field: NonNullable[number]; value: unknown; isPartialSchema: boolean; log: ReturnType; }): unknown { const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap]; // TODO: Move all the schemas along with their respective types to fieldTypeSchema, that would make schemas shared across Routing Forms builder and Booking Question Formm builder if (fieldTypeSchema) { return fieldTypeSchema.preprocess({ response: value, isPartialSchema, field, }); } if (field.type === "boolean") { // Turn a boolean in string to a real boolean return value === "true" || value === true; } // Make sure that the value is an array else if (field.type === "multiemail" || field.type === "checkbox" || field.type === "multiselect") { return value instanceof Array ? value : [value]; } // Parse JSON else if (field.type === "radioInput" && typeof value === "string") { let parsedValue = { optionValue: "", value: "", }; try { parsedValue = JSON.parse(value); } catch (e) { log.error(`Failed to parse JSON for field ${field.name}`, e); } const optionsInputs = field.optionsInputs; const optionInputField = optionsInputs?.[parsedValue.value]; if (optionInputField && optionInputField.type === "phone") { parsedValue.optionValue = ensureValidPhoneNumber(parsedValue.optionValue); } return parsedValue; } else if (field.type === "phone") { return ensureValidPhoneNumber(typeof value === "string" ? value : String(value)); } else { return value; } } /** * Runs superRefine validation for a field's response value based on its type. * Handles all field type validations including email, phone, multiselect, etc. * Throws on configuration errors (e.g., invalid variant) - caller should wrap in try-catch for partial schemas. */ async function superRefineField({ field, value, isPartialSchema, isRequired, checkOptional, zodCtx, translateFn, responses, }: { field: NonNullable[number]; value: unknown; isPartialSchema: boolean; isRequired: boolean; checkOptional: boolean; zodCtx: FieldZodCtx; translateFn?: TranslationFunction; responses: Record; }): Promise { const stringSchema = z.string(); const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement); const phoneSchema = isPartialSchema ? z.string() : z.string().refine(async (val) => { return isValidPhoneNumber(val); }); // Tag the message with the input name so that the message can be shown at appropriate place const m = (message: string, options?: Record) => { const translatedMessage = translateFn ? translateFn(message, options) : message; return `{${field.name}}${translatedMessage}`; }; if (isRequired && !isPartialSchema && !value) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); return; } if (field.type === "email") { if (!field.hidden && (isRequired || (value && String(value).trim() !== ""))) { // Email RegExp to validate if the input is a valid email if (!emailSchema.safeParse(value).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("email_validation_error"), }); } if (value) { const bookerEmail = String(value); const excludedEmails = field.excludeEmails?.split(",").map((domain) => domain.trim()) || []; const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry)); if (match) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("exclude_emails_match_found_error_message"), }); } const requiredEmails = field.requireEmails ?.split(",") .map((domain) => domain.trim()) .filter(Boolean) || []; const requiredEmailsMatch = requiredEmails.find((requiredEntry) => doesEmailMatchEntry(bookerEmail, requiredEntry) ); if (requiredEmails.length > 0 && !requiredEmailsMatch) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("require_emails_no_match_found_error_message"), }); } } } return; } const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap]; if (fieldTypeSchema) { fieldTypeSchema.superRefine({ // We use `unknown` here because the response type is not trivial to know here // We know for sure that the value here is preprocessed(considering how we have called z.preprocess()) // and thus the fieldTypeSchema implementation could rely on having the correct type as per its preprocess fn return value response: value as unknown as any, ctx: zodCtx, m, field, isPartialSchema, }); return; } if (field.type === "multiemail") { const emailsParsed = emailSchema.array().safeParse(value); if (isRequired && (!value || (value as unknown[]).length === 0)) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); return; } if (!emailsParsed.success) { // If additional guests are shown but all inputs are empty then don't show any errors if (field.name === "guests" && (value as string[]).every((email: string) => email === "")) { // reset guests to empty array, otherwise it adds "" for every input responses[field.name] = []; return; } zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("email_validation_error"), }); return; } const emails = emailsParsed.data; emails.sort().some((item, i) => { if (item === emails[i + 1]) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("duplicate_email") }); return true; } }); return; } if (field.type === "multiselect") { if (isRequired && (!value || (value as unknown[]).length === 0)) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); return; } if (!stringSchema.array().safeParse(value).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") }); } return; } if (field.type === "checkbox") { if (!stringSchema.array().safeParse(value).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") }); } return; } if (field.type === "phone") { // Determine if the phone field needs validation const needsValidation = isRequired || (value && (value as string).trim() !== ""); // Validate phone number if the field is not hidden and requires validation if (!field.hidden && needsValidation) { if (!(await phoneSchema.safeParseAsync(value)).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") }); } } return; } if (field.type === "boolean") { const schema = z.boolean(); if (!schema.safeParse(value).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") }); } return; } if (field.type === "radioInput") { if (field.optionsInputs) { const typedValue = value as { optionValue?: string; value?: string } | undefined; const optionValue = typedValue?.optionValue; const optionField = field.optionsInputs[typedValue?.value ?? ""]; const typeOfOptionInput = optionField?.type; if ( // Either the field is required or there is a radio selected, we need to check if the optionInput is required or not. (isRequired || typedValue?.value) && checkOptional ? true : optionField?.required && !optionValue ) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") }); return; } if (optionValue) { // `typeOfOptionInput` can be any of the main types. So, the same validations should run for `optionValue` if (typeOfOptionInput === "phone") { if (!(await phoneSchema.safeParseAsync(optionValue)).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") }); } } } } return; } // Use fieldTypeConfig.propsType to validate for propsType=="text" or propsType=="select" as in those cases, the response would be a string. // If say we want to do special validation for 'address' that can be added to `fieldTypesSchemaMap` if (["address", "text", "select", "number", "radio", "textarea"].includes(field.type)) { const schema = stringSchema; if (!schema.safeParse(value).success) { zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") }); } return; } zodCtx.addIssue({ code: z.ZodIssueCode.custom, message: `Can't parse unknown booking field type: ${field.type}`, }); } /** * Checks if a booker email matches an email/domain entry. * Supports three formats: * - Full email: "user@example.com" - matches exactly * - Domain with @ prefix: "@example.com" - matches any email ending with "@example.com" * - Domain without @ prefix: "example.com" - matches any email ending with "@example.com" */ const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => { const bookerEmailLower = bookerEmail.toLowerCase(); if (entry.startsWith("@")) { const domain = entry.slice(1).toLowerCase(); return bookerEmailLower.endsWith("@" + domain); } if (entry.includes("@")) { return bookerEmailLower === entry.toLowerCase(); } return bookerEmailLower.endsWith("@" + entry.toLowerCase()); }; export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => { const schema = bookingResponses.unwrap().partial().and(catchAllSchema); return preprocess({ schema, bookingFields, isPartialSchema: true, view, translateFn }); }; // Should be used when we know that not all fields responses are present // - Can happen when we are parsing the prefill query string // - Can happen when we are parsing a booking's responses (which was created before we added a new required field) export default function getBookingResponsesSchema({ bookingFields, view, translateFn }: CommonParams) { const schema = bookingResponses.and(z.record(z.any())); return preprocess({ schema, bookingFields, isPartialSchema: false, view, translateFn }); } // Should be used when we want to check if the optional fields are entered and valid as well export function getBookingResponsesSchemaWithOptionalChecks({ bookingFields, view, translateFn, }: CommonParams) { const schema = bookingResponses.and(z.record(z.any())); return preprocess({ schema, bookingFields, isPartialSchema: false, view, checkOptional: true, translateFn, }); } type FieldZodCtxState = { issues: z.IssueData[]; } | null; const buildFieldZodCtx = ({ zodCtx, isPartialSchema, }: { zodCtx: z.RefinementCtx; isPartialSchema: boolean; }): { fieldZodCtx: FieldZodCtx; state: FieldZodCtxState; } => { if (isPartialSchema) { const state: FieldZodCtxState = { issues: [], }; return { fieldZodCtx: { addIssue: (issue: z.IssueData) => { state.issues.push(issue); }, }, state, }; } return { fieldZodCtx: zodCtx, state: null, }; }; // TODO: Move preprocess of `booking.responses` to FormBuilder schema as that is going to parse the fields supported by FormBuilder // It allows anyone using FormBuilder to get the same preprocessing automatically function preprocess({ schema, bookingFields, isPartialSchema, view: currentView, checkOptional = false, translateFn, }: CommonParams & { 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; checkOptional?: boolean; }): z.ZodType, z.infer, z.infer> { const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] }); const preprocessed = z.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 (!bookingFields) return parsedResponses; bookingFields.forEach((field) => { const value = parsedResponses[field.name]; if (value === undefined) { // If there is no response for the field, then we don't need to do any processing return; } const views = field.views; const isFieldApplicableToCurrentView = currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true; if (!isFieldApplicableToCurrentView) { // If the field is not applicable in the current view, then we don't need to do any processing return; } try { newResponses[field.name] = preprocessField({ field, value, isPartialSchema, log }); } catch (e) { if (!isPartialSchema) { throw e; } const errorMessage = e instanceof Error ? e.message : "preprocessing failed"; const invalidFieldName = field.name; // Remove invalid field like it never existed in the first place delete parsedResponses[invalidFieldName]; console.warn(`Skipped invalid field during preprocessing: ${invalidFieldName} (${errorMessage})`); } }); return { ...parsedResponses, ...newResponses, }; }, schema.superRefine(async (responses, ctx) => { if (!bookingFields) { // if eventType has been deleted, we won't have bookingFields and thus we can't validate the responses. return; } const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber"); const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden; const emailField = bookingFields.find((field) => field.name === "email"); const isEmailFieldHidden = !!emailField?.hidden; // To prevent using user's session email as attendee's email, we set email to empty string if (isEmailFieldHidden && !isAttendeePhoneNumberFieldHidden) { responses["email"] = ""; } for (const bookingField of bookingFields) { const value = responses[bookingField.name]; const views = bookingField.views; const isFieldApplicableToCurrentView = currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true; let hidden = bookingField.hidden; const numOptions = bookingField.options?.length ?? 0; if (bookingField.hideWhenJustOneOption) { hidden = hidden || numOptions <= 1; } let isRequired = false; // If the field is hidden, then it can never be required if (!hidden && isFieldApplicableToCurrentView) { isRequired = checkOptional || !!bookingField.required; } if ((isPartialSchema || !isRequired) && value === undefined) { continue; } // For partial schemas, use a proxy ctx to capture issues const { fieldZodCtx, state } = buildFieldZodCtx({ zodCtx: ctx, isPartialSchema, }); let superRefineError = false; try { await superRefineField({ field: bookingField, value, isPartialSchema, isRequired, checkOptional, zodCtx: fieldZodCtx, translateFn, responses, }); } catch (e) { if (!isPartialSchema) { throw e; } superRefineError = true; } // For partial schemas, remove invalid fields from responses const issues = state?.issues ?? []; if (isPartialSchema && (superRefineError || issues.length > 0)) { delete responses[bookingField.name]; console.warn( `Partial prefill: skipped field '${bookingField.name}' due to ${issues.length} validation error(s)` ); } } }) ); if (isPartialSchema) { // Query Params can be completely invalid, try to preprocess as much of it in correct format but in worst case simply don't prefill instead of crashing return preprocessed.catch((res?: { error?: unknown[] }) => { console.error("Failed to validate query params, prefilling will be skipped entirely", res?.error); return {}; }); } return preprocessed; }