fix: handle null/undefined tracking IDs in analytics schemas (#27625)

* fix(analytics): handle null/undefined tracking IDs in embed endpoint

PR 26976 introduced strict validation for analytics app schemas,
but this broke the embed endpoint when apps are installed with
null tracking IDs (apps enabled but not yet configured)

Changes:
- Add nullishToEmpty preprocessor to convert null/undefined to ""
- Update all analytics schemas to handle null gracefully
- Change GTM to allowEmpty: true for consistency
- Fix createPrefixedIdSchema to not add prefix to empty strings
- Add tests for null handling

* fix(analytics): add optional() to schemas for type compatibility

The preprocess schemas output string type but BookerEvent has
trackingId?: string | undefined. Adding .optional() makes the
types compatible while preserving null → "" conversion at runtime

* refactor: use z.union+transform for proper type inference

Replace z.preprocess() with z.union([string, null, undefined]).transform()
pattern. This ensures TypeScript correctly infers:
- Input: string | null | undefined
- Output: string (always)

Remove .optional() from tracking ID fields to output string, not string | undefined

* fix(analytics): make trackingId optional in metapixel and plausible schemas

Address Cubic AI review feedback (confidence 9/10) to keep trackingId
optional in the schema to avoid breaking existing payloads that omit
the field entirely. The union with null/undefined handles the value
transformation, but the property itself must be optional to allow
payloads without the key.

Co-Authored-By: unknown <>

* fix: restore .optional() for missing tracking fields

* fix: revert safeUrlSchema to maintain databuddy type compat

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-02-04 21:02:27 +00:00
1 parent 5d7738e39d
commit 04e828358a
10 files changed
+133 -39

No files matched your search

+25 -17
View File
@@ -1,5 +1,15 @@
import { z } from "zod";
/**
* Helper to create schemas that accept nullish values but always output string.
* Used for tracking IDs that may be null/undefined in the database.
*/
const nullishString = () =>
z
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim() : ""));
// URL schema: string input, validates http/https (kept as original to maintain type compatibility)
export const safeUrlSchema = z
.string()
.transform((val) => val.trim())
@@ -16,23 +26,19 @@ export const safeUrlSchema = z
{ message: "Invalid URL format. Must be a valid http or https URL" }
);
// Schema for tracking IDs that should only contain letters, numbers, underscores, and hyphens
export const alphanumericIdSchema = z
.string()
.transform((val) => val.trim())
.refine((val) => !val || /^[A-Za-z0-9_-]+$/.test(val), {
message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens",
});
// Alphanumeric ID schema - accepts nullish for tracking IDs
export const alphanumericIdSchema = nullishString().refine(
(val) => !val || /^[A-Za-z0-9_-]+$/.test(val),
{ message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens" }
);
// Schema for tracking IDs that should only contain digits
export const numericIdSchema = z
.string()
.transform((val) => val.trim())
.refine((val) => !val || /^[0-9]+$/.test(val), {
message: "Invalid ID format. Expected a numeric ID",
});
// Numeric ID schema - accepts nullish for tracking IDs
export const numericIdSchema = nullishString().refine(
(val) => !val || /^[0-9]+$/.test(val),
{ message: "Invalid ID format. Expected a numeric ID" }
);
// Factory for creating prefixed ID schemas (GTM, GA4, Fathom, etc.)
// Factory for prefixed ID schemas (GTM-, G-, etc.)
export const createPrefixedIdSchema = (options: {
prefix?: string;
addPrefixIfMissing?: boolean;
@@ -41,9 +47,11 @@ export const createPrefixedIdSchema = (options: {
const { prefix = "", addPrefixIfMissing = false, allowEmpty = true } = options;
return z
.string()
.transform((val) => {
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => {
if (typeof val !== "string") return "";
let result = val.trim().toUpperCase();
if (!result) return result;
if (prefix && addPrefixIfMissing) {
const clean = result.replace(new RegExp(`^${prefix}`, "i"), "");
result = `${prefix}${clean}`;
+95
View File
@@ -252,4 +252,99 @@ describe("Analytics Apps - Input Validation", () => {
}
});
});
// Null/undefined handling for embed endpoint (PR 26976)
// Schemas accept nullish input and always output string
describe("Null/Undefined Handling", () => {
it("GTM converts null to empty string", () => {
expect(gtmSchema.parse({ trackingId: null }).trackingId).toBe("");
});
it("GA4 converts null to empty string", () => {
expect(ga4Schema.parse({ trackingId: null }).trackingId).toBe("");
});
it("Meta Pixel converts null to empty string", () => {
expect(metapixelSchema.parse({ trackingId: null }).trackingId).toBe("");
});
it("PostHog converts null to empty string", () => {
expect(posthogSchema.parse({ TRACKING_ID: null }).TRACKING_ID).toBe("");
});
it("Fathom converts null to empty string", () => {
expect(fathomSchema.parse({ trackingId: null }).trackingId).toBe("");
});
it("Plausible converts null to empty string", () => {
expect(plausibleSchema.parse({ trackingId: null }).trackingId).toBe("");
});
it("Matomo converts null to empty string", () => {
expect(matomoSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
});
it("Umami converts null to empty string", () => {
expect(umamiSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
});
it("Twipla converts null to empty string", () => {
expect(twiplaSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
});
it("Insihts converts null to empty string", () => {
expect(insihtsSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
});
it("Databuddy converts null to empty string", () => {
expect(databuddySchema.parse({ CLIENT_ID: null }).CLIENT_ID).toBe("");
});
});
// Missing key handling - fields should be optional
describe("Missing Key Handling", () => {
it("GA4 accepts missing trackingId", () => {
expect(() => ga4Schema.parse({})).not.toThrow();
});
it("GTM accepts missing trackingId", () => {
expect(() => gtmSchema.parse({})).not.toThrow();
});
it("Meta Pixel accepts missing trackingId", () => {
expect(() => metapixelSchema.parse({})).not.toThrow();
});
it("Fathom accepts missing trackingId", () => {
expect(() => fathomSchema.parse({})).not.toThrow();
});
it("Plausible accepts missing trackingId", () => {
expect(() => plausibleSchema.parse({})).not.toThrow();
});
it("PostHog accepts missing TRACKING_ID", () => {
expect(() => posthogSchema.parse({})).not.toThrow();
});
it("Matomo accepts missing fields", () => {
expect(() => matomoSchema.parse({})).not.toThrow();
});
it("Umami accepts missing SITE_ID", () => {
expect(() => umamiSchema.parse({})).not.toThrow();
});
it("Twipla accepts missing SITE_ID", () => {
expect(() => twiplaSchema.parse({})).not.toThrow();
});
it("Insihts accepts missing fields", () => {
expect(() => insihtsSchema.parse({})).not.toThrow();
});
it("Databuddy accepts missing CLIENT_ID", () => {
expect(() => databuddySchema.parse({})).not.toThrow();
});
});
});
+1 -2
View File
@@ -1,12 +1,11 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: false }),
trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: true }),
})
);
-1
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { alphanumericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
export const appDataSchema = eventTypeAppCardZod.merge(
-1
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { numericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
export const appDataSchema = eventTypeAppCardZod.merge(
+2 -2
View File
@@ -4,8 +4,8 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
// Meta Pixel IDs are numeric strings of 15-16 digits
const metaPixelIdSchema = z
.string()
.transform((val) => val.trim())
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
.refine((val) => val === "" || /^[0-9]{15,16}$/.test(val), {
message: "Invalid Meta Pixel ID format. Expected a numeric ID (e.g., 1234567890123456)",
})
+3 -5
View File
@@ -5,14 +5,12 @@ import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
// Domain schema for Plausible tracking (e.g., example.com, sub.example.com)
const domainSchema = z
.string()
.transform((val) => val.trim().toLowerCase())
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim().toLowerCase() : ""))
.refine(
(val) =>
val === "" || /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(val),
{
message: "Invalid domain format. Expected format: example.com",
}
{ message: "Invalid domain format. Expected format: example.com" }
)
.optional();
+2 -3
View File
@@ -1,13 +1,12 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
// PostHog Project API Keys (typically start with phc_) - allow alphanumeric to not break legacy data
const posthogIdSchema = z
.string()
.transform((val) => val.trim())
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
.refine((val) => !val || /^[A-Za-z0-9_]+$/.test(val), {
message: "Invalid PostHog Project API Key format. Expected alphanumeric characters or underscores",
})
+2 -2
View File
@@ -4,8 +4,8 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
// Twipla Site IDs can be UUID or alphanumeric strings
const twiplaSiteIdSchema = z
.string()
.transform((val) => val.trim())
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
.refine((val) => !val || /^[A-Za-z0-9-]+$/.test(val), {
message: "Invalid Twipla Site ID format. Expected alphanumeric characters or UUID",
})
+3 -6
View File
@@ -1,21 +1,18 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
// Umami Website IDs: UUID in v2 (e.g., 4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b) or numeric in v1
const umamiSiteIdSchema = z
.string()
.transform((val) => val.trim().toLowerCase())
.union([z.string(), z.null(), z.undefined()])
.transform((val): string => (typeof val === "string" ? val.trim().toLowerCase() : ""))
.refine(
(val) =>
!val ||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(val) ||
/^[0-9]+$/.test(val),
{
message: "Invalid Umami Website ID format. Expected UUID or numeric ID",
}
{ message: "Invalid Umami Website ID format. Expected UUID or numeric ID" }
)
.optional();