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:
10 files changed
+133
-39
No files matched your search
@@ -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}`;
|
||||
|
||||
@@ -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,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,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,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(
|
||||
|
||||
@@ -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)",
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user