@orion-studios/cms 0.5.8 → 0.5.10

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.
@@ -171,6 +171,10 @@ function Analytics({
171
171
  const value = params.get(`utm_${key}`);
172
172
  if (value) utm[key] = value;
173
173
  }
174
+ if (params.has("gclid") && !utm.source) {
175
+ utm.source = "google";
176
+ utm.medium = "cpc";
177
+ }
174
178
  if (Object.keys(utm).length > 0) event.utm = utm;
175
179
  }
176
180
  track(event);
@@ -77,6 +77,8 @@ type CmsRoutesOptions = {
77
77
  projectRef?: string;
78
78
  /** Purpose-specific secret for analytics visitor and session hashing. */
79
79
  analyticsSecret?: string;
80
+ /** IANA timezone used for analytics dates and hourly buckets. Defaults to UTC. */
81
+ analyticsTimeZone?: string;
80
82
  /** Purpose-specific secret for auto-reply recipient hashing. */
81
83
  autoReplyHashSecret?: string;
82
84
  /** Cloudflare Turnstile secret — when set, public submits must include a valid `_turnstileToken`. */
@@ -204,6 +206,7 @@ type StoredEvent = {
204
206
  };
205
207
  /** Minimal authoritative form record used by aggregate analytics. */
206
208
  type StoredFormSubmission = {
209
+ id?: number | string;
207
210
  form: string;
208
211
  session_key: string;
209
212
  created_at: string;
@@ -221,8 +224,11 @@ type AnalyticsKpis = {
221
224
  portalClicks: number;
222
225
  conversions: number;
223
226
  conversionRate: number;
227
+ attributedSubmits: number;
228
+ unattributedSubmits: number;
224
229
  };
225
230
  type AnalyticsSummary = {
231
+ timeZone: string;
226
232
  range: {
227
233
  from: string;
228
234
  to: string;
@@ -273,7 +279,7 @@ type AnalyticsSummary = {
273
279
  declare function aggregateAnalytics(events: StoredEvent[], previousEvents: StoredEvent[], range: {
274
280
  from: string;
275
281
  to: string;
276
- }, submissions?: StoredFormSubmission[], previousSubmissions?: StoredFormSubmission[]): AnalyticsSummary;
282
+ }, submissions?: StoredFormSubmission[], previousSubmissions?: StoredFormSubmission[], timeZone?: string): AnalyticsSummary;
277
283
 
278
284
  /**
279
285
  * Server-side analytics ingest: validation, bot filtering, and the
@@ -307,7 +313,7 @@ type AnalyticsEventRow = {
307
313
  * Daily-rotating visitor hash: same visitor+day → same key, next day → a new
308
314
  * unrelated key. Orders one visit into a path; can't track anyone over time.
309
315
  */
310
- declare function sessionKeyFor(ip: string, userAgent: string, secret: string, now?: Date): string;
316
+ declare function sessionKeyFor(ip: string, userAgent: string, secret: string, now?: Date, timeZone?: string): string;
311
317
  /** One-way server hash for a consented first-party visitor cookie. */
312
318
  declare function visitorKeyFor(visitorId: unknown, secret: string): string;
313
319
  /** True when the request looks like an automated client, not a visitor. */
@@ -1,3 +1,6 @@
1
+ import {
2
+ CONTENT_CACHE_TAG
3
+ } from "../chunk-NSAZCP4I.js";
1
4
  import {
2
5
  createMemoryRateLimitStore,
3
6
  getAutoReplyEmailFields,
@@ -5,30 +8,162 @@ import {
5
8
  processSubmission,
6
9
  resolveAutoReplyEmailField
7
10
  } from "../chunk-CFZP7674.js";
8
- import {
9
- CONTENT_CACHE_TAG
10
- } from "../chunk-NSAZCP4I.js";
11
11
 
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";
@@ -45,8 +180,6 @@ var isConversion = (event) => event.server_verified === true && (event.type ===
45
180
  var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
46
181
  function computeKpis(events, submissions) {
47
182
  const sessions = /* @__PURE__ */ new Set();
48
- const visitors = /* @__PURE__ */ new Set();
49
- const visitorSessions = /* @__PURE__ */ new Map();
50
183
  const converting = /* @__PURE__ */ new Set();
51
184
  let pageviews = 0;
52
185
  let calls = 0;
@@ -55,13 +188,6 @@ function computeKpis(events, submissions) {
55
188
  let portalClicks = 0;
56
189
  for (const event of events) {
57
190
  if (event.session_key) sessions.add(event.session_key);
58
- const identity = event.visitor_key || event.session_key;
59
- if (identity) visitors.add(identity);
60
- if (event.visitor_key && event.session_key) {
61
- const keys = visitorSessions.get(event.visitor_key) || /* @__PURE__ */ new Set();
62
- keys.add(event.session_key);
63
- visitorSessions.set(event.visitor_key, keys);
64
- }
65
191
  if (event.type === "pageview") pageviews += 1;
66
192
  if (event.type === "click" && event.name === "call") calls += 1;
67
193
  if (event.type === "click" && event.name === "email") emails += 1;
@@ -74,50 +200,107 @@ function computeKpis(events, submissions) {
74
200
  converting.add(submission.session_key);
75
201
  }
76
202
  }
203
+ const attributedSubmits = (submissions || []).filter(
204
+ (submission) => Boolean(submission.session_key && sessions.has(submission.session_key))
205
+ ).length;
77
206
  const conversions = events.filter(isConversion).length + (submissions?.length ?? 0);
78
- const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
207
+ const visitorMetrics = computeVisitorMetrics(events);
79
208
  return {
80
- visitors: visitors.size,
81
- identifiedVisitors: visitorSessions.size,
82
- returningVisitors,
209
+ visitors: visitorMetrics.visitors,
210
+ identifiedVisitors: visitorMetrics.identifiedVisitors,
211
+ returningVisitors: visitorMetrics.returningVisitors,
83
212
  sessions: sessions.size,
84
213
  pageviews,
85
- pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
214
+ pagesPerVisitor: visitorMetrics.visitors > 0 ? pageviews / visitorMetrics.visitors : 0,
86
215
  calls,
87
216
  formSubmits: submissions?.length ?? formSubmits,
88
217
  emails,
89
218
  portalClicks,
90
219
  conversions,
91
- 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
92
223
  };
93
224
  }
94
- function aggregateAnalytics(events, previousEvents, range, submissions, previousSubmissions) {
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();
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") {
95
276
  const trafficEvents = submissions === void 0 ? events : events.filter((event) => !isFormSubmitEvent(event));
96
277
  const previousTrafficEvents = previousSubmissions === void 0 ? previousEvents : previousEvents.filter((event) => !isFormSubmitEvent(event));
97
278
  const kpis = computeKpis(trafficEvents, submissions);
98
279
  const previous = computeKpis(previousTrafficEvents, previousSubmissions);
99
280
  const byDay = /* @__PURE__ */ new Map();
100
- const dayOf = (iso) => iso.slice(0, 10);
281
+ const summaryTimeZone = normalizeTimeZone(timeZone);
282
+ const dayOf = (iso) => dateKeyInTimeZone(iso, summaryTimeZone);
101
283
  for (const event of trafficEvents) {
102
284
  const day = dayOf(event.created_at);
103
- const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
104
- const identity = event.visitor_key || event.session_key;
105
- if (identity) entry.visitors.add(identity);
285
+ const entry = byDay.get(day) || { events: [], conversions: 0 };
286
+ entry.events.push(event);
106
287
  if (isConversion(event)) entry.conversions += 1;
107
288
  byDay.set(day, entry);
108
289
  }
109
290
  for (const submission of submissions || []) {
110
291
  const day = dayOf(submission.created_at);
111
- const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
292
+ const entry = byDay.get(day) || { events: [], conversions: 0 };
112
293
  entry.conversions += 1;
113
294
  byDay.set(day, entry);
114
295
  }
115
296
  const trend = [];
116
- 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)) {
117
- const day = cursor.toISOString().slice(0, 10);
297
+ for (const day of dateKeysInRange(range.from, range.to, summaryTimeZone)) {
118
298
  const entry = byDay.get(day);
119
- trend.push({ day, visitors: entry?.visitors.size ?? 0, conversions: entry?.conversions ?? 0 });
120
- if (trend.length > 370) break;
299
+ trend.push({
300
+ day,
301
+ visitors: entry ? computeVisitorMetrics(entry.events).visitors : 0,
302
+ conversions: entry?.conversions ?? 0
303
+ });
121
304
  }
122
305
  const bySession = /* @__PURE__ */ new Map();
123
306
  for (const event of trafficEvents) {
@@ -158,7 +341,7 @@ function aggregateAnalytics(events, previousEvents, range, submissions, previous
158
341
  const sources = /* @__PURE__ */ new Map();
159
342
  for (const [sessionKey, list] of bySession.entries()) {
160
343
  const first = list.find((event) => event.type === "pageview");
161
- const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
344
+ const label = normalizeSource(first?.utm?.source || "", first?.referrer || "", first?.utm?.medium || "");
162
345
  const entry = sources.get(label) || { sessions: 0, conversions: 0 };
163
346
  entry.sessions += 1;
164
347
  if (convertedSessionKeys.has(sessionKey)) entry.conversions += 1;
@@ -175,7 +358,7 @@ function aggregateAnalytics(events, previousEvents, range, submissions, previous
175
358
  const hours = new Array(24).fill(0);
176
359
  for (const event of trafficEvents) {
177
360
  if (event.type !== "pageview") continue;
178
- const hour = new Date(event.created_at).getUTCHours();
361
+ const hour = hourInTimeZone(event.created_at, summaryTimeZone);
179
362
  if (Number.isFinite(hour)) hours[hour] += 1;
180
363
  }
181
364
  const pathCounts = /* @__PURE__ */ new Map();
@@ -194,27 +377,46 @@ function aggregateAnalytics(events, previousEvents, range, submissions, previous
194
377
  pathCounts.set(key, entry);
195
378
  }
196
379
  const forms = /* @__PURE__ */ new Map();
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
+ };
197
387
  for (const event of trafficEvents) {
198
388
  if (event.type !== "form") continue;
199
389
  const [slug, stage] = event.name.split(":");
200
390
  if (!slug || !stage) continue;
201
391
  if (stage !== "viewed" && stage !== "start" && stage !== "submit") continue;
202
- const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
203
- if (stage === "viewed") entry.views += 1;
204
- if (stage === "start") entry.starts += 1;
205
- if (stage === "submit" && event.server_verified === true) entry.submits += 1;
206
- forms.set(slug, entry);
207
- }
208
- for (const submission of submissions || []) {
209
- const entry = forms.get(submission.form) || { views: 0, starts: 0, submits: 0 };
210
- entry.submits += 1;
211
- forms.set(submission.form, entry);
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 });
212
413
  }
213
414
  const notFound = /* @__PURE__ */ new Map();
214
415
  for (const event of trafficEvents) {
215
416
  if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
216
417
  }
217
418
  return {
419
+ timeZone: summaryTimeZone,
218
420
  range,
219
421
  kpis,
220
422
  previous,
@@ -228,7 +430,7 @@ function aggregateAnalytics(events, previousEvents, range, submissions, previous
228
430
  path: key.split(" \u2192 "),
229
431
  ...data
230
432
  })),
231
- forms: [...forms.entries()].map(([form, data]) => ({ form, ...data })),
433
+ forms: formSummary,
232
434
  notFound: top(notFound, 20, (count) => count).map(([path, count]) => ({ path, count }))
233
435
  };
234
436
  }
@@ -239,8 +441,8 @@ var EVENT_TYPES = ["pageview", "click", "form", "not_found"];
239
441
  var MAX_EVENTS_PER_BATCH = 20;
240
442
  var MAX_TEXT = 300;
241
443
  var MAX_META_JSON = 1e3;
242
- function sessionKeyFor(ip, userAgent, secret, now = /* @__PURE__ */ new Date()) {
243
- 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));
244
446
  return createHash("sha256").update(`${secret}|${day}|${ip}|${userAgent}`).digest("hex").slice(0, 24);
245
447
  }
246
448
  function visitorKeyFor(visitorId, secret) {
@@ -1200,6 +1402,7 @@ function createCmsRoutes(options) {
1200
1402
  };
1201
1403
  const previewProjectRef = options.projectRef || (options.memoryMode ? "memory" : "");
1202
1404
  const analyticsSecret = options.analyticsSecret || (options.memoryMode ? "orion-memory-analytics-secret" : "");
1405
+ const analyticsTimeZone = normalizeTimeZone(options.analyticsTimeZone);
1203
1406
  const autoReplyHashSecret = options.autoReplyHashSecret || (options.memoryMode ? "orion-memory-auto-reply-secret" : "");
1204
1407
  const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
1205
1408
  const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
@@ -1282,7 +1485,13 @@ function createCmsRoutes(options) {
1282
1485
  const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
1283
1486
  const requestSessionKey = (request) => {
1284
1487
  if (!analyticsSecret) return "";
1285
- 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
+ );
1286
1495
  };
1287
1496
  const guard = async (request, action) => {
1288
1497
  if (hostedMemoryMode) return errors.unauthorized();
@@ -2261,10 +2470,12 @@ function createCmsRoutes(options) {
2261
2470
  }
2262
2471
  }
2263
2472
  try {
2264
- await db().from("cms_events").insert(
2473
+ const { error } = await db().from("cms_events").insert(
2265
2474
  rows.map((row) => ({ ...row, server_verified: false }))
2266
2475
  );
2476
+ if (error) console.error("CMS analytics ingest failed.");
2267
2477
  } catch {
2478
+ console.error("CMS analytics ingest failed.");
2268
2479
  }
2269
2480
  }
2270
2481
  return json({ success: true });
@@ -2273,31 +2484,33 @@ function createCmsRoutes(options) {
2273
2484
  const all = [];
2274
2485
  let cursor = 0;
2275
2486
  for (let page = 0; page < 60; page += 1) {
2276
- 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);
2277
- 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;
2278
2490
  all.push(...data);
2279
2491
  cursor = Number(data[data.length - 1].id);
2280
- if (data.length < 1e3) break;
2492
+ if (data.length < 1e3) return all;
2281
2493
  }
2282
- return all;
2494
+ throw new Error("Analytics event read exceeded the supported row limit.");
2283
2495
  };
2284
2496
  const fetchFormSubmissions = async (fromIso, toIso) => {
2285
2497
  const all = [];
2286
2498
  let cursor = 0;
2287
2499
  for (let page = 0; page < 60; page += 1) {
2288
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);
2289
- if (error) return all.length === 0 ? null : all;
2290
- if (!data || data.length === 0) break;
2501
+ if (error) throw new Error("Analytics submission read failed.");
2502
+ if (!data || data.length === 0) return all;
2291
2503
  all.push(...data);
2292
2504
  cursor = Number(data[data.length - 1].id);
2293
- if (data.length < 1e3) break;
2505
+ if (data.length < 1e3) return all;
2294
2506
  }
2295
- return all;
2507
+ throw new Error("Analytics submission read exceeded the supported row limit.");
2296
2508
  };
2297
2509
  const fetchFormSlugs = async () => {
2298
2510
  const { data, error } = await db().from("cms_forms").select("id, slug").limit(1e3);
2299
2511
  const slugs = /* @__PURE__ */ new Map();
2300
- if (error || !data) return slugs;
2512
+ if (error) throw new Error("Analytics form label read failed.");
2513
+ if (!data) return slugs;
2301
2514
  for (const form of data) {
2302
2515
  const id = String(form.id || "");
2303
2516
  const slug = String(form.slug || "");
@@ -2309,32 +2522,60 @@ function createCmsRoutes(options) {
2309
2522
  const auth = await guard(request, "analytics.read");
2310
2523
  if (auth instanceof Response) return auth;
2311
2524
  const url = new URL(request.url);
2312
- const toMs = Date.parse(url.searchParams.get("to") || "") || Date.now();
2313
- const defaultFrom = toMs - 30 * 864e5;
2314
- const fromMs = Math.min(Date.parse(url.searchParams.get("from") || "") || defaultFrom, toMs);
2315
- const windowMs = Math.max(toMs - fromMs, 864e5);
2316
- const from = new Date(fromMs).toISOString();
2317
- const to = new Date(toMs).toISOString();
2318
- const previousFrom = new Date(fromMs - windowMs).toISOString();
2319
- const [events, previousEvents, submissionRows, formSlugs] = await Promise.all([
2320
- fetchEvents(from, to),
2321
- fetchEvents(previousFrom, from),
2322
- fetchFormSubmissions(previousFrom, to),
2323
- fetchFormSlugs()
2324
- ]);
2325
- const submissions = submissionRows?.map((submission) => ({
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,
2326
2560
  form: formSlugs.get(String(submission.form_id)) || String(submission.form_id),
2327
2561
  session_key: String(submission.session_key || ""),
2328
2562
  created_at: String(submission.created_at)
2329
2563
  }));
2330
- const currentSubmissions = submissions?.filter(
2564
+ const currentSubmissions = submissions.filter(
2331
2565
  (submission) => Date.parse(submission.created_at) >= fromMs
2332
2566
  );
2333
- const previousSubmissions = submissions?.filter(
2334
- (submission) => Date.parse(submission.created_at) < fromMs
2567
+ const previousSubmissions = submissions.filter(
2568
+ (submission) => Date.parse(submission.created_at) <= Date.parse(previousTo)
2335
2569
  );
2336
2570
  return json(
2337
- aggregateAnalytics(events, previousEvents, { from, to }, currentSubmissions, previousSubmissions)
2571
+ aggregateAnalytics(
2572
+ events,
2573
+ previousEvents,
2574
+ { from, to },
2575
+ currentSubmissions,
2576
+ previousSubmissions,
2577
+ analyticsTimeZone
2578
+ )
2338
2579
  );
2339
2580
  };
2340
2581
  const pruneEvents = async () => {
@@ -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[];
@@ -8,7 +8,7 @@ import {
8
8
  import "../chunk-DPKXKG2S.js";
9
9
 
10
10
  // src/studio/Studio.tsx
11
- import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
11
+ import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo4, useState as useState12 } from "react";
12
12
 
13
13
  // src/studio/api.ts
14
14
  var StudioApiError = class extends Error {
@@ -122,6 +122,7 @@ function createStudioApi(options) {
122
122
  listActivity: () => call("GET", "/activity"),
123
123
  analytics: (params) => {
124
124
  const query = new URLSearchParams();
125
+ if (params?.days) query.set("days", String(params.days));
125
126
  if (params?.from) query.set("from", params.from);
126
127
  if (params?.to) query.set("to", params.to);
127
128
  const suffix = query.toString() ? `?${query.toString()}` : "";
@@ -383,7 +384,7 @@ function LoginView({ siteName, logoUrl }) {
383
384
  }
384
385
 
385
386
  // src/studio/views/AnalyticsView.tsx
386
- import { useEffect as useEffect2, useMemo as useMemo2, useState as useState3 } from "react";
387
+ import { useEffect as useEffect2, useState as useState3 } from "react";
387
388
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
388
389
  var RANGES = [
389
390
  { key: "7", label: "7 days", days: 7 },
@@ -460,7 +461,8 @@ function TrendChart({ trend }) {
460
461
  }
461
462
  function BarList({
462
463
  rows,
463
- max
464
+ max,
465
+ emptyMessage = "Nothing recorded yet."
464
466
  }) {
465
467
  const top = max ?? Math.max(1, ...rows.map((row) => row.value));
466
468
  return /* @__PURE__ */ jsxs3("div", { className: "ost-barlist", children: [
@@ -475,7 +477,7 @@ function BarList({
475
477
  ] }) : null
476
478
  ] })
477
479
  ] }, row.label)),
478
- rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Nothing recorded yet." }) : null
480
+ rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: emptyMessage }) : null
479
481
  ] });
480
482
  }
