From ad65fbbd811239dd5b0c77fb2730210045b4587e Mon Sep 17 00:00:00 2001 From: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:01:23 +0530 Subject: [PATCH] fix: change URL prefill behavior to skip only invalid fields (#26982) * fix: change URL prefill behavior to skip only invalid fields Previously, when prefilling booking form fields from URL query parameters, if any single field failed validation, all prefilling was skipped and an empty object was returned. This change modifies the behavior so that only invalid fields are skipped, while valid fields are still prefilled. The implementation: - Adds validateSingleFieldForPrefill() to validate individual fields - Adds parseFieldByField() to collect valid fields when full parse fails - Modifies getBookingResponsesPartialSchema to use field-by-field parsing as a fallback when full schema validation fails - Logs which specific fields were skipped and why This improves user experience by preserving valid prefill data even when some fields have invalid values. Co-Authored-By: hariom@cal.com * refactor: reduce code duplication in partial prefill validation Simplify the implementation by integrating field-by-field error handling directly into the existing preprocess() function instead of using separate helper functions. Changes: - Remove validateSingleFieldForPrefill() and parseFieldByField() functions - Simplify getBookingResponsesPartialSchema to directly use preprocess() - Add try-catch wrapper in preprocess() when isPartialSchema=true - Continue processing other fields even if one fails during preprocessing - Log skipped fields with reasons at debug level This reduces code duplication while maintaining the same behavior of skipping only invalid fields during URL prefill. Co-Authored-By: hariom@cal.com * fixes * refactor: rename getRefinedValue to superRefineField and add edge case tests - Rename getRefinedValue to superRefineField for clarity - Remove duplicate multiselect test block - Add test for empty object name field during partial prefill - Add test for invalid URL format during partial prefill - Add test for null responses during partial prefill Co-Authored-By: hariom@cal.com * Simplifu * Simplif more * Enhance booking responses schema tests to validate required fields and name variants. Updated error handling for empty names and added tests for first and last name requirements. Refactored schema validation logic to improve clarity and maintainability. * fix types * test: add test for email validation behavior during partial prefill Documents that email validation is relaxed to z.string() during partial prefill, consistent with phone field behavior where partial values like '+91' are accepted. This addresses Cubic AI feedback (confidence 9/10) to verify email handling. Co-Authored-By: hariom@cal.com * fix * Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts Generated by Paragon from proposal for PR #26982 * Revert "Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts" This reverts commit fc2b7ad7ca8099bc4f68e97b383ea16b41cdd772. * Simplify * fix: sanitize partial prefill log to avoid exposing user input data Replaced raw Zod issues array in console.warn with a count of validation errors to prevent sensitive user data from being logged. Issue identified by cubic (confidence 9/10). Co-Authored-By: unknown <> * fix * Remove throw Error * Refactor booking responses schema tests to use helper functions for assertions - Introduced `expectResponsesToBe` and `expectParsingToFail` helper functions to streamline test assertions. - Replaced direct assertions with these helper functions for improved readability and maintainability. - Removed redundant error handling code to simplify test logic. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../lib/getBookingResponsesSchema.test.ts | 2666 ++++++++++------- .../bookings/lib/getBookingResponsesSchema.ts | 631 ++-- packages/features/form-builder/schema.ts | 110 +- 3 files changed, 1989 insertions(+), 1418 deletions(-) diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts index a96f65b21a..857bc170c0 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.test.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.test.ts @@ -1,10 +1,7 @@ -/* eslint-disable playwright/no-conditional-in-test */ -import { describe, expect } from "vitest"; -import type { z } from "zod"; - import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; import { test } from "@calcom/testing/lib/fixtures/fixtures"; - +import { describe, expect } from "vitest"; +import type { z } from "zod"; import getBookingResponsesSchema, { getBookingResponsesPartialSchema } from "./getBookingResponsesSchema"; const CUSTOM_REQUIRED_FIELD_ERROR_MSG = "error_required_field"; @@ -15,6 +12,22 @@ const CUSTOM_EMAIL_EXCLUDED_ERROR_MSG = "exclude_emails_match_found_error_messag const CUSTOM_EMAIL_REQUIRED_ERROR_MSG = "require_emails_no_match_found_error_message"; const ZOD_REQUIRED_FIELD_ERROR_MSG = "Required"; +function expectResponsesToBe( + parsedResponses: z.SafeParseReturnType, + expected: unknown +) { + expect(parsedResponses.success).toBe(true); + expect(parsedResponses.data).toEqual(expected); +} + +function expectParsingToFail( + parsedResponses: z.SafeParseReturnType, + issue: unknown +) { + expect(parsedResponses.success).toBe(false); + expect(parsedResponses.error?.issues[0]).toEqual(issue); +} + describe("getBookingResponsesSchema", () => { test(`should parse booking responses`, async ({}) => { const schema = getBookingResponsesSchema({ @@ -87,163 +100,204 @@ describe("getBookingResponsesSchema", () => { } }); - describe("System Fields", () => { - describe(`'name' and 'email' must be considered as required fields`, () => { - test(`'name' and 'email' must be considered as required fields `, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponsesWithJustName = await schema.safeParseAsync({ - name: "John", - }); - expect(parsedResponsesWithJustName.success).toBe(false); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - expect(parsedResponsesWithJustName.error.issues[0]).toEqual( - expect.objectContaining({ - path: ["email"], - message: ZOD_REQUIRED_FIELD_ERROR_MSG, - }) - ); - - const parsedResponsesWithJustEmail = await schema.safeParseAsync({ - email: "john@example.com", - }); - - expect(parsedResponsesWithJustEmail.success).toBe(false); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - expect(parsedResponsesWithJustEmail.error.issues[0]).toEqual( - expect.objectContaining({ - message: "Invalid input", - path: ["name"], - }) - ); + describe("Field Type: name", () => { + test(`'name' must be required`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponsesWithJustEmail = await schema.safeParseAsync({ + email: "john@example.com", }); - test(`'email' must be validated `, async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - name: "John", - email: "john", - }); - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - // We don't get zod default email address validation error because `bookingResponses` schema defines email as z.string() only - // So, the error comes from superRefine in getBookingResponsesSchema. We should change this to zod email validation error - message: `{email}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`, - code: "custom", - }) - ); + expectParsingToFail( + parsedResponsesWithJustEmail, + expect.objectContaining({ + message: "Invalid input", + path: ["name"], + }) + ); + }); + + test(`firstName is required and lastName is optional by default`, async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + name: { + firstName: "John", + }, + email: "john@example.com", }); - test(`hidden required email field should not be validated`, async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - hidden: true, - }, - { - name: "attendeePhoneNumber", - type: "phone", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - name: "John", - email: "", - attendeePhoneNumber: "+919999999999", - }); - expect(parsedResponses.success).toBe(true); + expectResponsesToBe(parsedResponses, { + name: { + firstName: "John", + lastName: "", + }, + email: "john@example.com", + }); + }); + + test(`should reject empty fullname`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + testField: "test", + email: "test@test.com", + name: "", }); - test(`hidden required phone field should not be validated`, async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "attendeePhoneNumber", - type: "phone", - required: true, - hidden: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - name: "John", - email: "john@example.com", - attendeePhoneNumber: "", - }); - expect(parsedResponses.success).toBe(true); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{name}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, + }) + ); + }); + + test(`should reject empty firstName`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + testField: "test", + email: "test@test.com", + name: { + firstName: " ", + lastName: "Doe", + }, }); - test(`firstName is required and lastName is optional by default`, async () => { + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{name}Invalid string`, + }) + ); + }); + + test(`should accept empty lastname`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.parseAsync({ + testField: "test", + email: "test@test.com", + name: { + firstName: "John", + lastName: "", + }, + }); + + expect(parsedResponses).toEqual({ + testField: "test", + email: "test@test.com", + name: { + firstName: "John", + lastName: "", + }, + }); + }); + + describe("variant transformation", () => { + test("`firstAndLastName` variant to `fullName`", async () => { const schema = getBookingResponsesSchema({ bookingFields: [ { name: "name", type: "name", required: true, - variant: "firstAndLastName", }, { name: "email", @@ -261,105 +315,58 @@ describe("getBookingResponsesSchema", () => { const parsedResponses = await schema.safeParseAsync({ name: { firstName: "John", - }, - email: "john@example.com", - }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ - name: { - firstName: "John", - }, - email: "john@example.com", - }); - }); - - test(`should reject empty fullname`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - testField: "test", - email: "test@test.com", - name: "", - }); - - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - code: "custom", - message: `{name}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, - }) - ); - }); - - test(`should reject empty firstName`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - testField: "test", - email: "test@test.com", - name: { - firstName: " ", lastName: "Doe", }, + email: "john@example.com", }); - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectResponsesToBe( + parsedResponses, expect.objectContaining({ - code: "custom", - message: `{name}Invalid string`, + name: "John Doe", + email: "john@example.com", }) ); }); - test(`should accept empty lastname`, async ({}) => { + // TODO: Fix this behaviour later + test.skip("`firstAndLastName` variant to `fullName` when passed as JSON string from URL prefill", async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // URL prefill passes name as JSON string + const parsedResponses = await schema.safeParseAsync({ + name: '{"firstName":"John","lastName":"Doe"}', + email: "john@example.com", + }); + + expectResponsesToBe( + parsedResponses, + expect.objectContaining({ + name: "John Doe", + email: "john@example.com", + }) + ); + }); + + test("`fullName` to `firstAndLastName` when there is a lastName(separated by space)", async () => { const schema = getBookingResponsesSchema({ bookingFields: [ { @@ -376,162 +383,615 @@ describe("getBookingResponsesSchema", () => { { name: "testField", type: "text", - required: true, + required: false, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - const parsedResponses = await schema.parseAsync({ - testField: "test", - email: "test@test.com", - name: { - firstName: "John", - lastName: "", - }, + const parsedResponses = await schema.safeParseAsync({ + name: "John Doe", + email: "john@example.com", }); - expect(parsedResponses).toEqual({ - testField: "test", - email: "test@test.com", - name: { - firstName: "John", - lastName: "", - }, - }); - }); - - describe(`'name' can be transformed from one variant to other `, () => { - test("`firstAndLastName` variant to `fullName`", async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ + expectResponsesToBe( + parsedResponses, + expect.objectContaining({ name: { firstName: "John", lastName: "Doe", }, email: "john@example.com", - }); + }) + ); + }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual( - expect.objectContaining({ - name: "John Doe", - email: "john@example.com", - }) - ); + test("`fullName` to `firstAndLastName` when there is no lastName(separated by space)", async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + name: "John", + email: "john@example.com", }); - test("`fullName` to `firstAndLastName` when there is a lastName(separated by space)", async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - name: "John Doe", + expectResponsesToBe( + parsedResponses, + expect.objectContaining({ + name: { + firstName: "John", + lastName: "", + }, email: "john@example.com", - }); - - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual( - expect.objectContaining({ - name: { - firstName: "John", - lastName: "Doe", - }, - email: "john@example.com", - }) - ); - }); - test("`fullName` to `firstAndLastName` when there is no lastName(separated by space)", async () => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - variant: "firstAndLastName", - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "testField", - type: "text", - required: false, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - name: "John", - email: "john@example.com", - }); - - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual( - expect.objectContaining({ - name: { - firstName: "John", - lastName: "", - }, - email: "john@example.com", - }) - ); - }); + }) + ); }); }); }); - describe("validate phone type field", () => { + describe("Field Type: email", () => { + test(`'email' must be required`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponsesWithJustName = await schema.safeParseAsync({ + name: "John", + }); + expectParsingToFail( + parsedResponsesWithJustName, + expect.objectContaining({ + path: ["email"], + message: ZOD_REQUIRED_FIELD_ERROR_MSG, + }) + ); + }); + + test(`'email' must be validated `, async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + name: "John", + email: "john", + }); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + // We don't get zod default email address validation error because `bookingResponses` schema defines email as z.string() only + // So, the error comes from superRefine in getBookingResponsesSchema. We should change this to zod email validation error + message: `{email}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`, + code: "custom", + }) + ); + }); + + test(`hidden required email field should not be validated`, async () => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + hidden: true, + }, + { + name: "attendeePhoneNumber", + type: "phone", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + name: "John", + email: "", + attendeePhoneNumber: "+919999999999", + }); + expect(parsedResponses.success).toBe(true); + }); + + describe("excluded email/domain validation", () => { + test("should fail if the email is present in excluded emails", async () => { + const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "harry@gmail.com", + }); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + }) + ); + }); + + test("should pass if the email is not present in excluded emails", async () => { + const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "harry@workmail.com", + }); + + expectResponsesToBe(parsedResponses, { + name: "test", + email: "harry@workmail.com", + }); + }); + + test("should not block email domains that contain excluded domain as substring", async () => { + // This tests the fix for the bug where `includes` was used instead of `endsWith` + // e.g., blocking "test.co" should not block "test.com" + const excludedEmails = "test.co"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // test.com should NOT be blocked when test.co is excluded + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "user@test.com", + }); + + expectResponsesToBe(parsedResponses, { + name: "test", + email: "user@test.com", + }); + }); + + test("should block exact domain match when using endsWith", async () => { + const excludedEmails = "test.co"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // test.co should be blocked + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "user@test.co", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + }) + ); + }); + + test("should not block emails where excluded domain appears in local part", async () => { + // Ensures that the @ anchor prevents matching domains in the local part of email + const excludedEmails = "blocked.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // Email with blocked.com in local part should not be blocked + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "blocked.com@allowed.com", + }); + + expectResponsesToBe(parsedResponses, { + name: "test", + email: "blocked.com@allowed.com", + }); + }); + + test("should block full email match when exact email is excluded", async () => { + const excludedEmails = "anik@cal.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // anik@cal.com should be blocked + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "anik@cal.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + }) + ); + }); + + test("should block emails with domain when domain starts with @", async () => { + const excludedEmails = "@gmail.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // anik@gmail.com should be blocked when @gmail.com is excluded + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "anik@gmail.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + }) + ); + }); + + test("should block emails with domain when excluded email is just the domain without @", async () => { + const excludedEmails = "gmail.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + excludeEmails: excludedEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // test@gmail.com should be blocked when gmail.com is excluded + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "test@gmail.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + }) + ); + }); + }); + + describe("require email/domain validation", () => { + test("should fail if the required email/domain is not present", async () => { + const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com"; + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + requireEmails: requiredEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "test@test.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + }) + ); + }); + + test("should pass if the required email/domain is present", async () => { + const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + requireEmails: requiredEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "test@gmail.com", + }); + + expectResponsesToBe(parsedResponses, { + name: "test", + email: "test@gmail.com", + }); + }); + + test("should not match email domains that contain required domain as substring", async () => { + // This tests the fix for the bug where `includes` was used instead of `endsWith` + // e.g., requiring "test.co" should not match "test.com" + const requiredEmails = "test.co"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + requireEmails: requiredEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // test.com should NOT match when test.co is required + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "user@test.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + }) + ); + }); + + test("should match exact domain when using endsWith", async () => { + const requiredEmails = "test.co"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + requireEmails: requiredEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // test.co should match + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "user@test.co", + }); + + expectResponsesToBe(parsedResponses, { + name: "test", + email: "user@test.co", + }); + }); + + test("should not match emails where required domain appears in local part", async () => { + // Ensures that the @ anchor prevents matching domains in the local part of email + const requiredEmails = "required.com"; + + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + requireEmails: requiredEmails, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // Email with required.com in local part should not match + const parsedResponses = await schema.safeParseAsync({ + name: "test", + email: "required.com@other.com", + }); + + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + }) + ); + }); + }); + }); + + describe("Field Type: phone", () => { test(`should fail parsing if invalid phone provided`, async ({}) => { const schema = getBookingResponsesSchema({ bookingFields: [ @@ -558,11 +1018,8 @@ describe("getBookingResponsesSchema", () => { name: "test", testPhone: "1234567890", }); - expect(parsedResponses.success).toBe(false); - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `{testPhone}${CUSTOM_PHONE_VALIDATION_ERROR_MSG}`, @@ -595,11 +1052,7 @@ describe("getBookingResponsesSchema", () => { name: "test", testPhone: "+919999999999", }); - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testPhone: "+919999999999", @@ -633,11 +1086,7 @@ describe("getBookingResponsesSchema", () => { // Space can come due to libraries considering + to be space testPhone: " 919999999999", }); - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testPhone: "+919999999999", @@ -649,11 +1098,7 @@ describe("getBookingResponsesSchema", () => { // Space can come due to libraries considering + to be space testPhone: " 919999999999", }); - expect(parsedResponses2.success).toBe(true); - if (!parsedResponses2.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses2.data).toEqual({ + expectResponsesToBe(parsedResponses2, { email: "test@test.com", name: "test", testPhone: "+919999999999", @@ -686,12 +1131,8 @@ describe("getBookingResponsesSchema", () => { name: "test", testPhone: "", }); - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `{testPhone}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, @@ -724,12 +1165,8 @@ describe("getBookingResponsesSchema", () => { email: "test@test.com", name: "test", }); - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `{testPhone}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, @@ -764,11 +1201,8 @@ describe("getBookingResponsesSchema", () => { name: "test", invalidField: "1234567890", }); - expect(parsedResponses.success).toBe(false); - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `Can't parse unknown booking field type: unknown-field-type`, @@ -776,7 +1210,7 @@ describe("getBookingResponsesSchema", () => { ); }); - describe("multiemail field type", () => { + describe("Field Type: multiemail", () => { test("should successfully parse a multiemail type field", async () => { const schema = getBookingResponsesSchema({ bookingFields: [ @@ -803,12 +1237,7 @@ describe("getBookingResponsesSchema", () => { name: "test", testEmailsList: ["first@example.com"], }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testEmailsList: ["first@example.com"], @@ -841,12 +1270,8 @@ describe("getBookingResponsesSchema", () => { name: "test", testEmailsList: ["first@example.com", "invalid@example"], }); - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `{testEmailsList}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`, @@ -880,11 +1305,7 @@ describe("getBookingResponsesSchema", () => { name: "test", testEmailsList: "first@example.com", }); - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testEmailsList: ["first@example.com"], @@ -892,7 +1313,7 @@ describe("getBookingResponsesSchema", () => { }); }); - describe("multiselect field type", () => { + describe("Field Type: multiselect", () => { test("should successfully parse a multiselect type field", async () => { const schema = getBookingResponsesSchema({ bookingFields: [ @@ -919,12 +1340,7 @@ describe("getBookingResponsesSchema", () => { name: "test", testMultiselect: ["option1", "option-2"], }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testMultiselect: ["option1", "option-2"], @@ -956,12 +1372,7 @@ describe("getBookingResponsesSchema", () => { name: "test", testMultiselect: "option1", }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", testMultiselect: ["option1"], @@ -993,14 +1404,8 @@ describe("getBookingResponsesSchema", () => { name: "test", testMultiselect: [1, 2], }); - expect(parsedResponses.success).toBe(false); - - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expectParsingToFail( + parsedResponses, expect.objectContaining({ code: "custom", message: `{testMultiselect}Invalid array of strings`, @@ -1009,8 +1414,8 @@ describe("getBookingResponsesSchema", () => { }); }); - describe("multiselect field type", () => { - test("should successfully parse a multiselect type field", async () => { + describe("Field Type: radioInput", () => { + test(`should fail parsing if invalid phone number is provided`, async ({}) => { const schema = getBookingResponsesSchema({ bookingFields: [ { @@ -1024,8 +1429,98 @@ describe("getBookingResponsesSchema", () => { required: true, }, { - name: "testMultiselect", - type: "multiselect", + name: "radioInput", + type: "radioInput", + required: true, + optionsInputs: { + phone: { + type: "phone", + }, + }, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + email: "test@test.com", + name: "test", + radioInput: JSON.stringify({ + value: "phone", + optionValue: " 9999999999", + }), + }); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{radioInput}${CUSTOM_PHONE_VALIDATION_ERROR_MSG}`, + }) + ); + }); + + test(`should correctly handle space in the beginning of phone number which could come from a + in prefill URL`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "radioInput", + type: "radioInput", + required: true, + optionsInputs: { + // A field of type phone + randomField: { + type: "phone", + }, + }, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + email: "test@test.com", + name: "test", + radioInput: JSON.stringify({ + value: "randomField", + optionValue: " 919999999999", + }), + }); + expectResponsesToBe(parsedResponses, { + email: "test@test.com", + name: "test", + radioInput: { + value: "randomField", + optionValue: "+919999999999", + }, + }); + }); + }); + + describe("Field Type: url", () => { + test(`should pass parsing if protocol is missing`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "url", + type: "url", required: true, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, @@ -1034,281 +1529,267 @@ describe("getBookingResponsesSchema", () => { const parsedResponses = await schema.safeParseAsync({ email: "test@test.com", name: "test", - testMultiselect: ["option1", "option-2"], + url: "www.example.com", }); - expect(parsedResponses.success).toBe(true); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ + // Expect success because our logic now handles missing protocol + expectResponsesToBe(parsedResponses, { email: "test@test.com", name: "test", - testMultiselect: ["option1", "option-2"], + url: "www.example.com", }); }); - }); - test.todo("select"); - test.todo("textarea"); - test.todo("number"); - test.todo("checkbox"); - test.todo("radio"); - test.todo("boolean"); -}); - -describe("validate radioInput type field", () => { - test(`should fail parsing if invalid phone number is provided`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "radioInput", - type: "radioInput", - required: true, - optionsInputs: { - phone: { - type: "phone", - }, + test(`should fail parsing if url is truly invalid`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, }, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - radioInput: JSON.stringify({ - value: "phone", - optionValue: " 9999999999", - }), - }); - expect(parsedResponses.success).toBe(false); - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - code: "custom", - message: `{radioInput}${CUSTOM_PHONE_VALIDATION_ERROR_MSG}`, - }) - ); - }); - - test(`should correctly handle space in the beginning of phone number which could come from a + in prefill URL`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "radioInput", - type: "radioInput", - required: true, - optionsInputs: { - // A field of type phone - randomField: { - type: "phone", - }, + { + name: "email", + type: "email", + required: true, }, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", + { + name: "url", + type: "url", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + email: "test@test.com", + name: "test", + url: "http:/nope", + }); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{url}${CUSTOM_URL_VALIDATION_ERROR_MSG}`, + }) + ); }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - radioInput: JSON.stringify({ - value: "randomField", - optionValue: " 919999999999", - }), - }); - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ - email: "test@test.com", - name: "test", - radioInput: { - value: "randomField", - optionValue: "+919999999999", - }, - }); - }); -}); -describe("validate url type field", () => { - test(`should pass parsing if protocol is missing`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "url", - type: "url", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", + test(`should successfully give responses if url type field value is valid`, async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "url", + type: "url", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + email: "test@test.com", + name: "test", + url: "https://8x8.vc/company", + }); + expectResponsesToBe(parsedResponses, { + email: "test@test.com", + name: "test", + url: "https://8x8.vc/company", + }); }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - url: "www.example.com", - }); - // Expect success because our logic now handles missing protocol - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ - email: "test@test.com", - name: "test", - url: "www.example.com", - }); - }); - test(`should fail parsing if url is truly invalid`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "url", - type: "url", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", + test("should fail parsing if url field value is empty", async ({}) => { + const schema = getBookingResponsesSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "url", + type: "url", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + const parsedResponses = await schema.safeParseAsync({ + email: "test@test.com", + name: "test", + url: "", + }); + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + code: "custom", + message: `{url}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, + }) + ); }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - url: "http:/nope", - }); - expect(parsedResponses.success).toBe(false); - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - code: "custom", - message: `{url}${CUSTOM_URL_VALIDATION_ERROR_MSG}`, - }) - ); - }); - - test(`should successfully give responses if url type field value is valid`, async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "url", - type: "url", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - url: "https://8x8.vc/company", - }); - expect(parsedResponses.success).toBe(true); - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.data).toEqual({ - email: "test@test.com", - name: "test", - url: "https://8x8.vc/company", - }); - }); - - test("should fail parsing if url field value is empty", async ({}) => { - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - }, - { - name: "url", - type: "url", - required: true, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - const parsedResponses = await schema.safeParseAsync({ - email: "test@test.com", - name: "test", - url: "", - }); - expect(parsedResponses.success).toBe(false); - // eslint-disable-next-line playwright/no-conditional-in-test - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - code: "custom", - message: `{url}${CUSTOM_REQUIRED_FIELD_ERROR_MSG}`, - }) - ); }); }); describe("getBookingResponsesPartialSchema - Prefill validation", () => { + describe("Field Type: name - prefill", () => { + test(`should accept object response with additional fields beyond firstName and lastName`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Additional fields like middleName, suffix, etc. should not cause errors + const parsedResponses = await schema.parseAsync({ + name: { + firstName: "John", + lastName: "Doe", + middleName: "William", + suffix: "Jr.", + }, + }); + // The additional fields are stripped/ignored, but the parsing should succeed + expect(parsedResponses).toEqual( + expect.objectContaining({ + name: { + firstName: "John", + lastName: "Doe", + }, + }) + ); + }); + + test(`should return empty string for name field with invalid array response during partial prefill`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Array response is invalid for name field - should be skipped during partial prefill + const parsedResponses = await schema.parseAsync({ + name: ["John", "Doe"], + testField: "test value", + }); + // Name field should be present with empty string, and testField should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + name: "", + testField: "test value", + }) + ); + }); + + test(`should return empty string for name field with invalid number response during partial prefill`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Number response is invalid for name field - should be skipped during partial prefill + const parsedResponses = await schema.parseAsync({ + name: 12345, + testField: "test value", + }); + // Name field should be present with empty string, and testField should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + name: "", + testField: "test value", + }) + ); + }); + + test(`should skip name field with object missing firstName during partial prefill`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Object missing firstName is invalid - should be skipped during partial prefill + const parsedResponses = await schema.parseAsync({ + name: { lastName: "Doe" }, + testField: "test value", + }); + // Name field should be skipped (not included), but testField should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + testField: "test value", + }) + ); + expect(parsedResponses).not.toHaveProperty("name"); + }); + }); + test(`should be able to get fields prefilled even when name is empty string`, async ({}) => { const schema = getBookingResponsesPartialSchema({ bookingFields: [ @@ -1341,13 +1822,9 @@ describe("getBookingResponsesPartialSchema - Prefill validation", () => { }) ); }); -}); -describe("excluded email/domain validation", () => { - test("should fail if the email is present in excluded emails", async () => { - const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com"; - - const schema = getBookingResponsesSchema({ + test(`should prefill all valid fields`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1358,33 +1835,38 @@ describe("excluded email/domain validation", () => { name: "email", type: "email", required: true, - excludeEmails: excludedEmails, + }, + { + name: "testPhone", + type: "phone", + required: true, + }, + { + name: "testField", + type: "text", + required: true, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "harry@gmail.com", + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + email: "valid@email.com", + testPhone: "+919999999999", + testField: "test value", }); - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + name: "John Doe", + email: "valid@email.com", + testPhone: "+919999999999", + testField: "test value", }) ); }); - test("should pass if the email is not present in excluded emails", async () => { - const excludedEmails = "spammer@cal.com, hotmail.com, yahoo.com, gmail.com"; - - const schema = getBookingResponsesSchema({ + test(`should prefill name and text fields when phone field has partial value (country code only)`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1395,114 +1877,37 @@ describe("excluded email/domain validation", () => { name: "email", type: "email", required: true, - excludeEmails: excludedEmails, + }, + { + name: "testPhone", + type: "phone", + required: false, + }, + { + name: "testField", + type: "text", + required: true, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "harry@workmail.com", + // Phone field with space at beginning (URL encoding of +) should be converted to + + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + testPhone: " 91", + testField: "test value", }); - - expect(parsedResponses.success).toBe(true); - - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.data).toEqual({ - name: "test", - email: "harry@workmail.com", - }); - }); - - test("should not block email domains that contain excluded domain as substring", async () => { - // This tests the fix for the bug where `includes` was used instead of `endsWith` - // e.g., blocking "test.co" should not block "test.com" - const excludedEmails = "test.co"; - - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - excludeEmails: excludedEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - // test.com should NOT be blocked when test.co is excluded - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "user@test.com", - }); - - expect(parsedResponses.success).toBe(true); - - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.data).toEqual({ - name: "test", - email: "user@test.com", - }); - }); - - test("should block exact domain match when using endsWith", async () => { - const excludedEmails = "test.co"; - - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - excludeEmails: excludedEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - // test.co should be blocked - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "user@test.co", - }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + name: "John Doe", + testPhone: "+91", + testField: "test value", }) ); }); - test("should not block emails where excluded domain appears in local part", async () => { - // Ensures that the @ anchor prevents matching domains in the local part of email - const excludedEmails = "blocked.com"; - - const schema = getBookingResponsesSchema({ + test(`should accept email field with any string value during partial prefill (validation relaxed for partial values)`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1513,74 +1918,34 @@ describe("excluded email/domain validation", () => { name: "email", type: "email", required: true, - excludeEmails: excludedEmails, + }, + { + name: "testField", + type: "text", + required: false, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - // Email with blocked.com in local part should not be blocked - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "blocked.com@allowed.com", + // During partial prefill, email validation is relaxed to z.string() to allow partial values + // This is consistent with phone field behavior where partial values like "+91" are accepted + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + email: "invalid-email", + testField: "test value", }); - - expect(parsedResponses.success).toBe(true); - - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.data).toEqual({ - name: "test", - email: "blocked.com@allowed.com", - }); - }); - - test("should block full email match when exact email is excluded", async () => { - const excludedEmails = "anik@cal.com"; - - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - excludeEmails: excludedEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - // anik@cal.com should be blocked - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "anik@cal.com", - }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + // Email field accepts any string during partial prefill (validation is relaxed) + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + name: "John Doe", + email: "invalid-email", + testField: "test value", }) ); }); - test("should block emails with domain when domain starts with @", async () => { - const excludedEmails = "@gmail.com"; - - const schema = getBookingResponsesSchema({ + test(`should prefill valid boolean field`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1591,36 +1956,29 @@ describe("excluded email/domain validation", () => { name: "email", type: "email", required: true, - excludeEmails: excludedEmails, + }, + { + name: "testBoolean", + type: "boolean", + required: false, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - // anik@gmail.com should be blocked when @gmail.com is excluded - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "anik@gmail.com", + const parsedResponses = await schema.parseAsync({ + name: "John", + testBoolean: "true", }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, + name: "John", + testBoolean: true, }) ); }); - test("should block emails with domain when excluded email is just the domain without @", async () => { - const excludedEmails = "gmail.com"; - - const schema = getBookingResponsesSchema({ + test(`should prefill valid multiselect field`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1631,76 +1989,33 @@ describe("excluded email/domain validation", () => { name: "email", type: "email", required: true, - excludeEmails: excludedEmails, + }, + { + name: "testMultiselect", + type: "multiselect", + required: false, + options: [ + { label: "Option 1", value: "option1" }, + { label: "Option 2", value: "option2" }, + ], }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - // test@gmail.com should be blocked when gmail.com is excluded - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "test@gmail.com", + const parsedResponses = await schema.parseAsync({ + name: "John", + testMultiselect: ["option1", "option2"], }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`, - }) - ); - }); -}); - -describe("require email/domain validation", () => { - test("should fail if the required email/domain is not present", async () => { - const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com"; - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - requireEmails: requiredEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "test@test.com", - }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( - expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + name: "John", + testMultiselect: ["option1", "option2"], }) ); }); - test("should pass if the required email/domain is present", async () => { - const requiredEmails = "gmail.com, user@hotmail.com, yahoo.com"; - - const schema = getBookingResponsesSchema({ + test(`should convert single value to array for multiselect field during prefill`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1711,75 +2026,33 @@ describe("require email/domain validation", () => { name: "email", type: "email", required: true, - requireEmails: requiredEmails, + }, + { + name: "testMultiselect", + type: "multiselect", + required: false, + options: [ + { label: "Option 1", value: "option1" }, + { label: "Option 2", value: "option2" }, + ], }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "test@gmail.com", + const parsedResponses = await schema.parseAsync({ + name: "John", + testMultiselect: "option1", }); - - expect(parsedResponses.success).toBe(true); - - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.data).toEqual({ - name: "test", - email: "test@gmail.com", - }); - }); - - test("should not match email domains that contain required domain as substring", async () => { - // This tests the fix for the bug where `includes` was used instead of `endsWith` - // e.g., requiring "test.co" should not match "test.com" - const requiredEmails = "test.co"; - - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - requireEmails: requiredEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - // test.com should NOT match when test.co is required - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "user@test.com", - }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + name: "John", + testMultiselect: ["option1"], }) ); }); - test("should match exact domain when using endsWith", async () => { - const requiredEmails = "test.co"; - - const schema = getBookingResponsesSchema({ + test(`should handle partial prefill with multiple field types`, async ({}) => { + const schema = getBookingResponsesPartialSchema({ bookingFields: [ { name: "name", @@ -1790,71 +2063,278 @@ describe("require email/domain validation", () => { name: "email", type: "email", required: true, - requireEmails: requiredEmails, + }, + { + name: "testText", + type: "text", + required: false, + }, + { + name: "testNumber", + type: "number", + required: false, + }, + { + name: "testTextarea", + type: "textarea", + required: false, }, ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, view: "ALL_VIEWS", }); - - // test.co should match - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "user@test.co", + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + testText: "some text", + testNumber: "42", + testTextarea: "multi\nline\ntext", }); - - expect(parsedResponses.success).toBe(true); - - if (!parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.data).toEqual({ - name: "test", - email: "user@test.co", - }); - }); - - test("should not match emails where required domain appears in local part", async () => { - // Ensures that the @ anchor prevents matching domains in the local part of email - const requiredEmails = "required.com"; - - const schema = getBookingResponsesSchema({ - bookingFields: [ - { - name: "name", - type: "name", - required: true, - }, - { - name: "email", - type: "email", - required: true, - requireEmails: requiredEmails, - }, - ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, - view: "ALL_VIEWS", - }); - - // Email with required.com in local part should not match - const parsedResponses = await schema.safeParseAsync({ - name: "test", - email: "required.com@other.com", - }); - - expect(parsedResponses.success).toBe(false); - - if (parsedResponses.success) { - throw new Error("Should not reach here"); - } - - expect(parsedResponses.error.issues[0]).toEqual( + expect(parsedResponses).toEqual( expect.objectContaining({ - code: "custom", - message: `{email}${CUSTOM_EMAIL_REQUIRED_ERROR_MSG}`, + name: "John Doe", + testText: "some text", + testNumber: "42", + testTextarea: "multi\nline\ntext", }) ); }); + describe("superRefine error handling in partial prefill", () => { + test(`should skip name field gracefully when superRefine throws due to invalid variant config`, async () => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "invalidVariant", // Invalid variant that will cause superRefine to throw + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // This should NOT throw - should gracefully skip the invalid field + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + testField: "test value", + }); + + // Name field should be skipped due to superRefine error, but testField should still work + expect(parsedResponses).toEqual( + expect.objectContaining({ + testField: "test value", + }) + ); + }); + + test(`should skip field and still prefill other valid fields when superRefine throws`, async () => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "invalidVariant", // Will cause superRefine to throw + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "validPhone", + type: "phone", + required: false, + }, + { + name: "validText", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + const parsedResponses = await schema.parseAsync({ + name: "John", + validPhone: "+919999999999", + validText: "some text", + }); + + // Should have the valid fields prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + validPhone: "+919999999999", + validText: "some text", + }) + ); + }); + + test(`should skip textarea field when text exceeds maxLength during partial prefill`, async () => { + // This test verifies that when textarea value exceeds maxLength, the validation error is handled + // gracefully during partial prefill and other fields are still prefilled + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "notes", + type: "textarea", + required: false, + maxLength: 5, // Set maxLength to 5 characters + }, + { + name: "validText", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + + // Provide text that exceeds maxLength (more than 5 chars) + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + notes: "This text is way too long for maxLength of 5", + validText: "test value", + }); + + // Parsing should succeed - partial prefill should handle validation errors gracefully + expect(parsedResponses).toBeDefined(); + // Other valid fields should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + name: "John Doe", + validText: "test value", + }) + ); + // The invalid textarea field should NOT be included in the response + expect(parsedResponses.notes).toBeUndefined(); + }); + + test(`should skip name field with empty object during partial prefill`, async () => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + variant: "firstAndLastName", + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Empty object {} is invalid for name field - should be skipped during partial prefill + const parsedResponses = await schema.parseAsync({ + name: {}, + testField: "test value", + }); + // Name field should be skipped (not included), but testField should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + testField: "test value", + }) + ); + expect(parsedResponses).not.toHaveProperty("name"); + }); + + test(`should skip url field with invalid format during partial prefill`, async () => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "website", + type: "url", + required: false, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Invalid URL should be skipped during partial prefill + const parsedResponses = await schema.parseAsync({ + name: "John Doe", + website: "not-a-valid-url", + testField: "test value", + }); + // URL field should be skipped due to validation error, but other fields should still be prefilled + expect(parsedResponses).toEqual( + expect.objectContaining({ + name: "John Doe", + testField: "test value", + }) + ); + expect(parsedResponses).not.toHaveProperty("website"); + }); + + test(`should handle null responses gracefully during partial prefill`, async () => { + const schema = getBookingResponsesPartialSchema({ + bookingFields: [ + { + name: "name", + type: "name", + required: true, + }, + { + name: "email", + type: "email", + required: true, + }, + { + name: "testField", + type: "text", + required: false, + }, + ] as z.infer & z.BRAND<"HAS_SYSTEM_FIELDS">, + view: "ALL_VIEWS", + }); + // Null responses should be handled gracefully + const parsedResponses = await schema.parseAsync(null); + // Should return empty object or null without crashing + expect(parsedResponses).toBeDefined(); + }); + }); + test(`optional email field should fail if invalid input is provided`, async () => { const schema = getBookingResponsesSchema({ bookingFields: [ @@ -1883,14 +2363,12 @@ describe("require email/domain validation", () => { 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}`, - }) - ); - } + expectParsingToFail( + parsedResponses, + expect.objectContaining({ + message: `{optionalEmail}${CUSTOM_EMAIL_VALIDATION_ERROR_MSG}`, + }) + ); }); test(`optional email field should pass if left empty`, async () => { diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index 8f06060bc2..e4be05e95d 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -1,21 +1,16 @@ -import { isValidPhoneNumber } from "libphonenumber-js/max"; -import z from "zod"; - import type { ALL_VIEWS } from "@calcom/features/form-builder/schema"; -import { fieldTypesSchemaMap } 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; -}; +type CommonParams = { bookingFields: BookingFields; view: View; translateFn?: TranslationFunction }; export const bookingResponse = dbReadResponseSchema; export const bookingResponsesDbSchema = z.record(dbReadResponseSchema); @@ -29,6 +24,272 @@ const ensureValidPhoneNumber = (value: string) => { 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: @@ -52,14 +313,7 @@ const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => { }; 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 @@ -67,13 +321,7 @@ export const getBookingResponsesPartialSchema = ({ bookingFields, view, translat // - 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, - }); + 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 @@ -93,6 +341,39 @@ export function getBookingResponsesSchemaWithOptionalChecks({ }); } +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({ @@ -130,45 +411,18 @@ function preprocess({ // 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]; - // 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({ - response: value, - isPartialSchema, - field, - }); - return; - } - if (field.type === "boolean") { - // Turn a boolean in string to a real boolean - 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") { - newResponses[field.name] = 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); + + try { + newResponses[field.name] = preprocessField({ field, value, isPartialSchema, log }); + } catch (e) { + if (!isPartialSchema) { + throw e; } - const optionsInputs = field.optionsInputs; - const optionInputField = optionsInputs?.[parsedValue.value]; - if (optionInputField && optionInputField.type === "phone") { - parsedValue.optionValue = ensureValidPhoneNumber(parsedValue.optionValue); - } - newResponses[field.name] = parsedValue; - } else if (field.type === "phone") { - newResponses[field.name] = ensureValidPhoneNumber(value); - } else { - newResponses[field.name] = value; + 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})`); } }); @@ -196,18 +450,6 @@ 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 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 `{${bookingField.name}}${translatedMessage}`; - }; const views = bookingField.views; const isFieldApplicableToCurrentView = currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true; @@ -226,219 +468,46 @@ function preprocess({ continue; } - if (isRequired && !isPartialSchema && !value) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m(`error_required_field`), - }); - return; - } - - if (bookingField.type === "email") { - 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({ - code: z.ZodIssueCode.custom, - message: m("email_validation_error"), - }); - } - - // validate the excluded emails - 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"), - }); - } - } - } - - continue; - } - - const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap]; - - if (fieldTypeSchema) { - fieldTypeSchema.superRefine({ - response: value, - ctx, - m, - field: bookingField, - isPartialSchema, - }); - continue; - } - - if (bookingField.type === "multiemail") { - const emailsParsed = emailSchema.array().safeParse(value); - - if (isRequired && (!value || value.length === 0)) { - 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 === "")) { - // reset guests to empty array, otherwise it adds "" for every input - responses[bookingField.name] = []; - continue; - } - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("email_validation_error"), - }); - continue; - } - - const emails = emailsParsed.data; - emails.sort().some((item, i) => { - if (item === emails[i + 1]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("duplicate_email"), - }); - return true; - } - }); - continue; - } - - if (bookingField.type === "multiselect") { - if (isRequired && (!value || value.length === 0)) { - 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"), - }); - } - continue; - } - - if (bookingField.type === "checkbox") { - if (!stringSchema.array().safeParse(value).success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("Invalid array of strings"), - }); - } - continue; - } - - if (bookingField.type === "phone") { - // Determine if the phone field needs validation - const needsValidation = isRequired || (value && value.trim() !== ""); - - // 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"), - }); - } - } - continue; - } - - if (bookingField.type === "boolean") { - const schema = z.boolean(); - if (!schema.safeParse(value).success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("Invalid Boolean"), - }); - } - continue; - } - - if (bookingField.type === "radioInput") { - if (bookingField.optionsInputs) { - const optionValue = value?.optionValue; - const optionField = bookingField.optionsInputs[value?.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 || value?.value) && checkOptional ? true : optionField?.required && !optionValue - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("error_required_field"), - }); - return; - } - - if (optionValue) { - // `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"), - }); - } - } - } - } - continue; - } - - // 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)) { - const schema = stringSchema; - if (!schema.safeParse(value).success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: m("Invalid string"), - }); - } - continue; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Can't parse unknown booking field type: ${bookingField.type}`, + // 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(function (res?: { error?: unknown[] }) { - console.error("Failed to preprocess query params, prefilling will be skipped", res?.error); + return preprocessed.catch((res?: { error?: unknown[] }) => { + console.error("Failed to validate query params, prefilling will be skipped entirely", res?.error); return {}; }); } diff --git a/packages/features/form-builder/schema.ts b/packages/features/form-builder/schema.ts index 345feba7cf..d06bcdbe57 100644 --- a/packages/features/form-builder/schema.ts +++ b/packages/features/form-builder/schema.ts @@ -1,13 +1,32 @@ +import { fieldSchema, fieldTypeEnum, variantsConfigSchema } from "@calcom/prisma/zod-utils"; import { z } from "zod"; - -import { fieldSchema, fieldTypeEnum, variantsConfigSchema, type FieldType } from "@calcom/prisma/zod-utils"; - import { fieldTypesConfigMap } from "./fieldTypes"; import { preprocessNameFieldDataWithVariant } from "./utils"; import { getConfig as getVariantsConfig } from "./utils/variantsConfig"; const nonEmptyString = () => z.string().refine((value: string) => value.trim().length > 0); +type FieldTypeSchemaConfig = { + preprocess: (data: { + field: z.infer; + response: TInput; + isPartialSchema: boolean; + }) => TPreprocessedOutput; + superRefine: (data: { + field: z.infer; + response: TPreprocessedOutput; + isPartialSchema: boolean; + ctx: FieldZodCtx; + m: (key: string, options?: Record) => string; + }) => void; +}; + +function defineFieldSchema( + config: FieldTypeSchemaConfig +): FieldTypeSchemaConfig { + return config; +} + export type ALL_VIEWS = "ALL_VIEWS"; // It is the config that is specific to a type and doesn't make sense in all fields individually. Any field with the type will automatically inherit this config. @@ -92,36 +111,16 @@ export const fieldTypeConfigSchema = z export const fieldsSchema = z.array(fieldSchema); -export const fieldTypesSchemaMap: Partial< - Record< - FieldType, - { - /** - * - preprocess the responses received through prefill query params - * - preprocess the values being filled in the booking form. - * - does not run for the responses received from DB - */ - preprocess: (data: { - field: z.infer; - response: string; - isPartialSchema: boolean; - }) => unknown; - /** - * - Validates the response received through prefill query params - * - Validates the values being filled in the booking form. - * - does not run for the responses received from DB - */ - superRefine: (data: { - field: z.infer; - response: string; - isPartialSchema: boolean; - ctx: z.RefinementCtx; - m: (key: string, options?: Record) => string; - }) => void; - } - > -> = { - name: { +function stringifyResponse(response: unknown): string { + if (typeof response !== "string") { + return String(response); + } else { + return response; + } +} + +export const fieldTypesSchemaMap = { + name: defineFieldSchema({ preprocess: ({ response, field }) => { const fieldTypeConfig = fieldTypesConfigMap[field.type]; @@ -138,7 +137,28 @@ export const fieldTypesSchemaMap: Partial< correctedVariant = variantInResponse; } - return preprocessNameFieldDataWithVariant(correctedVariant, response); + // We return this default value so that it meets the requirement of 'name' field being required in zod-utils#bookingResponses + const defaultValue = ""; + if (response === null || response === undefined) { + return defaultValue; + } + + if (typeof response === "string") { + return preprocessNameFieldDataWithVariant(correctedVariant, response); + } + + if (typeof response === "object" && "firstName" in response && typeof response.firstName === "string") { + const firstAndLastNameResponse = { + firstName: response.firstName, + lastName: "", + }; + if ("lastName" in response && typeof response.lastName === "string") { + firstAndLastNameResponse.lastName = response.lastName; + } + return preprocessNameFieldDataWithVariant(correctedVariant, firstAndLastNameResponse); + } + + return defaultValue; }, superRefine: ({ field, response, isPartialSchema, ctx, m }) => { const stringSchema = z.string(); @@ -176,7 +196,7 @@ export const fieldTypesSchemaMap: Partial< if (!variantSupportedFields.includes(subField.type)) { throw new Error(`Unsupported field.type with variants: ${subField.type}`); } - const valueIdentified = response as unknown as Record; + const valueIdentified = response as Record; if (subField.required) { if (!isPartialSchema && !valueIdentified[subField.name]) ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) }); @@ -187,10 +207,10 @@ export const fieldTypesSchemaMap: Partial< } }); }, - }, - textarea: { + }), + textarea: defineFieldSchema({ preprocess: ({ response }) => { - return response.trim(); + return stringifyResponse(response).trim(); }, superRefine: ({ field, response, ctx, m }) => { const fieldTypeConfig = fieldTypesConfigMap[field.type]; @@ -219,17 +239,17 @@ export const fieldTypesSchemaMap: Partial< return; } }, - }, - url: { + }), + url: defineFieldSchema({ preprocess: ({ response }) => { - return response.trim(); + return stringifyResponse(response).trim(); }, superRefine: ({ response, ctx, m }) => { const value = response ?? ""; const urlSchema = z.string().url(); // Check for malformed protocols (missing second slash test case) - if (value.match(/^https?:\/[^\/]/)) { + if (value.match(/^https?:\/[^/]/)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("url_validation_error"), @@ -257,5 +277,9 @@ export const fieldTypesSchemaMap: Partial< message: m("url_validation_error"), }); }, - }, + }), +}; + +export type FieldZodCtx = { + addIssue: (issue: z.IssueData) => void; };