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();