From 04e828358a5703555b95e7ac12d7c88381978377 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Wed, 4 Feb 2026 18:02:27 -0300 Subject: [PATCH] fix: handle null/undefined tracking IDs in analytics schemas (#27625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- packages/app-store/_lib/analytics-schemas.ts | 42 +++++---- packages/app-store/analytics-apps.test.ts | 95 ++++++++++++++++++++ packages/app-store/gtm/zod.ts | 3 +- packages/app-store/insihts/zod.ts | 1 - packages/app-store/matomo/zod.ts | 1 - packages/app-store/metapixel/zod.ts | 4 +- packages/app-store/plausible/zod.ts | 8 +- packages/app-store/posthog/zod.ts | 5 +- packages/app-store/twipla/zod.ts | 4 +- packages/app-store/umami/zod.ts | 9 +- 10 files changed, 133 insertions(+), 39 deletions(-) diff --git a/packages/app-store/_lib/analytics-schemas.ts b/packages/app-store/_lib/analytics-schemas.ts index 5bc8466fcf..16b5edbf20 100644 --- a/packages/app-store/_lib/analytics-schemas.ts +++ b/packages/app-store/_lib/analytics-schemas.ts @@ -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}`; diff --git a/packages/app-store/analytics-apps.test.ts b/packages/app-store/analytics-apps.test.ts index 7b0b6fea95..0fe43255ca 100644 --- a/packages/app-store/analytics-apps.test.ts +++ b/packages/app-store/analytics-apps.test.ts @@ -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(); + }); + }); }); diff --git a/packages/app-store/gtm/zod.ts b/packages/app-store/gtm/zod.ts index 71ee5440ab..3e5a311676 100644 --- a/packages/app-store/gtm/zod.ts +++ b/packages/app-store/gtm/zod.ts @@ -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 }), }) ); diff --git a/packages/app-store/insihts/zod.ts b/packages/app-store/insihts/zod.ts index d8f0b3a24b..7c5431e483 100644 --- a/packages/app-store/insihts/zod.ts +++ b/packages/app-store/insihts/zod.ts @@ -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( diff --git a/packages/app-store/matomo/zod.ts b/packages/app-store/matomo/zod.ts index ee39ec1697..734829eebc 100644 --- a/packages/app-store/matomo/zod.ts +++ b/packages/app-store/matomo/zod.ts @@ -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( diff --git a/packages/app-store/metapixel/zod.ts b/packages/app-store/metapixel/zod.ts index 532f72d529..e4399565cd 100644 --- a/packages/app-store/metapixel/zod.ts +++ b/packages/app-store/metapixel/zod.ts @@ -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)", }) diff --git a/packages/app-store/plausible/zod.ts b/packages/app-store/plausible/zod.ts index df2c16b8bf..fbdc284322 100644 --- a/packages/app-store/plausible/zod.ts +++ b/packages/app-store/plausible/zod.ts @@ -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(); diff --git a/packages/app-store/posthog/zod.ts b/packages/app-store/posthog/zod.ts index 26ccc8cbba..b440b94cc0 100644 --- a/packages/app-store/posthog/zod.ts +++ b/packages/app-store/posthog/zod.ts @@ -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", }) diff --git a/packages/app-store/twipla/zod.ts b/packages/app-store/twipla/zod.ts index b337018986..c497d0c5a5 100644 --- a/packages/app-store/twipla/zod.ts +++ b/packages/app-store/twipla/zod.ts @@ -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", }) diff --git a/packages/app-store/umami/zod.ts b/packages/app-store/umami/zod.ts index 10b654ce66..5b39b25402 100644 --- a/packages/app-store/umami/zod.ts +++ b/packages/app-store/umami/zod.ts @@ -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();