Skip to content

Commit 04e8283

Browse files
fix: handle null/undefined tracking IDs in analytics schemas (calcom#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>
1 parent 5d7738e commit 04e8283

10 files changed

Lines changed: 133 additions & 39 deletions

File tree

packages/app-store/_lib/analytics-schemas.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { z } from "zod";
22

3+
/**
4+
* Helper to create schemas that accept nullish values but always output string.
5+
* Used for tracking IDs that may be null/undefined in the database.
6+
*/
7+
const nullishString = () =>
8+
z
9+
.union([z.string(), z.null(), z.undefined()])
10+
.transform((val): string => (typeof val === "string" ? val.trim() : ""));
11+
12+
// URL schema: string input, validates http/https (kept as original to maintain type compatibility)
313
export const safeUrlSchema = z
414
.string()
515
.transform((val) => val.trim())
@@ -16,23 +26,19 @@ export const safeUrlSchema = z
1626
{ message: "Invalid URL format. Must be a valid http or https URL" }
1727
);
1828

19-
// Schema for tracking IDs that should only contain letters, numbers, underscores, and hyphens
20-
export const alphanumericIdSchema = z
21-
.string()
22-
.transform((val) => val.trim())
23-
.refine((val) => !val || /^[A-Za-z0-9_-]+$/.test(val), {
24-
message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens",
25-
});
29+
// Alphanumeric ID schema - accepts nullish for tracking IDs
30+
export const alphanumericIdSchema = nullishString().refine(
31+
(val) => !val || /^[A-Za-z0-9_-]+$/.test(val),
32+
{ message: "Invalid ID format. Expected alphanumeric characters, underscores, or hyphens" }
33+
);
2634

27-
// Schema for tracking IDs that should only contain digits
28-
export const numericIdSchema = z
29-
.string()
30-
.transform((val) => val.trim())
31-
.refine((val) => !val || /^[0-9]+$/.test(val), {
32-
message: "Invalid ID format. Expected a numeric ID",
33-
});
35+
// Numeric ID schema - accepts nullish for tracking IDs
36+
export const numericIdSchema = nullishString().refine(
37+
(val) => !val || /^[0-9]+$/.test(val),
38+
{ message: "Invalid ID format. Expected a numeric ID" }
39+
);
3440