481
483
  function AnalyticsView({ api }) {
@@ -484,22 +486,22 @@ function AnalyticsView({ api }) {
484
486
  const [error, setError] = useState3("");
485
487
  useEffect2(() => {
486
488
  const range = RANGES.find((entry) => entry.key === rangeKey) ?? RANGES[1];
487
- const to = /* @__PURE__ */ new Date();
488
- const from = new Date(to.getTime() - range.days * 864e5);
489
+ let active = true;
489
490
  setData(null);
490
- api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
491
- setData,
492
- (e) => setError(e.message)
491
+ setError("");
492
+ api.analytics({ days: range.days }).then(
493
+ (summary) => {
494
+ if (active) setData(summary);
495
+ },
496
+ (e) => {
497
+ if (active) setError(e.message);
498
+ }
493
499
  );
500
+ return () => {
501
+ active = false;
502
+ };
494
503
  }, [api, rangeKey]);
495
- const localHours = useMemo2(() => {
496
- if (!data) return [];
497
- const offset = Math.round((/* @__PURE__ */ new Date()).getTimezoneOffset() / -60);
498
- return data.hours.map((_, hour) => ({
499
- hour,
500
- count: data.hours[((hour - offset) % 24 + 24) % 24]
501
- }));
502
- }, [data]);
504
+ const localHours = data?.hours.map((count, hour) => ({ hour, count })) ?? [];
503
505
  if (error) return /* @__PURE__ */ jsx3("div", { className: "ost-view", children: /* @__PURE__ */ jsx3("div", { className: "ost-error", children: error }) });
504
506
  const kpis = data?.kpis ?? null;
505
507
  const previous = data?.previous;
@@ -552,7 +554,7 @@ function AnalyticsView({ api }) {
552
554
  /* @__PURE__ */ jsx3(
553
555
  Kpi,
554
556
  {
555
- label: "Returning visitors",
557
+ label: "Repeat-day visitors",
556
558
  previous: previous ? { current: kpis.returningVisitors, prior: previous.returningVisitors } : void 0,
557
559
  value: String(kpis.returningVisitors)
558
560
  }
@@ -574,7 +576,7 @@ function AnalyticsView({ api }) {
574
576
  }
575
577
  ),
576
578
  /* @__PURE__ */ jsx3(Kpi, { label: "Pages per visitor", value: kpis.pagesPerVisitor.toFixed(1) }),
577
- /* @__PURE__ */ jsx3(Kpi, { label: "Conversion rate", value: pct(kpis.conversionRate) }),
579
+ /* @__PURE__ */ jsx3(Kpi, { label: "Attributed visit conversion rate", value: pct(kpis.conversionRate) }),
578
580
  /* @__PURE__ */ jsx3(
579
581
  Kpi,
580
582
  {
@@ -585,15 +587,17 @@ function AnalyticsView({ api }) {
585
587
  )
586
588
  ] }),
587
589
  /* @__PURE__ */ jsxs3("p", { className: "ost-muted ost-analytics-note", children: [
588
- "Returning visitors and cross-day totals use the pseudonymous cookie only after the visitor accepts analytics.",
589
- kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : ""
590
+ "Traffic counts cover consented tracked visits. Requests sent includes every stored submission. Repeat-day visitors use the pseudonymous cookie only after the visitor accepts analytics.",
591
+ kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : "",
592
+ ` ${kpis.attributedSubmits} of ${kpis.formSubmits} requests were linked to a tracked visit.`,
593
+ ` Ranges use site-local calendar days including today. Dates and hours use ${data.timeZone}.`
590
594
  ] }),
591
595
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
592
596
  /* @__PURE__ */ jsxs3("h3", { children: [
593
597
  "Daily visitors ",
594
598
  /* @__PURE__ */ jsxs3("span", { className: "ost-trend-key", children: [
595
599
  "\u25A0 visitors ",
596
- /* @__PURE__ */ jsx3("em", { children: "\u25A0 conversions" })
600
+ /* @__PURE__ */ jsx3("em", { children: "\u25A0 verified actions" })
597
601
  ] })
598
602
  ] }),
599
603
  /* @__PURE__ */ jsx3(TrendChart, { trend: data.trend })
@@ -607,27 +611,27 @@ function AnalyticsView({ api }) {
607
611
  rows: data.pages.slice(0, 12).map((page) => ({
608
612
  label: page.path === "/" ? "Home" : page.path,
609
613
  value: page.views,
610
- detail: page.conversions > 0 ? `\xB7 ${page.conversions} conv` : void 0
614
+ detail: page.conversions > 0 ? `\xB7 ${page.conversions} linked actions` : void 0
611
615
  }))
612
616
  }
613
617
  )
614
618
  ] }),
