diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index aab886487c..074aa94552 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -920,3 +920,56 @@ test.describe("Past booking cancellation", () => { await expect(page.locator('[data-testid="cancel"]')).toBeHidden(); }); }); + +test.describe("Optional Email Field Validation", () => { + test("should validate format if input is provided, but allow empty submission", async ({ page, users }) => { + + const user = await users.create({ + eventTypes: [ + { + title: "Test Event", + slug: "test-event", + length: 30, + bookingFields: [ + { name: "name", type: "name", required: true }, + { name: "email", type: "email", required: true }, + { + name: "altEmail", + type: "email", + required: false, + label: "Alternate Email" + }, + ], + }, + ], + }); + + await page.goto(`/${user.username}/test-event`); + await selectFirstAvailableTimeSlotNextMonth(page); + + await page.locator('input[name="name"]').fill('Test Booker'); + await page.locator('input[name="email"]').fill('booker@example.com'); + + const optionalField = page.getByLabel(/Alternate Email/i); + + // Visibility + await expect(optionalField).toBeVisible(); + + // Trigger validation error + await optionalField.fill('not-an-email'); + await page.click('[data-testid="confirm-book-button"]'); + + // Verify form didn't submit + await expect(page).not.toHaveURL(/.*isSuccessBookingPage=true.*/); + + // Verify empty submission is allowed + await optionalField.fill(''); + + // Click the button to submit + await page.click('[data-testid="confirm-book-button"]'); + + // Verify it reached the end + await expect(page).toHaveURL(/.*isSuccessBookingPage=true.*/, { timeout: 15000 }); + await expect(page.getByTestId('success-page')).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts index d3e820aacd..dde596199f 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts @@ -1854,4 +1854,73 @@ describe("require email/domain validation", () => { }) ); }); + + test(`optional email field should fail if invalid input is provided`, async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "optionalEmail", + type: "email", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "John Doe", + email: "john@example.com", + optionalEmail: "not-a-valid-email", + }); + + expect(parsedResponses.success).toBe(false); + if (!parsedResponses.success) { + expect(parsedResponses.error.issues[0]).toEqual( + expect.objectContaining({ + message: `{optionalEmail}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`, + }) + ); + } + }); + + test(`optional email field should pass if left empty`, async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "optionalEmail", + type: "email", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "John Doe", + email: "john@example.com", + optionalEmail: "", + }); + + expect(parsedResponses.success).toBe(true); + }); }); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 41918948bf..d9fff69530 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -6,12 +6,24 @@ import { 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 { + bookingResponses, + emailSchemaRefinement, +} from "@calcom/prisma/zod-utils"; 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 }; +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); @@ -46,18 +58,38 @@ const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => { return bookerEmailLower.endsWith("@" + entry.toLowerCase()); }; -export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => { +export const getBookingResponsesPartialSchema = ({ + bookingFields, + view, + translateFn, +}: CommonParams) => { const schema = bookingResponses.unwrap().partial().and(catchAllSchema); - return preprocess({ schema, bookingFields, isPartialSchema: true, view, translateFn }); + 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) { +export default function getBookingResponsesSchema({ + bookingFields, + view, + translateFn, +}: CommonParams) { const schema = bookingResponses.and(z.record(z.any())); - return preprocess({ schema, bookingFields, isPartialSchema: false, view, translateFn }); + 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 @@ -97,7 +129,8 @@ function preprocess({ const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] }); const preprocessed = z.preprocess( (responses) => { - const parsedResponses = z.record(z.any()).nullable().parse(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; @@ -109,12 +142,17 @@ function preprocess({ } const views = field.views; const isFieldApplicableToCurrentView = - currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true; + 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; } - const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap]; + 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) { newResponses[field.name] = fieldTypeSchema.preprocess({ @@ -129,7 +167,11 @@ function preprocess({ newResponses[field.name] = value === "true" || value === true; } // Make sure that the value is an array - else if (field.type === "multiemail" || field.type === "checkbox" || field.type === "multiselect") { + else if ( + field.type === "multiemail" || + field.type === "checkbox" || + field.type === "multiselect" + ) { newResponses[field.name] = value instanceof Array ? value : [value]; } // Parse JSON @@ -141,12 +183,17 @@ function preprocess({ try { parsedValue = JSON.parse(value); } catch (e) { - log.error(`Failed to parse JSON for field ${field.name}: ${value}`, 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); + parsedValue.optionValue = ensureValidPhoneNumber( + parsedValue.optionValue + ); } newResponses[field.name] = parsedValue; } else if (field.type === "phone") { @@ -167,7 +214,9 @@ function preprocess({ return; } - const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber"); + const attendeePhoneNumberField = bookingFields.find( + (field) => field.name === "attendeePhoneNumber" + ); const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden; const emailField = bookingFields.find((field) => field.name === "email"); @@ -181,7 +230,9 @@ function preprocess({ for (const bookingField of bookingFields) { const value = responses[bookingField.name]; const stringSchema = z.string(); - const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement); + const emailSchema = isPartialSchema + ? z.string() + : z.string().refine(emailSchemaRefinement); const phoneSchema = isPartialSchema ? z.string() : z.string().refine(async (val) => { @@ -189,12 +240,18 @@ function preprocess({ }); // 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; + const translatedMessage = translateFn + ? translateFn(message, options) + : message; return `{${bookingField.name}}${translatedMessage}`; }; const views = bookingField.views; const isFieldApplicableToCurrentView = - currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true; + 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) { @@ -211,12 +268,18 @@ function preprocess({ } if (isRequired && !isPartialSchema && !value) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m(`error_required_field`), + }); return; } if (bookingField.type === "email") { - if (!bookingField.hidden && (checkOptional || bookingField.required)) { + if ( + !bookingField.hidden && + (isRequired || (value && value.trim() !== "")) + ) { // Email RegExp to validate if the input is a valid email if (!emailSchema.safeParse(value).success) { ctx.addIssue({ @@ -226,37 +289,46 @@ function preprocess({ } // validate the excluded emails - const bookerEmail = value; - const excludedEmails = - bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || []; + if (value) { + const bookerEmail = value; + const excludedEmails = + bookingField.excludeEmails + ?.split(",") + .map((domain) => domain.trim()) || []; - const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry)); - if (match) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("exclude_emails_match_found_error_message"), - }); - } - const requiredEmails = - bookingField.requireEmails - ?.split(",") - .map((domain) => domain.trim()) - .filter(Boolean) || []; - const requiredEmailsMatch = requiredEmails.find((requiredEntry) => - doesEmailMatchEntry(bookerEmail, requiredEntry) - ); - if (requiredEmails.length > 0 && !requiredEmailsMatch) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("require_emails_no_match_found_error_message"), - }); + const match = excludedEmails.find((excludedEntry) => + doesEmailMatchEntry(bookerEmail, excludedEntry) + ); + if (match) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("exclude_emails_match_found_error_message"), + }); + } + const requiredEmails = + bookingField.requireEmails + ?.split(",") + .map((domain) => domain.trim()) + .filter(Boolean) || []; + const requiredEmailsMatch = requiredEmails.find((requiredEntry) => + doesEmailMatchEntry(bookerEmail, requiredEntry) + ); + if (requiredEmails.length > 0 && !requiredEmailsMatch) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("require_emails_no_match_found_error_message"), + }); + } } } continue; } - const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap]; + const fieldTypeSchema = + fieldTypesSchemaMap[ + bookingField.type as keyof typeof fieldTypesSchemaMap + ]; if (fieldTypeSchema) { fieldTypeSchema.superRefine({ @@ -273,13 +345,19 @@ function preprocess({ const emailsParsed = emailSchema.array().safeParse(value); if (isRequired && (!value || value.length === 0)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m(`error_required_field`), + }); continue; } if (!emailsParsed.success) { // If additional guests are shown but all inputs are empty then don't show any errors - if (bookingField.name === "guests" && value.every((email: string) => email === "")) { + if ( + bookingField.name === "guests" && + value.every((email: string) => email === "") + ) { // reset guests to empty array, otherwise it adds "" for every input responses[bookingField.name] = []; continue; @@ -294,7 +372,10 @@ function preprocess({ const emails = emailsParsed.data; emails.sort().some((item, i) => { if (item === emails[i + 1]) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("duplicate_email") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("duplicate_email"), + }); return true; } }); @@ -303,18 +384,27 @@ function preprocess({ if (bookingField.type === "multiselect") { if (isRequired && (!value || value.length === 0)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m(`error_required_field`), + }); continue; } if (!stringSchema.array().safeParse(value).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("Invalid array of strings"), + }); } continue; } if (bookingField.type === "checkbox") { if (!stringSchema.array().safeParse(value).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid array of strings") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("Invalid array of strings"), + }); } continue; } @@ -326,7 +416,10 @@ function preprocess({ // Validate phone number if the field is not hidden and requires validation if (!bookingField.hidden && needsValidation) { if (!(await phoneSchema.safeParseAsync(value)).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("invalid_number"), + }); } } continue; @@ -335,7 +428,10 @@ function preprocess({ if (bookingField.type === "boolean") { const schema = z.boolean(); if (!schema.safeParse(value).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid Boolean") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("Invalid Boolean"), + }); } continue; } @@ -347,9 +443,14 @@ function preprocess({ 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 || value?.value) && checkOptional ? true : optionField?.required && !optionValue + (isRequired || value?.value) && checkOptional + ? true + : optionField?.required && !optionValue ) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("error_required_field") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("error_required_field"), + }); return; } @@ -357,7 +458,10 @@ function preprocess({ // `typeOfOptionInput` can be any of the main types. So, we the same validations should run for `optionValue` if (typeOfOptionInput === "phone") { if (!(await phoneSchema.safeParseAsync(optionValue)).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("invalid_number") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("invalid_number"), + }); } } } @@ -367,10 +471,17 @@ function preprocess({ // 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(bookingField.type)) { + if ( + ["address", "text", "select", "number", "radio", "textarea"].includes( + bookingField.type + ) + ) { const schema = stringSchema; if (!schema.safeParse(value).success) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: m("Invalid string"), + }); } continue; } @@ -385,7 +496,10 @@ function preprocess({ 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(function (res?: { error?: unknown[] }) { - console.error("Failed to preprocess query params, prefilling will be skipped", res?.error); + console.error( + "Failed to preprocess query params, prefilling will be skipped", + res?.error + ); return {}; }); } diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 60268124ed..53ac8fe40c 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -901,7 +901,9 @@ export const emailSchema = emailRegexSchema; // The PR at https://github.com/colinhacks/zod/pull/2157 addresses this issue and improves email validation // I introduced this refinement(to be used with z.email()) as a short term solution until we upgrade to a zod // version that will include updates in the above PR. -export const emailSchemaRefinement = (value: string) => { +export const emailSchemaRefinement = (value: string | null | undefined) => { + // If there's no value, it's NOT a valid email format, so return false. + if (!value) return false; return emailSchema.safeParse(value).success; };