just the currents, never the identities
1/**
2 * Event validation for Driftline Analytics
3 */
4
5import type { AnalyticsEvent, Environment, EventType } from "./types.ts";
6
7const VALID_ENVS: Environment[] = ["dev", "prod"];
8const VALID_TYPES: EventType[] = ["account", "view", "action"];
9const UID_PATTERN = /^[a-f0-9]{12}$/;
10
11export type ValidationResult =
12 | { valid: true; events: AnalyticsEvent[] }
13 | { valid: false; error: string };
14
15function isValidIsoDate(str: string): boolean {
16 const date = new Date(str);
17 return !isNaN(date.getTime()) && date.toISOString() === str;
18}
19
20function validateSingleEvent(event: unknown, index?: number): string | null {
21 const prefix = index !== undefined ? `Event[${index}]: ` : "";
22
23 if (!event || typeof event !== "object") {
24 return `${prefix}Event must be an object`;
25 }
26
27 const e = event as Record<string, unknown>;
28
29 if (e.v !== 1) {
30 return `${prefix}Version (v) must be 1`;
31 }
32
33 if (typeof e.appView !== "string" || e.appView.length === 0) {
34 return `${prefix}appView must be a non-empty string`;
35 }
36
37 if (!VALID_ENVS.includes(e.env as Environment)) {
38 return `${prefix}env must be 'dev' or 'prod'`;
39 }
40
41 if (typeof e.ts !== "string" || !isValidIsoDate(e.ts)) {
42 return `${prefix}ts must be a valid ISO timestamp`;
43 }
44
45 if (typeof e.uid !== "string" || !UID_PATTERN.test(e.uid)) {
46 return `${prefix}uid must be a 12-character hex string`;
47 }
48
49 if (!VALID_TYPES.includes(e.type as EventType)) {
50 return `${prefix}type must be 'account', 'view', or 'action'`;
51 }
52
53 if (typeof e.name !== "string" || e.name.length === 0) {
54 return `${prefix}name must be a non-empty string`;
55 }
56
57 if (e.screen !== undefined && typeof e.screen !== "string") {
58 return `${prefix}screen must be a string if provided`;
59 }
60
61 if (
62 e.props !== undefined && (typeof e.props !== "object" || e.props === null)
63 ) {
64 return `${prefix}props must be an object if provided`;
65 }
66
67 return null;
68}
69
70export function validateCollectRequest(body: unknown): ValidationResult {
71 if (!body || typeof body !== "object") {
72 return { valid: false, error: "Request body must be an object" };
73 }
74
75 const b = body as Record<string, unknown>;
76
77 // Check if it's a batch request
78 if ("events" in b && Array.isArray(b.events)) {
79 if (b.events.length === 0) {
80 return { valid: false, error: "Events array cannot be empty" };
81 }
82
83 if (b.events.length > 100) {
84 return { valid: false, error: "Maximum 100 events per request" };
85 }
86
87 const events: AnalyticsEvent[] = [];
88 for (let i = 0; i < b.events.length; i++) {
89 const error = validateSingleEvent(b.events[i], i);
90 if (error) {
91 return { valid: false, error };
92 }
93 events.push(b.events[i] as AnalyticsEvent);
94 }
95
96 return { valid: true, events };
97 }
98
99 // Single event
100 const error = validateSingleEvent(body);
101 if (error) {
102 return { valid: false, error };
103 }
104
105 return { valid: true, events: [body as AnalyticsEvent] };
106}
107
108export function validateAppViewMatch(
109 events: AnalyticsEvent[],
110 expectedAppView: string,
111): string | null {
112 for (let i = 0; i < events.length; i++) {
113 if (events[i].appView !== expectedAppView) {
114 return `Event[${i}]: appView '${
115 events[i].appView
116 }' does not match API key's app_view '${expectedAppView}'`;
117 }
118 }
119 return null;
120}