@orion-studios/cms 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,23 +12,158 @@ import {
12
12
  // src/server/routes.ts
13
13
  import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
14
14
 
15
+ // src/analytics/time.ts
16
+ var datePartsFormatter = (timeZone) => new Intl.DateTimeFormat("en-US", {
17
+ timeZone,
18
+ year: "numeric",
19
+ month: "2-digit",
20
+ day: "2-digit",
21
+ hour: "2-digit",
22
+ minute: "2-digit",
23
+ second: "2-digit",
24
+ hourCycle: "h23"
25
+ });
26
+ function isValidTimeZone(timeZone) {
27
+ try {
28
+ datePartsFormatter(timeZone).format(/* @__PURE__ */ new Date(0));
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ function normalizeTimeZone(timeZone) {
35
+ const value = timeZone?.trim() || "UTC";
36
+ if (!isValidTimeZone(value)) {
37
+ throw new Error(`Invalid analytics time zone: ${value}. Use an IANA name such as America/Chicago.`);
38
+ }
39
+ return value;
40
+ }
41
+ function localParts(date, timeZone) {
42
+ const values = new Map(
43
+ datePartsFormatter(timeZone).formatToParts(date).filter((part) => part.type !== "literal").map((part) => [part.type, Number(part.value)])
44
+ );
45
+ return {
46
+ year: values.get("year") || 0,
47
+ month: values.get("month") || 0,
48
+ day: values.get("day") || 0,
49
+ hour: values.get("hour") || 0,
50
+ minute: values.get("minute") || 0,
51
+ second: values.get("second") || 0,
52
+ millisecond: date.getUTCMilliseconds()
53
+ };
54
+ }
55
+ function dateKey(parts) {
56
+ return `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
57
+ }
58
+ function dateKeyInTimeZone(value, timeZone) {
59
+ const date = value instanceof Date ? value : new Date(value);
60
+ return dateKey(localParts(date, timeZone));
61
+ }
62
+ function hourInTimeZone(value, timeZone) {
63
+ const date = value instanceof Date ? value : new Date(value);
64
+ return localParts(date, timeZone).hour;
65
+ }
66
+ function addDays(key, days) {
67
+ const [year, month, day] = key.split("-").map(Number);
68
+ const date = new Date(Date.UTC(year, month - 1, day + days));
69
+ return date.toISOString().slice(0, 10);
70
+ }
71
+ function localDateTimeUtc(parts, timeZone) {
72
+ const desired = Date.UTC(
73
+ parts.year,
74
+ parts.month - 1,
75
+ parts.day,
76
+ parts.hour,
77
+ parts.minute,
78
+ parts.second,
79
+ parts.millisecond
80
+ );
81
+ let instant = desired;
82
+ for (let attempt = 0; attempt < 3; attempt += 1) {
83
+ const observed = localParts(new Date(instant), timeZone);
84
+ const observedAsUtc = Date.UTC(
85
+ observed.year,
86
+ observed.month - 1,
87
+ observed.day,
88
+ observed.hour,
89
+ observed.minute,
90
+ observed.second,
91
+ observed.millisecond
92
+ );
93
+ instant += desired - observedAsUtc;
94
+ }
95
+ return new Date(instant);
96
+ }
97
+ function localMidnightUtc(key, timeZone) {
98
+ const [year, month, day] = key.split("-").map(Number);
99
+ return localDateTimeUtc({ year, month, day, hour: 0, minute: 0, second: 0, millisecond: 0 }, timeZone);
100
+ }
101
+ function shiftLocalDays(date, days, timeZone) {
102
+ const current = localParts(date, timeZone);
103
+ const shifted = new Date(Date.UTC(
104
+ current.year,
105
+ current.month - 1,
106
+ current.day + days,
107
+ current.hour,
108
+ current.minute,
109
+ current.second,
110
+ current.millisecond
111
+ ));
112
+ return localDateTimeUtc({
113
+ year: shifted.getUTCFullYear(),
114
+ month: shifted.getUTCMonth() + 1,
115
+ day: shifted.getUTCDate(),
116
+ hour: shifted.getUTCHours(),
117
+ minute: shifted.getUTCMinutes(),
118
+ second: shifted.getUTCSeconds(),
119
+ millisecond: shifted.getUTCMilliseconds()
120
+ }, timeZone);
121
+ }
122
+ function calendarRange(days, timeZone, now = /* @__PURE__ */ new Date()) {
123
+ if (!Number.isInteger(days) || days < 1 || days > 370) {
124
+ throw new Error("Analytics days must be an integer from 1 through 370.");
125
+ }
126
+ const today = dateKeyInTimeZone(now, timeZone);
127
+ const fromKey = addDays(today, -(days - 1));
128
+ const previousFromKey = addDays(fromKey, -days);
129
+ const fromDate = localMidnightUtc(fromKey, timeZone);
130
+ return {
131
+ from: fromDate.toISOString(),
132
+ to: now.toISOString(),
133
+ previousFrom: localMidnightUtc(previousFromKey, timeZone).toISOString(),
134
+ previousTo: shiftLocalDays(now, -days, timeZone).toISOString()
135
+ };
136
+ }
137
+ function dateKeysInRange(from, to, timeZone) {
138
+ const first = dateKeyInTimeZone(from, timeZone);
139
+ const last = dateKeyInTimeZone(to, timeZone);
140
+ const keys = [];
141
+ for (let key = first; key <= last && keys.length <= 370; key = addDays(key, 1)) keys.push(key);
142
+ return keys;
143
+ }
144
+
15
145
  // src/analytics/aggregate.ts
16
146
  var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
17
- function normalizeSource(utmSource, referrer) {
147
+ function normalizeSource(utmSource, referrer, utmMedium = "") {
18
148
  const tag = utmSource.trim().toLowerCase();
149
+ const medium = utmMedium.trim().toLowerCase();
19
150
  if (tag) {
20
151
  if (["gbp", "gmb", "google-business", "google_business", "business.google.com"].includes(tag)) {
21
152
  return "Google Business Profile";
22
153
  }
23
- if (tag === "google") return "Google";
154
+ if (tag === "google") {
155
+ if (["cpc", "ppc", "paid", "paidsearch", "paid-search"].includes(medium)) return "Google Ads";
156
+ if (medium === "organic") return "Google search";
157
+ return "Google (unclassified)";
158
+ }
24
159
  if (tag === "facebook" || tag === "fb") return "Facebook";
25
160
  if (tag === "instagram" || tag === "ig") return "Instagram";
26
161
  return utmSource.trim();
27
162
  }
28
163
  const host = referrer.trim().toLowerCase();
29
- if (!host) return "direct";
164
+ if (!host) return "No referrer / direct";
30
165
  if (host === "business.google.com") return "Google Business Profile";
31
- if (host.includes("google.")) return "Google search";
166
+ if (host.includes("google.")) return "Google (unclassified)";
32
167
  if (host.includes("bing.")) return "Bing";
33
168
  if (host.includes("duckduckgo.")) return "DuckDuckGo";
34
169
  if (host.includes("yahoo.")) return "Yahoo";
@@ -40,12 +175,11 @@ function normalizeSource(utmSource, referrer) {
40
175
  if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
41
176
  return host;
42
177
  }
43
- var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
178
+ var isFormSubmitEvent = (event) => event.type === "form" && event.name.endsWith(":submit");
179
+ var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || isFormSubmitEvent(event));
44
180
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
45
- function computeKpis(events) {
181
+ function computeKpis(events, submissions) {
46
182
  const sessions = /* @__PURE__ */ new Set();
47
- const visitors = /* @__PURE__ */ new Set();
48
- const visitorSessions = /* @__PURE__ */ new Map();
49
183
  const converting = /* @__PURE__ */ new Set();
50
184
  let pageviews = 0;
51
185
  let calls = 0;
@@ -54,61 +188,122 @@ function computeKpis(events) {
54
188
  let portalClicks = 0;
55
189
  for (const event of events) {
56
190
  if (event.session_key) sessions.add(event.session_key);
57
- const identity = event.visitor_key || event.session_key;
58
- if (identity) visitors.add(identity);
59
- if (event.visitor_key && event.session_key) {
60
- const keys = visitorSessions.get(event.visitor_key) || /* @__PURE__ */ new Set();
61
- keys.add(event.session_key);
62
- visitorSessions.set(event.visitor_key, keys);
63
- }
64
191
  if (event.type === "pageview") pageviews += 1;
65
192
  if (event.type === "click" && event.name === "call") calls += 1;
66
193
  if (event.type === "click" && event.name === "email") emails += 1;
67
194
  if (event.type === "click" && event.name === "portal") portalClicks += 1;
68
- if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
69
- formSubmits += 1;
70
- }
195
+ if (event.server_verified === true && isFormSubmitEvent(event)) formSubmits += 1;
71
196
  if (isConversion(event) && event.session_key) converting.add(event.session_key);
72
197
  }
73
- const conversions = events.filter(isConversion).length;
74
- const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
198
+ for (const submission of submissions || []) {
199
+ if (submission.session_key && sessions.has(submission.session_key)) {
200
+ converting.add(submission.session_key);
201
+ }
202
+ }
203
+ const attributedSubmits = (submissions || []).filter(
204
+ (submission) => Boolean(submission.session_key && sessions.has(submission.session_key))
205
+ ).length;
206
+ const conversions = events.filter(isConversion).length + (submissions?.length ?? 0);
207
+ const visitorMetrics = computeVisitorMetrics(events);
75
208
  return {
76
- visitors: visitors.size,
77
- identifiedVisitors: visitorSessions.size,
78
- returningVisitors,
209
+ visitors: visitorMetrics.visitors,
210
+ identifiedVisitors: visitorMetrics.identifiedVisitors,
211
+ returningVisitors: visitorMetrics.returningVisitors,
79
212
  sessions: sessions.size,
80
213
  pageviews,
81
- pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
214
+ pagesPerVisitor: visitorMetrics.visitors > 0 ? pageviews / visitorMetrics.visitors : 0,
82
215
  calls,
83
- formSubmits,
216
+ formSubmits: submissions?.length ?? formSubmits,
84
217
  emails,
85
218
  portalClicks,
86
219
  conversions,
87
- conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0
220
+ conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0,
221
+ attributedSubmits,
222
+ unattributedSubmits: (submissions?.length ?? 0) - attributedSubmits
88
223
  };
89
224
  }
90
- function aggregateAnalytics(events, previousEvents, range) {
91
- const kpis = computeKpis(events);
92
- const previous = computeKpis(previousEvents);
93
- const byDay = /* @__PURE__ */ new Map();
94
- const dayOf = (iso) => iso.slice(0, 10);
225
+ function computeVisitorMetrics(events) {
226
+ const parents = /* @__PURE__ */ new Map();
227
+ const ensure = (node) => {
228
+ if (!parents.has(node)) parents.set(node, node);
229
+ };
230
+ const find = (node) => {
231
+ const parent = parents.get(node) || node;
232
+ if (parent === node) return node;
233
+ const root = find(parent);
234
+ parents.set(node, root);
235
+ return root;
236
+ };
237
+ const union = (left, right) => {
238
+ ensure(left);
239
+ ensure(right);
240
+ const leftRoot = find(left);
241
+ const rightRoot = find(right);
242
+ if (leftRoot !== rightRoot) parents.set(rightRoot, leftRoot);
243
+ };
244
+ const sessionNodes = /* @__PURE__ */ new Set();
245
+ const visitorNodes = /* @__PURE__ */ new Set();
95
246
  for (const event of events) {
247
+ const sessionNode = event.session_key ? `session:${event.session_key}` : "";
248
+ const visitorNode = event.visitor_key ? `visitor:${event.visitor_key}` : "";
249
+ if (sessionNode) {
250
+ ensure(sessionNode);
251
+ sessionNodes.add(sessionNode);
252
+ }
253
+ if (visitorNode) {
254
+ ensure(visitorNode);
255
+ visitorNodes.add(visitorNode);
256
+ }
257
+ if (sessionNode && visitorNode) union(sessionNode, visitorNode);
258
+ }
259
+ const roots = new Set([...parents.keys()].map(find));
260
+ const identifiedRoots = new Set([...visitorNodes].map(find));
261
+ const sessionsPerRoot = /* @__PURE__ */ new Map();
262
+ for (const sessionNode of sessionNodes) {
263
+ const root = find(sessionNode);
264
+ sessionsPerRoot.set(root, (sessionsPerRoot.get(root) || 0) + 1);
265
+ }
266
+ const returningVisitors = [...identifiedRoots].filter(
267
+ (root) => (sessionsPerRoot.get(root) || 0) > 1
268
+ ).length;
269
+ return {
270
+ visitors: roots.size,
271
+ identifiedVisitors: identifiedRoots.size,
272
+ returningVisitors
273
+ };
274
+ }
275
+ function aggregateAnalytics(events, previousEvents, range, submissions, previousSubmissions, timeZone = "UTC") {
276
+ const trafficEvents = submissions === void 0 ? events : events.filter((event) => !isFormSubmitEvent(event));
277
+ const previousTrafficEvents = previousSubmissions === void 0 ? previousEvents : previousEvents.filter((event) => !isFormSubmitEvent(event));
278
+ const kpis = computeKpis(trafficEvents, submissions);
279
+ const previous = computeKpis(previousTrafficEvents, previousSubmissions);
280
+ const byDay = /* @__PURE__ */ new Map();
281
+ const summaryTimeZone = normalizeTimeZone(timeZone);
282
+ const dayOf = (iso) => dateKeyInTimeZone(iso, summaryTimeZone);
283
+ for (const event of trafficEvents) {
96
284
  const day = dayOf(event.created_at);
97
- const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
98
- const identity = event.visitor_key || event.session_key;
99
- if (identity) entry.visitors.add(identity);
285
+ const entry = byDay.get(day) || { events: [], conversions: 0 };
286
+ entry.events.push(event);
100
287
  if (isConversion(event)) entry.conversions += 1;
101
288
  byDay.set(day, entry);
102
289
  }
290
+ for (const submission of submissions || []) {
291
+ const day = dayOf(submission.created_at);
292
+ const entry = byDay.get(day) || { events: [], conversions: 0 };
293
+ entry.conversions += 1;
294
+ byDay.set(day, entry);
295
+ }
103
296
  const trend = [];
104
- for (let cursor = /* @__PURE__ */ new Date(`${dayOf(range.from)}T00:00:00Z`); cursor.toISOString().slice(0, 10) <= dayOf(range.to); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
105
- const day = cursor.toISOString().slice(0, 10);
297
+ for (const day of dateKeysInRange(range.from, range.to, summaryTimeZone)) {
106
298
  const entry = byDay.get(day);
107
- trend.push({ day, visitors: entry?.visitors.size ?? 0, conversions: entry?.conversions ?? 0 });
108
- if (trend.length > 370) break;
299
+ trend.push({
300
+ day,
301
+ visitors: entry ? computeVisitorMetrics(entry.events).visitors : 0,
302
+ conversions: entry?.conversions ?? 0
303
+ });
109
304
  }
110
305
  const bySession = /* @__PURE__ */ new Map();
111
- for (const event of events) {
306
+ for (const event of trafficEvents) {
112
307
  if (!event.session_key) continue;
113
308
  const list = bySession.get(event.session_key) || [];
114
309
  list.push(event);
@@ -117,27 +312,39 @@ function aggregateAnalytics(events, previousEvents, range) {
117
312
  for (const list of bySession.values()) {
118
313
  list.sort((a, b) => a.created_at.localeCompare(b.created_at));
119
314
  }
315
+ const convertedSessionKeys = /* @__PURE__ */ new Set();
316
+ for (const event of trafficEvents) {
317
+ if (isConversion(event) && event.session_key) convertedSessionKeys.add(event.session_key);
318
+ }
319
+ for (const submission of submissions || []) {
320
+ if (submission.session_key) convertedSessionKeys.add(submission.session_key);
321
+ }
120
322
  const pages = /* @__PURE__ */ new Map();
121
323
  const pageEntry = (path) => {
122
324
  const entry = pages.get(path) || { views: 0, entries: 0, conversions: 0 };
123
325
  pages.set(path, entry);
124
326
  return entry;
125
327
  };
126
- for (const event of events) {
328
+ for (const event of trafficEvents) {
127
329
  if (event.type === "pageview") pageEntry(event.path).views += 1;
128
330
  if (isConversion(event)) pageEntry(event.path).conversions += 1;
129
331
  }
332
+ for (const submission of submissions || []) {
333
+ const list = bySession.get(submission.session_key);
334
+ const lastPageview = list?.filter((event) => event.type === "pageview" && event.created_at <= submission.created_at).at(-1);
335
+ if (lastPageview) pageEntry(lastPageview.path).conversions += 1;
336
+ }
130
337
  for (const list of bySession.values()) {
131
338
  const first = list.find((event) => event.type === "pageview");
132
339
  if (first) pageEntry(first.path).entries += 1;
133
340
  }
134
341
  const sources = /* @__PURE__ */ new Map();
135
- for (const list of bySession.values()) {
342
+ for (const [sessionKey, list] of bySession.entries()) {
136
343
  const first = list.find((event) => event.type === "pageview");
137
- const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
344
+ const label = normalizeSource(first?.utm?.source || "", first?.referrer || "", first?.utm?.medium || "");
138
345
  const entry = sources.get(label) || { sessions: 0, conversions: 0 };
139
346
  entry.sessions += 1;
140
- if (list.some(isConversion)) entry.conversions += 1;
347
+ if (convertedSessionKeys.has(sessionKey)) entry.conversions += 1;
141
348
  sources.set(label, entry);
142
349
  }
143
350
  const locations = /* @__PURE__ */ new Map();
@@ -149,13 +356,13 @@ function aggregateAnalytics(events, previousEvents, range) {
149
356
  if (sample.device) devices.set(sample.device, (devices.get(sample.device) || 0) + 1);
150
357
  }
151
358
  const hours = new Array(24).fill(0);
152
- for (const event of events) {
359
+ for (const event of trafficEvents) {
153
360
  if (event.type !== "pageview") continue;
154
- const hour = new Date(event.created_at).getUTCHours();
361
+ const hour = hourInTimeZone(event.created_at, summaryTimeZone);
155
362
  if (Number.isFinite(hour)) hours[hour] += 1;
156
363
  }
157
364
  const pathCounts = /* @__PURE__ */ new Map();
158
- for (const list of bySession.values()) {
365
+ for (const [sessionKey, list] of bySession.entries()) {
159
366
  const steps = [];
160
367
  for (const event of list) {
161
368
  if (event.type !== "pageview") continue;
@@ -166,25 +373,50 @@ function aggregateAnalytics(events, previousEvents, range) {
166
373
  const key = steps.join(" \u2192 ");
167
374
  const entry = pathCounts.get(key) || { count: 0, converted: 0 };
168
375
  entry.count += 1;
169
- if (list.some(isConversion)) entry.converted += 1;
376
+ if (convertedSessionKeys.has(sessionKey)) entry.converted += 1;
170
377
  pathCounts.set(key, entry);
171
378
  }
172
379
  const forms = /* @__PURE__ */ new Map();
173
- for (const event of events) {
380
+ const formEvidence = (form, sessionKey) => {
381
+ const sessions = forms.get(form) || /* @__PURE__ */ new Map();
382
+ forms.set(form, sessions);
383
+ const evidence = sessions.get(sessionKey) || { viewed: false, start: false, submit: false };
384
+ sessions.set(sessionKey, evidence);
385
+ return evidence;
386
+ };
387
+ for (const event of trafficEvents) {
174
388
  if (event.type !== "form") continue;
175
389
  const [slug, stage] = event.name.split(":");
176
390
  if (!slug || !stage) continue;
177
- const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
178
- if (stage === "view") entry.views += 1;
179
- if (stage === "start") entry.starts += 1;
180
- if (stage === "submit" && event.server_verified === true) entry.submits += 1;
181
- forms.set(slug, entry);
391
+ if (stage !== "viewed" && stage !== "start" && stage !== "submit") continue;
392
+ const sessionKey = event.session_key || `event:${String(event.id ?? event.created_at)}`;
393
+ const evidence = formEvidence(slug, sessionKey);
394
+ if (stage === "viewed") evidence.viewed = true;
395
+ if (stage === "start") evidence.start = true;
396
+ if (stage === "submit" && event.server_verified === true) evidence.submit = true;
397
+ }
398
+ for (const [index, submission] of (submissions || []).entries()) {
399
+ const sessionKey = submission.session_key || `submission:${String(submission.id ?? index)}`;
400
+ formEvidence(submission.form, sessionKey).submit = true;
401
+ }
402
+ const formSummary = [];
403
+ for (const [form, sessions] of forms.entries()) {
404
+ let views = 0;
405
+ let starts = 0;
406
+ let submits = 0;
407
+ for (const evidence of sessions.values()) {
408
+ if (evidence.viewed || evidence.start || evidence.submit) views += 1;
409
+ if (evidence.start || evidence.submit) starts += 1;
410
+ if (evidence.submit) submits += 1;
411
+ }
412
+ formSummary.push({ form, views, starts, submits });
182
413
  }
183
414
  const notFound = /* @__PURE__ */ new Map();
184
- for (const event of events) {
415
+ for (const event of trafficEvents) {
185
416
  if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
186
417
  }
187
418
  return {
419
+ timeZone: summaryTimeZone,
188
420
  range,
189
421
  kpis,
190
422
  previous,
@@ -198,7 +430,7 @@ function aggregateAnalytics(events, previousEvents, range) {
198
430
  path: key.split(" \u2192 "),
199
431
  ...data
200
432
  })),
201
- forms: [...forms.entries()].map(([form, data]) => ({ form, ...data })),
433
+ forms: formSummary,
202
434
  notFound: top(notFound, 20, (count) => count).map(([path, count]) => ({ path, count }))
203
435
  };
204
436
  }
@@ -209,8 +441,8 @@ var EVENT_TYPES = ["pageview", "click", "form", "not_found"];
209
441
  var MAX_EVENTS_PER_BATCH = 20;
210
442
  var MAX_TEXT = 300;
211
443
  var MAX_META_JSON = 1e3;
212
- function sessionKeyFor(ip, userAgent, secret, now = /* @__PURE__ */ new Date()) {
213
- const day = now.toISOString().slice(0, 10);
444
+ function sessionKeyFor(ip, userAgent, secret, now = /* @__PURE__ */ new Date(), timeZone = "UTC") {
445
+ const day = dateKeyInTimeZone(now, normalizeTimeZone(timeZone));
214
446
  return createHash("sha256").update(`${secret}|${day}|${ip}|${userAgent}`).digest("hex").slice(0, 24);
215
447
  }
216
448
  function visitorKeyFor(visitorId, secret) {
@@ -1170,6 +1402,7 @@ function createCmsRoutes(options) {
1170
1402
  };
1171
1403
  const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
1172
1404
  const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
1405
+ const analyticsTimeZone = normalizeTimeZone(options.analyticsTimeZone);
1173
1406
  const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
1174
1407
  const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
1175
1408
  const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
@@ -1252,7 +1485,13 @@ function createCmsRoutes(options) {
1252
1485
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
1253
1486
  const requestSessionKey = (request) => {
1254
1487
  if (!analyticsSecret) return "";
1255
- return sessionKeyFor(clientKey(request), request.headers.get("user-agent") || "", analyticsSecret);
1488
+ return sessionKeyFor(
1489
+ clientKey(request),
1490
+ request.headers.get("user-agent") || "",
1491
+ analyticsSecret,
1492
+ /* @__PURE__ */ new Date(),
1493
+ analyticsTimeZone
1494
+ );
1256
1495
  };
1257
1496
  const guard = async (request, action) => {
1258
1497
  if (hostedMemoryMode) return errors.unauthorized();
@@ -2231,10 +2470,12 @@ function createCmsRoutes(options) {
2231
2470
  }
2232
2471
  }
2233
2472
  try {
2234
- await db().from("cms_events").insert(
2473
+ const { error } = await db().from("cms_events").insert(
2235
2474
  rows.map((row) => ({ ...row, server_verified: false }))
2236
2475
  );
2476
+ if (error) console.error("CMS analytics ingest failed.");
2237
2477
  } catch {
2478
+ console.error("CMS analytics ingest failed.");
2238
2479
  }
2239
2480
  }
2240
2481
  return json({ success: true });
@@ -2243,30 +2484,99 @@ function createCmsRoutes(options) {
2243
2484
  const all = [];
2244
2485
  let cursor = 0;
2245
2486
  for (let page = 0; page < 60; page += 1) {
2246
- const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, server_verified, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
2247
- if (error || !data || data.length === 0) break;
2487
+ const { data, error } = await db().from("cms_events").select("id, session_key, visitor_key, type, name, path, referrer, utm, device, region, city, meta, server_verified, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
2488
+ if (error) throw new Error("Analytics event read failed.");
2489
+ if (!data || data.length === 0) return all;
2248
2490
  all.push(...data);
2249
2491
  cursor = Number(data[data.length - 1].id);
2250
- if (data.length < 1e3) break;
2492
+ if (data.length < 1e3) return all;
2251
2493
  }
2252
- return all;
2494
+ throw new Error("Analytics event read exceeded the supported row limit.");
2495
+ };
2496
+ const fetchFormSubmissions = async (fromIso, toIso) => {
2497
+ const all = [];
2498
+ let cursor = 0;
2499
+ for (let page = 0; page < 60; page += 1) {
2500
+ const { data, error } = await db().from("cms_form_submissions").select("id, form_id, session_key, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
2501
+ if (error) throw new Error("Analytics submission read failed.");
2502
+ if (!data || data.length === 0) return all;
2503
+ all.push(...data);
2504
+ cursor = Number(data[data.length - 1].id);
2505
+ if (data.length < 1e3) return all;
2506
+ }
2507
+ throw new Error("Analytics submission read exceeded the supported row limit.");
2508
+ };
2509
+ const fetchFormSlugs = async () => {
2510
+ const { data, error } = await db().from("cms_forms").select("id, slug").limit(1e3);
2511
+ const slugs = /* @__PURE__ */ new Map();
2512
+ if (error) throw new Error("Analytics form label read failed.");
2513
+ if (!data) return slugs;
2514
+ for (const form of data) {
2515
+ const id = String(form.id || "");
2516
+ const slug = String(form.slug || "");
2517
+ if (id && slug) slugs.set(id, slug);
2518
+ }
2519
+ return slugs;
2253
2520
  };
2254
2521
  const getAnalytics = async (request) => {
2255
2522
  const auth = await guard(request, "analytics.read");
2256
2523
  if (auth instanceof Response) return auth;
2257
2524
  const url = new URL(request.url);
2258
- const toMs = Date.parse(url.searchParams.get("to") || "") || Date.now();
2259
- const defaultFrom = toMs - 30 * 864e5;
2260
- const fromMs = Math.min(Date.parse(url.searchParams.get("from") || "") || defaultFrom, toMs);
2261
- const windowMs = Math.max(toMs - fromMs, 864e5);
2262
- const from = new Date(fromMs).toISOString();
2263
- const to = new Date(toMs).toISOString();
2264
- const previousFrom = new Date(fromMs - windowMs).toISOString();
2265
- const [events, previousEvents] = await Promise.all([
2266
- fetchEvents(from, to),
2267
- fetchEvents(previousFrom, from)
2268
- ]);
2269
- return json(aggregateAnalytics(events, previousEvents, { from, to }));
2525
+ const requestedDays = Number(url.searchParams.get("days") || "");
2526
+ let from;
2527
+ let to;
2528
+ let previousFrom;
2529
+ let previousTo;
2530
+ if (Number.isInteger(requestedDays) && requestedDays >= 1 && requestedDays <= 370) {
2531
+ ({ from, to, previousFrom, previousTo } = calendarRange(requestedDays, analyticsTimeZone));
2532
+ } else {
2533
+ const toMs = Date.parse(url.searchParams.get("to") || "") || Date.now();
2534
+ const defaultFrom = toMs - 30 * 864e5;
2535
+ const fromMs2 = Math.min(Date.parse(url.searchParams.get("from") || "") || defaultFrom, toMs);
2536
+ const windowMs = Math.max(toMs - fromMs2, 864e5);
2537
+ from = new Date(fromMs2).toISOString();
2538
+ to = new Date(toMs).toISOString();
2539
+ previousFrom = new Date(fromMs2 - windowMs).toISOString();
2540
+ previousTo = new Date(fromMs2 - 1).toISOString();
2541
+ }
2542
+ let events;
2543
+ let previousEvents;
2544
+ let submissionRows;
2545
+ let formSlugs;
2546
+ try {
2547
+ [events, previousEvents, submissionRows, formSlugs] = await Promise.all([
2548
+ fetchEvents(from, to),
2549
+ fetchEvents(previousFrom, previousTo),
2550
+ fetchFormSubmissions(previousFrom, to),
2551
+ fetchFormSlugs()
2552
+ ]);
2553
+ } catch {
2554
+ console.error("CMS analytics summary read failed.");
2555
+ return json({ error: "Analytics data is temporarily unavailable. No partial totals were shown." }, 503);
2556
+ }
2557
+ const fromMs = Date.parse(from);
2558
+ const submissions = submissionRows.map((submission) => ({
2559
+ id: submission.id,
2560
+ form: formSlugs.get(String(submission.form_id)) || String(submission.form_id),
2561
+ session_key: String(submission.session_key || ""),
2562
+ created_at: String(submission.created_at)
2563
+ }));
2564
+ const currentSubmissions = submissions.filter(
2565
+ (submission) => Date.parse(submission.created_at) >= fromMs
2566
+ );
2567
+ const previousSubmissions = submissions.filter(
2568
+ (submission) => Date.parse(submission.created_at) <= Date.parse(previousTo)
2569
+ );
2570
+ return json(
2571
+ aggregateAnalytics(
2572
+ events,
2573
+ previousEvents,
2574
+ { from, to },
2575
+ currentSubmissions,
2576
+ previousSubmissions,
2577
+ analyticsTimeZone
2578
+ )
2579
+ );
2270
2580
  };
2271
2581
  const pruneEvents = async () => {
2272
2582
  const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
@@ -164,8 +164,11 @@ type AnalyticsKpis = {
164
164
  portalClicks: number;
165
165
  conversions: number;
166
166
  conversionRate: number;
167
+ attributedSubmits: number;
168
+ unattributedSubmits: number;
167
169
  };
168
170
  type AnalyticsSummary = {
171
+ timeZone: string;
169
172
  range: {
170
173
  from: string;
171
174
  to: string;
@@ -514,6 +517,7 @@ declare function createStudioApi(options: {
514
517
  analytics: (params?: {
515
518
  from?: string;
516
519
  to?: string;
520
+ days?: number;
517
521
  }) => Promise<AnalyticsSummary>;
518
522
  listUsers: () => Promise<{
519
523
  users: StudioUser[];