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; };