Files
cal.diy/packages/features/form-builder/schema.ts
T
Hariom BalharaandDevin AI ad65fbbd81 fix: change URL prefill behavior to skip only invalid fields (#26982)
* fix: change URL prefill behavior to skip only invalid fields

Previously, when prefilling booking form fields from URL query parameters,
if any single field failed validation, all prefilling was skipped and an
empty object was returned.

This change modifies the behavior so that only invalid fields are skipped,
while valid fields are still prefilled. The implementation:

- Adds validateSingleFieldForPrefill() to validate individual fields
- Adds parseFieldByField() to collect valid fields when full parse fails
- Modifies getBookingResponsesPartialSchema to use field-by-field parsing
  as a fallback when full schema validation fails
- Logs which specific fields were skipped and why

This improves user experience by preserving valid prefill data even when
some fields have invalid values.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: reduce code duplication in partial prefill validation

Simplify the implementation by integrating field-by-field error handling
directly into the existing preprocess() function instead of using separate
helper functions.

Changes:
- Remove validateSingleFieldForPrefill() and parseFieldByField() functions
- Simplify getBookingResponsesPartialSchema to directly use preprocess()
- Add try-catch wrapper in preprocess() when isPartialSchema=true
- Continue processing other fields even if one fails during preprocessing
- Log skipped fields with reasons at debug level

This reduces code duplication while maintaining the same behavior of
skipping only invalid fields during URL prefill.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fixes

* refactor: rename getRefinedValue to superRefineField and add edge case tests

- Rename getRefinedValue to superRefineField for clarity
- Remove duplicate multiselect test block
- Add test for empty object name field during partial prefill
- Add test for invalid URL format during partial prefill
- Add test for null responses during partial prefill

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Simplifu

* Simplif more

* Enhance booking responses schema tests to validate required fields and name variants. Updated error handling for empty names and added tests for first and last name requirements. Refactored schema validation logic to improve clarity and maintainability.

* fix types

* test: add test for email validation behavior during partial prefill

Documents that email validation is relaxed to z.string() during partial prefill,
consistent with phone field behavior where partial values like '+91' are accepted.
This addresses Cubic AI feedback (confidence 9/10) to verify email handling.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix

* Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts

Generated by Paragon from proposal for PR #26982

* Revert "Add tests: packages/features/bookings/lib/getBookingResponsesSchema.test.ts"

This reverts commit fc2b7ad7ca8099bc4f68e97b383ea16b41cdd772.

* Simplify

* fix: sanitize partial prefill log to avoid exposing user input data

Replaced raw Zod issues array in console.warn with a count of
validation errors to prevent sensitive user data from being logged.

Issue identified by cubic (confidence 9/10).

Co-Authored-By: unknown <>

* fix

* Remove throw Error

* Refactor booking responses schema tests to use helper functions for assertions

- Introduced `expectResponsesToBe` and `expectParsingToFail` helper functions to streamline test assertions.
- Replaced direct assertions with these helper functions for improved readability and maintainability.
- Removed redundant error handling code to simplify test logic.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-18 20:01:23 +05:30

286 lines
9.7 KiB
TypeScript

import { fieldSchema, fieldTypeEnum, variantsConfigSchema } from "@calcom/prisma/zod-utils";
import { z } from "zod";
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<TInput = unknown, TPreprocessedOutput = unknown> = {
preprocess: (data: {
field: z.infer<typeof fieldSchema>;
response: TInput;
isPartialSchema: boolean;
}) => TPreprocessedOutput;
superRefine: (data: {
field: z.infer<typeof fieldSchema>;
response: TPreprocessedOutput;
isPartialSchema: boolean;
ctx: FieldZodCtx;
m: (key: string, options?: Record<string, unknown>) => string;
}) => void;
};
function defineFieldSchema<TInput, TOutput>(
config: FieldTypeSchemaConfig<TInput, TOutput>
): FieldTypeSchemaConfig<TInput, TOutput> {
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.
// This allows making changes to the UI without having to make changes to the existing stored configs
export const fieldTypeConfigSchema = z
.object({
label: z.string(),
value: fieldTypeEnum,
isTextType: z.boolean().default(false).optional(),
systemOnly: z.boolean().default(false).optional(),
needsOptions: z.boolean().default(false).optional(),
supportsLengthCheck: z
.object({
maxLength: z.number(),
})
.optional(),
supportsPricing: z.boolean().default(false).optional(),
optionsSupportPricing: z.boolean().default(false).optional(),
propsType: z.enum([
"text",
"textList",
"select",
"multiselect",
"boolean",
"objectiveWithInput",
"variants",
]),
// It is the config that can tweak what an existing or a new field shows in the App UI or booker UI.
variantsConfig: z
.object({
/**
* This is the default variant that will be used when a new field is created.
*/
defaultVariant: z.string(),
/**
* Used only when there are 2 variants, so that UI can be simplified by showing a switch(with this label) instead of a Select
*/
toggleLabel: z.string().optional(),
variants: z.record(
z.object({
/**
* That's how the variant would be labelled in App UI. This label represents the field in booking questions' list
* Supports translation
*/
label: z.string(),
fieldsMap: z.record(
z.object({
/**
* Supports translation
*/
defaultLabel: z.string().optional(),
/**
* Supports translation
*/
defaultPlaceholder: z.string().optional(),
/**
* Decides if a variant field's required property can be changed or not
*/
canChangeRequirability: z.boolean().default(true).optional(),
})
),
})
),
/**
* This is the default configuration for the field.
*/
defaultValue: variantsConfigSchema.optional(),
})
.optional(),
})
.refine((data) => {
if (!data.variantsConfig) {
return;
}
const variantsConfig = data.variantsConfig;
if (!variantsConfig.variants[variantsConfig.defaultVariant]) {
throw new Error(`defaultVariant: ${variantsConfig.defaultVariant} is not in variants`);
}
return true;
});
export const fieldsSchema = z.array(fieldSchema);
function stringifyResponse(response: unknown): string {
if (typeof response !== "string") {
return String(response);
} else {
return response;
}
}
export const fieldTypesSchemaMap = {
name: defineFieldSchema<unknown, unknown>({
preprocess: ({ response, field }) => {
const fieldTypeConfig = fieldTypesConfigMap[field.type];
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
let correctedVariant: "firstAndLastName" | "fullName";
if (!variantInResponse) {
throw new Error("`variant` must be there for the field with `variantsConfig`");
}
if (variantInResponse !== "firstAndLastName" && variantInResponse !== "fullName") {
correctedVariant = "fullName";
} else {
correctedVariant = variantInResponse;
}
// 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();
const fieldTypeConfig = fieldTypesConfigMap[field.type];
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
if (!variantInResponse) {
throw new Error("`variant` must be there for the field with `variantsConfig`");
}
const variantsConfig = getVariantsConfig(field);
if (!variantsConfig) {
throw new Error("variantsConfig must be there for `name` field");
}
const fields =
variantsConfig.variants[variantInResponse as keyof typeof variantsConfig.variants].fields;
const variantSupportedFields = ["text"];
if (fields.length === 1) {
const field = fields[0];
if (variantSupportedFields.includes(field.type)) {
const schema = field.required && !isPartialSchema ? nonEmptyString() : stringSchema;
if (!schema.safeParse(response).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
}
return;
} else {
throw new Error(`Unsupported field.type with variants: ${field.type}`);
}
}
fields.forEach((subField) => {
const schema = subField.required && !isPartialSchema ? nonEmptyString() : stringSchema;
if (!variantSupportedFields.includes(subField.type)) {
throw new Error(`Unsupported field.type with variants: ${subField.type}`);
}
const valueIdentified = response as Record<string, string>;
if (subField.required) {
if (!isPartialSchema && !valueIdentified[subField.name])
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
if (!schema.safeParse(valueIdentified[subField.name]).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
return;
}
}
});
},
}),
textarea: defineFieldSchema<unknown, string>({
preprocess: ({ response }) => {
return stringifyResponse(response).trim();
},
superRefine: ({ field, response, ctx, m }) => {
const fieldTypeConfig = fieldTypesConfigMap[field.type];
const value = response ?? "";
const maxLength = field.maxLength ?? fieldTypeConfig.supportsLengthCheck?.maxLength;
const minLength = field.minLength ?? 0;
if (!maxLength) {
throw new Error("maxLength must be there for textarea field");
}
const hasExceededMaxLength = value.length > maxLength;
const hasNotReachedMinLength = value.length < minLength;
if (hasExceededMaxLength) {
const message = m(`max_characters_allowed`, { count: maxLength });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
});
return;
}
if (hasNotReachedMinLength) {
const message = m(`min_characters_required`, { count: minLength });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
});
return;
}
},
}),
url: defineFieldSchema<unknown, string>({
preprocess: ({ response }) => {
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?:\/[^/]/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("url_validation_error"),
});
return;
}
// 1. Try validating the original value
if (urlSchema.safeParse(value).success) {
return;
}
// 2. If it failed, try prepending https://
const domainLike = /^[a-z0-9.-]+\.[a-z]{2,}(\/.*)?$/i;
if (domainLike.test(value)) {
const valueWithHttps = `https://${value}`;
if (urlSchema.safeParse(valueWithHttps).success) {
return;
}
}
// 3. If all attempts fail, throw err
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("url_validation_error"),
});
},
}),
};
export type FieldZodCtx = {
addIssue: (issue: z.IssueData) => void;
};