@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.
- package/dist/analytics/react.js +4 -0
- package/dist/server/index.d.ts +8 -2
- package/dist/server/index.js +317 -76
- package/dist/studio/index.d.ts +4 -0
- package/dist/studio/index.js +51 -40
- package/package.json +1 -1
- package/sql/bootstrap.sql +49 -0
- package/sql/migrations/20260830143322_cms_analytics_unique_visitors.sql +43 -0
- package/sql/migrations/20260830202942_cms_page_version_fk_indexes.sql +11 -0
package/dist/analytics/react.js
CHANGED
|
@@ -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);
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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. */
|
package/dist/server/index.js
CHANGED
|
@@ -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")
|
|
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
|
|
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
|
|
207
|
+
const visitorMetrics = computeVisitorMetrics(events);
|
|
79
208
|
return {
|
|
80
|
-
visitors: visitors
|
|
81
|
-
identifiedVisitors:
|
|
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
|
|
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
|
|
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
|
|
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) || {
|
|
104
|
-
|
|
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) || {
|
|
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 (
|
|
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({
|
|
120
|
-
|
|
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 =
|
|
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
|
|
203
|
-
|
|
204
|
-
if (stage === "
|
|
205
|
-
if (stage === "
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
for (const submission of submissions || []) {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
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)
|
|
2492
|
+
if (data.length < 1e3) return all;
|
|
2281
2493
|
}
|
|
2282
|
-
|
|
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)
|
|
2290
|
-
if (!data || data.length === 0)
|
|
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)
|
|
2505
|
+
if (data.length < 1e3) return all;
|
|
2294
2506
|
}
|
|
2295
|
-
|
|
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
|
|
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
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
|
2564
|
+
const currentSubmissions = submissions.filter(
|
|
2331
2565
|
(submission) => Date.parse(submission.created_at) >= fromMs
|
|
2332
2566
|
);
|
|
2333
|
-
const previousSubmissions = submissions
|
|
2334
|
-
(submission) => Date.parse(submission.created_at)
|
|
2567
|
+
const previousSubmissions = submissions.filter(
|
|
2568
|
+
(submission) => Date.parse(submission.created_at) <= Date.parse(previousTo)
|
|
2335
2569
|
);
|
|
2336
2570
|
return json(
|
|
2337
|
-
aggregateAnalytics(
|
|
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 () => {
|
package/dist/studio/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/studio/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
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:
|
|
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
|
-
|
|
488
|
-
const from = new Date(to.getTime() - range.days * 864e5);
|
|
489
|
+
let active = true;
|
|
489
490
|
setData(null);
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
(
|
|
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 =
|
|
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: "
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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}
|
|
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
|
|
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}
|
|
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: "
|
|
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__ */
|
|
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
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
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);
|