/** * Event validation for Driftline Analytics */ import type { AnalyticsEvent, Environment, EventType } from "./types.ts"; const VALID_ENVS: Environment[] = ["dev", "prod"]; const VALID_TYPES: EventType[] = ["account", "view", "action"]; const UID_PATTERN = /^[a-f0-9]{12}$/; export type ValidationResult = | { valid: true; events: AnalyticsEvent[] } | { valid: false; error: string }; function isValidIsoDate(str: string): boolean { const date = new Date(str); return !isNaN(date.getTime()) && date.toISOString() === str; } function validateSingleEvent(event: unknown, index?: number): string | null { const prefix = index !== undefined ? `Event[${index}]: ` : ""; if (!event || typeof event !== "object") { return `${prefix}Event must be an object`; } const e = event as Record; if (e.v !== 1) { return `${prefix}Version (v) must be 1`; } if (typeof e.appView !== "string" || e.appView.length === 0) { return `${prefix}appView must be a non-empty string`; } if (!VALID_ENVS.includes(e.env as Environment)) { return `${prefix}env must be 'dev' or 'prod'`; } if (typeof e.ts !== "string" || !isValidIsoDate(e.ts)) { return `${prefix}ts must be a valid ISO timestamp`; } if (typeof e.uid !== "string" || !UID_PATTERN.test(e.uid)) { return `${prefix}uid must be a 12-character hex string`; } if (!VALID_TYPES.includes(e.type as EventType)) { return `${prefix}type must be 'account', 'view', or 'action'`; } if (typeof e.name !== "string" || e.name.length === 0) { return `${prefix}name must be a non-empty string`; } if (e.screen !== undefined && typeof e.screen !== "string") { return `${prefix}screen must be a string if provided`; } if ( e.props !== undefined && (typeof e.props !== "object" || e.props === null) ) { return `${prefix}props must be an object if provided`; } return null; } export function validateCollectRequest(body: unknown): ValidationResult { if (!body || typeof body !== "object") { return { valid: false, error: "Request body must be an object" }; } const b = body as Record; // Check if it's a batch request if ("events" in b && Array.isArray(b.events)) { if (b.events.length === 0) { return { valid: false, error: "Events array cannot be empty" }; } if (b.events.length > 100) { return { valid: false, error: "Maximum 100 events per request" }; } const events: AnalyticsEvent[] = []; for (let i = 0; i < b.events.length; i++) { const error = validateSingleEvent(b.events[i], i); if (error) { return { valid: false, error }; } events.push(b.events[i] as AnalyticsEvent); } return { valid: true, events }; } // Single event const error = validateSingleEvent(body); if (error) { return { valid: false, error }; } return { valid: true, events: [body as AnalyticsEvent] }; } export function validateAppViewMatch( events: AnalyticsEvent[], expectedAppView: string, ): string | null { for (let i = 0; i < events.length; i++) { if (events[i].appView !== expectedAppView) { return `Event[${i}]: appView '${ events[i].appView }' does not match API key's app_view '${expectedAppView}'`; } } return null; }