615
619
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
616
- /* @__PURE__ */ jsx3("h3", { children: "Where visitors come from" }),
620
+ /* @__PURE__ */ jsx3("h3", { children: "Where tracked visits come from" }),
617
621
  /* @__PURE__ */ jsx3(
618
622
  BarList,
619
623
  {
620
624
  rows: data.sources.map((source) => ({
621
625
  label: source.source,
622
626
  value: source.sessions,
623
- detail: source.conversions > 0 ? `\xB7 ${source.conversions} conv` : void 0
627
+ detail: source.conversions > 0 ? `\xB7 ${source.conversions} linked actions` : void 0
624
628
  }))
625
629
  }
626
630
  )
627
631
  ] }),
628
632
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
629
633
  /* @__PURE__ */ jsx3("h3", { children: "Form funnel" }),
630
- /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "A view requires the first field to remain 75% visible for 2 seconds, or direct field interaction. Legacy form loads are excluded." }),
634
+ /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Each step counts unique tracked visits, not raw events. A view requires the first field to remain 75% visible for 2 seconds, or direct field interaction. Starts and submissions supply the minimum when a view event is unavailable. Legacy form loads are excluded." }),
631
635
  data.forms.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "No form activity yet." }) : null,
632
636
  data.forms.map((form) => /* @__PURE__ */ jsxs3("div", { className: "ost-funnel", children: [
633
637
  /* @__PURE__ */ jsx3("strong", { children: form.form }),
@@ -645,7 +649,7 @@ function AnalyticsView({ api }) {
645
649
  ] }, form.form))
646
650
  ] }),