35-
// Factory for creating prefixed ID schemas (GTM, GA4, Fathom, etc.)
41+
// Factory for prefixed ID schemas (GTM-, G-, etc.)
3642
export const createPrefixedIdSchema = (options: {
3743
prefix?: string;
3844
addPrefixIfMissing?: boolean;
@@ -41,9 +47,11 @@ export const createPrefixedIdSchema = (options: {
4147
const { prefix = "", addPrefixIfMissing = false, allowEmpty = true } = options;
4248

4349
return z
44-
.string()
45-
.transform((val) => {
50+
.union([z.string(), z.null(), z.undefined()])
51+
.transform((val): string => {
52+
if (typeof val !== "string") return "";
4653
let result = val.trim().toUpperCase();
54+
if (!result) return result;
4755
if (prefix && addPrefixIfMissing) {
4856
const clean = result.replace(new RegExp(`^${prefix}`, "i"), "");
4957
result = `${prefix}${clean}`;

packages/app-store/analytics-apps.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,99 @@ describe("Analytics Apps - Input Validation", () => {
252252
}
253253
});
254254
});
255+
256+
// Null/undefined handling for embed endpoint (PR 26976)
257+
// Schemas accept nullish input and always output string
258+
describe("Null/Undefined Handling", () => {
259+
it("GTM converts null to empty string", () => {
260+
expect(gtmSchema.parse({ trackingId: null }).trackingId).toBe("");
261+
});
262+
263+
it("GA4 converts null to empty string", () => {
264+
expect(ga4Schema.parse({ trackingId: null }).trackingId).toBe("");
265+
});
266+
267+
it("Meta Pixel converts null to empty string", () => {
268+
expect(metapixelSchema.parse({ trackingId: null }).trackingId).toBe("");
269+
});
270+
271+
it("PostHog converts null to empty string", () => {
272+
expect(posthogSchema.parse({ TRACKING_ID: null }).TRACKING_ID).toBe("");
273+
});
274+
275+
it("Fathom converts null to empty string", () => {
276+
expect(fathomSchema.parse({ trackingId: null }).trackingId).toBe("");
277+
});
278+
279+
it("Plausible converts null to empty string", () => {
280+
expect(plausibleSchema.parse({ trackingId: null }).trackingId).toBe("");
281+
});
282+
283+
it("Matomo converts null to empty string", () => {
284+
expect(matomoSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
285+
});
286+
287+
it("Umami converts null to empty string", () => {
288+
expect(umamiSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
289+
});
290+
291+
it("Twipla converts null to empty string", () => {
292+
expect(twiplaSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
293+
});
294+
295+
it("Insihts converts null to empty string", () => {
296+
expect(insihtsSchema.parse({ SITE_ID: null }).SITE_ID).toBe("");
297+
});
298+
299+
it("Databuddy converts null to empty string", () => {
300+
expect(databuddySchema.parse({ CLIENT_ID: null }).CLIENT_ID).toBe("");
301+
});
302+
});
303+
304+
// Missing key handling - fields should be optional
305+
describe("Missing Key Handling", () => {
306+
it("GA4 accepts missing trackingId", () => {
307+
expect(() => ga4Schema.parse({})).not.toThrow();
308+
});
309+
310+
it("GTM accepts missing trackingId", () => {
311+
expect(() => gtmSchema.parse({})).not.toThrow();
312+
});
313+
314+
it("Meta Pixel accepts missing trackingId", () => {
315+
expect(() => metapixelSchema.parse({})).not.toThrow();
316+
});
317+
318+
it("Fathom accepts missing trackingId", () => {
319+
expect(() => fathomSchema.parse({})).not.toThrow();
320+
});
321+
322+
it("Plausible accepts missing trackingId", () => {
323+
expect(() => plausibleSchema.parse({})).not.toThrow();
324+
});
325+
326+
it("PostHog accepts missing TRACKING_ID", () => {
327+
expect(() => posthogSchema.parse({})).not.toThrow();
328+
});
329+
330+
it("Matomo accepts missing fields", () => {
331+
expect(() => matomoSchema.parse({})).not.toThrow();
332+
});
333+
334+
it("Umami accepts missing SITE_ID", () => {
335+
expect(() => umamiSchema.parse({})).not.toThrow();
336+
});
337+
338+
it("Twipla accepts missing SITE_ID", () => {
339+
expect(() => twiplaSchema.parse({})).not.toThrow();
340+
});
341+
342+
it("Insihts accepts missing fields", () => {
343+
expect(() => insihtsSchema.parse({})).not.toThrow();
344+
});
345+
346+
it("Databuddy accepts missing CLIENT_ID", () => {
347+
expect(() => databuddySchema.parse({})).not.toThrow();
348+
});
349+
});
255350
});

packages/app-store/gtm/zod.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
4-
54
import { createPrefixedIdSchema } from "@calcom/app-store/_lib/analytics-schemas";
65

76
export const appDataSchema = eventTypeAppCardZod.merge(
87
z.object({
9-
trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: false }),
8+
trackingId: createPrefixedIdSchema({ prefix: "GTM-", addPrefixIfMissing: true, allowEmpty: true }),
109
})
1110
);
1211

packages/app-store/insihts/zod.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
4-
54
import { alphanumericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
65

76
export const appDataSchema = eventTypeAppCardZod.merge(

packages/app-store/matomo/zod.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
4-
54
import { numericIdSchema, safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
65

76
export const appDataSchema = eventTypeAppCardZod.merge(

packages/app-store/metapixel/zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
44

55
// Meta Pixel IDs are numeric strings of 15-16 digits
66
const metaPixelIdSchema = z
7-
.string()
8-
.transform((val) => val.trim())
7+
.union([z.string(), z.null(), z.undefined()])
8+
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
99
.refine((val) => val === "" || /^[0-9]{15,16}$/.test(val), {
1010
message: "Invalid Meta Pixel ID format. Expected a numeric ID (e.g., 1234567890123456)",
1111
})

packages/app-store/plausible/zod.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@ import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
55

66
// Domain schema for Plausible tracking (e.g., example.com, sub.example.com)
77
const domainSchema = z
8-
.string()
9-
.transform((val) => val.trim().toLowerCase())
8+
.union([z.string(), z.null(), z.undefined()])
9+
.transform((val): string => (typeof val === "string" ? val.trim().toLowerCase() : ""))
1010
.refine(
1111
(val) =>
1212
val === "" || /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(val),
13-
{
14-
message: "Invalid domain format. Expected format: example.com",
15-
}
13+
{ message: "Invalid domain format. Expected format: example.com" }
1614
)
1715
.optional();
1816

packages/app-store/posthog/zod.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
4-
54
import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
65

76
// PostHog Project API Keys (typically start with phc_) - allow alphanumeric to not break legacy data
87
const posthogIdSchema = z
9-
.string()
10-
.transform((val) => val.trim())
8+
.union([z.string(), z.null(), z.undefined()])
9+
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
1110
.refine((val) => !val || /^[A-Za-z0-9_]+$/.test(val), {
1211
message: "Invalid PostHog Project API Key format. Expected alphanumeric characters or underscores",
1312
})

packages/app-store/twipla/zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
44

55
// Twipla Site IDs can be UUID or alphanumeric strings
66
const twiplaSiteIdSchema = z
7-
.string()
8-
.transform((val) => val.trim())
7+
.union([z.string(), z.null(), z.undefined()])
8+
.transform((val): string => (typeof val === "string" ? val.trim() : ""))
99
.refine((val) => !val || /^[A-Za-z0-9-]+$/.test(val), {
1010
message: "Invalid Twipla Site ID format. Expected alphanumeric characters or UUID",
1111
})

packages/app-store/umami/zod.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import { z } from "zod";
22

33
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
4-
54
import { safeUrlSchema } from "@calcom/app-store/_lib/analytics-schemas";
65

76
// Umami Website IDs: UUID in v2 (e.g., 4fb7fa4c-5b46-438d-94b3-3a8fb9bc2e8b) or numeric in v1
87
const umamiSiteIdSchema = z
9-
.string()
10-
.transform((val) => val.trim().toLowerCase())
8+
.union([z.string(), z.null(), z.undefined()])
9+
.transform((val): string => (typeof val === "string" ? val.trim().toLowerCase() : ""))
1110
.refine(
1211
(val) =>
1312
!val ||
1413
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(val) ||
1514
/^[0-9]+$/.test(val),
16-
{
17-
message: "Invalid Umami Website ID format. Expected UUID or numeric ID",
18-
}
15+
{ message: "Invalid Umami Website ID format. Expected UUID or numeric ID" }
1916
)
2017
.optional();
2118

0 commit comments

Comments
 (0)