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