647
651
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
648
- /* @__PURE__ */ jsx3("h3", { children: "Visitor locations" }),
652
+ /* @__PURE__ */ jsx3("h3", { children: "Tracked visit locations" }),
649
653
  /* @__PURE__ */ jsx3(
650
654
  BarList,
651
655
  {
@@ -678,7 +682,11 @@ function AnalyticsView({ api }) {
678
682
  ": ",
679
683
  device.sessions
680
684
  ] }, device.device)) }),
681
- /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Busiest hours" }),
685
+ /* @__PURE__ */ jsxs3("h4", { className: "ost-analytics-subhead", children: [
686
+ "Busiest hours (",
687
+ data.timeZone,
688
+ ")"
689
+ ] }),
682
690
  /* @__PURE__ */ jsx3("div", { className: "ost-hours", children: localHours.map((entry) => {
683
691
  const max = Math.max(1, ...localHours.map((h) => h.count));
684
692
  return /* @__PURE__ */ jsx3(
@@ -686,14 +694,19 @@ function AnalyticsView({ api }) {
686
694
  {
687
695
  className: "ost-hour-bar",
688
696
  style: { height: `${Math.max(entry.count / max * 100, 4)}%` },
689
- title: `${entry.hour}:00 \u2014 ${entry.count} views`
697
+ title: `${entry.hour}:00, ${entry.count} views`
690
698
  },
691
699
  entry.hour
692
700
  );
693
701
  }) }),
694
702
  /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Broken links (404s)" }),
695
- data.notFound.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "None \u2014 nice." }) : null,
696
- /* @__PURE__ */ jsx3(BarList, { rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count })) })
703
+ /* @__PURE__ */ jsx3(
704
+ BarList,
705
+ {
706
+ emptyMessage: "No broken links recorded.",
707
+ rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count }))
708
+ }
709
+ )
697
710
  ] })
698
711
  ] })
699
712
  ] }) : null
@@ -741,9 +754,7 @@ function DashboardView({
741
754
  useEffect3(() => {
742
755
  api.dashboard().then(setData, (e) => setError(e.message));
743
756
  if (canReadAnalytics) {
744
- const to = /* @__PURE__ */ new Date();
745
- const from = new Date(to.getTime() - 7 * 864e5);
746
- api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
757
+ api.analytics({ days: 7 }).then(
747
758
  (summary) => setWeek({
748
759
  visitors: summary.kpis.visitors,
749
760
  calls: summary.kpis.calls,
@@ -1447,7 +1458,7 @@ function FieldEditor({
1447
1458
  }
1448
1459
 
1449
1460
  // src/studio/views/PageEditor.tsx
1450
- import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo3, useRef, useState as useState7 } from "react";
1461
+ import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo2, useRef, useState as useState7 } from "react";
1451
1462
 
1452
1463
  // src/studio/inline.ts
1453
1464
  function collectInlineTargets(definition, data) {
@@ -2175,7 +2186,7 @@ function PageEditor({
2175
2186
  const selectedIndex = selectedId ? layout.findIndex((block) => block.id === selectedId) : -1;
2176
2187
  const selected = selectedIndex >= 0 ? layout[selectedIndex] : null;
2177
2188
  const selectedDefinition = selected ? registry.get(selected.type) : void 0;
2178
- const changedSincePublish = useMemo3(() => {
2189
+ const changedSincePublish = useMemo2(() => {
2179
2190
  if (!page) return false;
2180
2191
  if (page.status !== "published") return true;
2181
2192
  return dirty || title !== (page.published_title ?? "") || JSON.stringify(seo) !== JSON.stringify(page.published_seo ?? {}) || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
@@ -2348,7 +2359,7 @@ function PageEditor({
2348
2359
  setDirty(false);
2349
2360
  setMessage("Version restored into draft.");
2350
2361
  };
2351
- const palette = useMemo3(() => registry.palette(), [registry]);
2362
+ const palette = useMemo2(() => registry.palette(), [registry]);
2352
2363
  const insertBlock = (type, at) => {
2353
2364
  const instance = registry.createInstance(type);
2354
2365
  const next = [...layout];
@@ -3314,7 +3325,7 @@ function UsersView({ api, meId }) {
3314
3325
  }
3315
3326
 
3316
3327
  // src/studio/views/views.tsx
3317
- import { useEffect as useEffect10, useMemo as useMemo4, useState as useState11 } from "react";
3328
+ import { useEffect as useEffect10, useMemo as useMemo3, useState as useState11 } from "react";
3318
3329
  import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
3319
3330
  var pageStatus = (page) => {
3320
3331
  if (page.publish_at) return { label: "scheduled", live: false };
@@ -3401,7 +3412,7 @@ function MediaView({
3401
3412
  const [selected, setSelected] = useState11(null);
3402
3413
  const [search, setSearch] = useState11("");
3403
3414
  const [error, setError] = useState11("");
3404
- const visible = useMemo4(() => {
3415
+ const visible = useMemo3(() => {
3405
3416
  const term = search.trim().toLowerCase();
3406
3417
  if (!term) return media;
3407
3418
  return media.filter(
@@ -3742,7 +3753,7 @@ var NAV = [
3742
3753
  ];
3743
3754
  function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3744
3755
  const { session, loading, getToken, signOut } = useStudioSession();
3745
- const api = useMemo5(() => createStudioApi({ getToken }), [getToken]);
3756
+ const api = useMemo4(() => createStudioApi({ getToken }), [getToken]);
3746
3757
  useEffect11(() => {
3747
3758
  try {
3748
3759
  localStorage.setItem("orion-analytics-exclude", "1");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
package/sql/bootstrap.sql CHANGED
@@ -164,6 +164,12 @@ where scheduled_version_id is not null
164
164
 
165
165
  create unique index if not exists cms_page_versions_page_id_id_idx
166
166
  on cms_page_versions (page_id, id);
167
+ create index if not exists cms_pages_draft_version_fk_idx
168
+ on cms_pages (id, draft_version_id);
169
+ create index if not exists cms_pages_published_version_fk_idx
170
+ on cms_pages (id, published_version_id);
171
+ create index if not exists cms_pages_scheduled_version_fk_idx
172
+ on cms_pages (id, scheduled_version_id);
167
173
  create index if not exists cms_pages_scheduled_publish_due_idx
168
174
  on cms_pages (publish_at)
169
175
  where publish_at is not null and scheduled_version_id is not null;
@@ -1074,6 +1080,49 @@ begin
1074
1080
  end;
1075
1081
  $$;
1076
1082
 
1083
+ -- Aggregate-only visitor count for care-report sharing. The service role
1084
+ -- invokes it, and raw visitor and session identifiers never leave PostgreSQL.
1085
+ create or replace function public.cms_analytics_unique_visitors(
1086
+ p_from timestamptz,
1087
+ p_to timestamptz
1088
+ ) returns bigint
1089
+ language sql
1090
+ stable
1091
+ security invoker
1092
+ set search_path = ''
1093
+ as $$
1094
+ with session_identities as (
1095
+ select
1096
+ session_key,
1097
+ max(nullif(visitor_key, '')) as visitor_key,
1098
+ min(id) as first_event_id
1099
+ from public.cms_events
1100
+ where created_at >= p_from
1101
+ and created_at <= p_to
1102
+ group by session_key
1103
+ )
1104
+ select count(distinct coalesce(
1105
+ visitor_key,
1106
+ nullif(session_key, ''),
1107
+ 'event:' || first_event_id::text
1108
+ ))::bigint
1109
+ from session_identities;
1110
+ $$;
1111
+
1112
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
1113
+ do $analytics_aggregate_grants$
1114
+ begin
1115
+ if exists (select 1 from pg_roles where rolname = 'anon') then
1116
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
1117
+ end if;
1118
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
1119
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
1120
+ end if;
1121
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
1122
+ grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
1123
+ end if;
1124
+ end $analytics_aggregate_grants$;
1125
+
1077
1126
  -- 3) Row-level security -------------------------------------------------------
1078
1127
  -- Defense-in-depth. The app's API layer (service role) is the primary
1079
1128
  -- enforcement point; anonymous access is read-only and published-only.
@@ -0,0 +1,43 @@
1
+ -- Aggregate-only visitor count for care-report sharing. Raw visitor and
2
+ -- session identifiers stay inside PostgreSQL.
3
+ create or replace function public.cms_analytics_unique_visitors(
4
+ p_from timestamptz,
5
+ p_to timestamptz
6
+ ) returns bigint
7
+ language sql
8
+ stable
9
+ security invoker
10
+ set search_path = ''
11
+ as $$
12
+ with session_identities as (
13
+ select
14
+ session_key,
15
+ max(nullif(visitor_key, '')) as visitor_key,
16
+ min(id) as first_event_id
17
+ from public.cms_events
18
+ where created_at >= p_from
19
+ and created_at <= p_to
20
+ group by session_key
21
+ )
22
+ select count(distinct coalesce(
23
+ visitor_key,
24
+ nullif(session_key, ''),
25
+ 'event:' || first_event_id::text
26
+ ))::bigint
27
+ from session_identities;
28
+ $$;
29
+
30
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
31
+
32
+ do $analytics_aggregate_grants$
33
+ begin
34
+ if exists (select 1 from pg_roles where rolname = 'anon') then
35
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
36
+ end if;
37
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
38
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
39
+ end if;
40
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
41
+ grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
42
+ end if;
43
+ end $analytics_aggregate_grants$;
@@ -0,0 +1,11 @@
1
+ -- Cover the three composite foreign keys from cms_pages to cms_page_versions.
2
+ -- PostgreSQL does not create indexes on the referencing columns automatically.
3
+
4
+ create index if not exists cms_pages_draft_version_fk_idx
5
+ on cms_pages (id, draft_version_id);
6
+
7
+ create index if not exists cms_pages_published_version_fk_idx
8
+ on cms_pages (id, published_version_id);
9
+
10
+ create index if not exists cms_pages_scheduled_version_fk_idx
11
+ on cms_pages (id, scheduled_version_id);