@nodewrite-labs/analytics-sdk 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,290 @@
1
+ import {
2
+ PayloadAnalyticsEventStore,
3
+ adminSections,
4
+ analyticsModulesFromConfig
5
+ } from "../chunk-GVOESWHZ.js";
6
+ import "../chunk-Q4JRXZ6M.js";
7
+ import "../chunk-F7BY2JQ3.js";
8
+
9
+ // src/payload/admin/view.tsx
10
+ import { DefaultTemplate } from "@payloadcms/next/templates";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ var WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
13
+ var reportStyle = `
14
+ .analytics-report {
15
+ box-sizing: border-box;
16
+ display: grid;
17
+ gap: 1.5rem;
18
+ max-width: 72rem;
19
+ padding: 1.5rem 1rem 3rem;
20
+ color: inherit;
21
+ }
22
+ .analytics-report h1,
23
+ .analytics-report h2 {
24
+ margin: 0;
25
+ }
26
+ .analytics-report a {
27
+ color: inherit;
28
+ }
29
+ .analytics-filters {
30
+ display: grid;
31
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
32
+ gap: 0.75rem;
33
+ align-items: end;
34
+ }
35
+ .analytics-filters label {
36
+ display: grid;
37
+ gap: 0.25rem;
38
+ }
39
+ .analytics-filters input,
40
+ .analytics-filters button {
41
+ font: inherit;
42
+ min-height: 2.5rem;
43
+ padding: 0.4rem 0.6rem;
44
+ }
45
+ .analytics-totals {
46
+ display: grid;
47
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
48
+ gap: 0.75rem;
49
+ margin: 0.75rem 0 0;
50
+ }
51
+ .analytics-scroll {
52
+ overflow-x: auto;
53
+ margin-top: 0.75rem;
54
+ }
55
+ .analytics-report table {
56
+ width: 100%;
57
+ border-collapse: collapse;
58
+ }
59
+ .analytics-report th,
60
+ .analytics-report td {
61
+ text-align: start;
62
+ padding: 0.5rem 0.75rem;
63
+ border-bottom: 1px solid currentColor;
64
+ vertical-align: top;
65
+ }
66
+ .analytics-back {
67
+ justify-self: start;
68
+ }
69
+ @media (max-width: 40rem) {
70
+ .analytics-report {
71
+ padding: 1rem 0.75rem 2rem;
72
+ }
73
+ }
74
+ `;
75
+ async function AnalyticsAdminView(props) {
76
+ if (!props.initPageResult.req.user) return null;
77
+ const modules = analyticsModulesFromConfig(props.payload.config);
78
+ const range = resolveRange(props.searchParams);
79
+ const country = modules.country ? firstParam(props.searchParams?.country) : null;
80
+ const path = firstParam(props.searchParams?.path);
81
+ const session = modules.sessions ? firstParam(props.searchParams?.session) : null;
82
+ const store = new PayloadAnalyticsEventStore(props.payload, modules);
83
+ const summary = await store.summarizeAnalytics(
84
+ {
85
+ from: range.from,
86
+ to: range.to,
87
+ country,
88
+ path,
89
+ sessionId: session
90
+ },
91
+ modules
92
+ );
93
+ const sections = adminSections(summary, modules, { session });
94
+ const visibleEntities = props.visibleEntities ?? props.initPageResult.visibleEntities;
95
+ return /* @__PURE__ */ jsx(
96
+ DefaultTemplate,
97
+ {
98
+ i18n: props.i18n,
99
+ payload: props.payload,
100
+ req: props.initPageResult.req,
101
+ viewType: props.viewType,
102
+ visibleEntities,
103
+ children: /* @__PURE__ */ jsxs("div", { className: "analytics-report", children: [
104
+ /* @__PURE__ */ jsx("style", { children: reportStyle }),
105
+ /* @__PURE__ */ jsx("h1", { children: "Analytics" }),
106
+ /* @__PURE__ */ jsx(
107
+ FilterForm,
108
+ {
109
+ from: range.from,
110
+ to: range.to,
111
+ country,
112
+ path,
113
+ session,
114
+ modules
115
+ }
116
+ ),
117
+ session ? /* @__PURE__ */ jsx("a", { className: "analytics-back", href: reportHref({ from: range.from, to: range.to, country, path }), children: "All sessions" }) : null,
118
+ sections.map((section) => /* @__PURE__ */ jsx(
119
+ SectionBlock,
120
+ {
121
+ section,
122
+ hrefForSession: (id) => reportHref({ from: range.from, to: range.to, country, path, session: id })
123
+ },
124
+ section.kind
125
+ ))
126
+ ] })
127
+ }
128
+ );
129
+ }
130
+ function FilterForm({
131
+ from,
132
+ to,
133
+ country,
134
+ path,
135
+ session,
136
+ modules
137
+ }) {
138
+ return /* @__PURE__ */ jsxs("form", { className: "analytics-filters", method: "get", action: "/admin/analytics", children: [
139
+ /* @__PURE__ */ jsxs("label", { children: [
140
+ "From",
141
+ /* @__PURE__ */ jsx("input", { name: "from", type: "text", defaultValue: from, autoComplete: "off" })
142
+ ] }),
143
+ /* @__PURE__ */ jsxs("label", { children: [
144
+ "To",
145
+ /* @__PURE__ */ jsx("input", { name: "to", type: "text", defaultValue: to, autoComplete: "off" })
146
+ ] }),
147
+ modules.country ? /* @__PURE__ */ jsxs("label", { children: [
148
+ "Country",
149
+ /* @__PURE__ */ jsx("input", { name: "country", type: "text", defaultValue: country ?? "", maxLength: 2, autoComplete: "off" })
150
+ ] }) : null,
151
+ /* @__PURE__ */ jsxs("label", { children: [
152
+ "Path",
153
+ /* @__PURE__ */ jsx("input", { name: "path", type: "text", defaultValue: path ?? "", maxLength: 2048, autoComplete: "off" })
154
+ ] }),
155
+ session ? /* @__PURE__ */ jsx("input", { type: "hidden", name: "session", value: session }) : null,
156
+ /* @__PURE__ */ jsx("button", { type: "submit", children: "Apply" })
157
+ ] });
158
+ }
159
+ function SectionBlock({
160
+ section,
161
+ hrefForSession
162
+ }) {
163
+ switch (section.kind) {
164
+ case "totals":
165
+ return /* @__PURE__ */ jsxs("section", { children: [
166
+ /* @__PURE__ */ jsx("h2", { children: "Totals" }),
167
+ /* @__PURE__ */ jsxs("dl", { className: "analytics-totals", children: [
168
+ /* @__PURE__ */ jsxs("div", { children: [
169
+ /* @__PURE__ */ jsx("dt", { children: "Total views" }),
170
+ /* @__PURE__ */ jsx("dd", { children: section.totalViews })
171
+ ] }),
172
+ /* @__PURE__ */ jsxs("div", { children: [
173
+ /* @__PURE__ */ jsx("dt", { children: "Average duration" }),
174
+ /* @__PURE__ */ jsx("dd", { children: formatSeconds(section.averageDurationSeconds) })
175
+ ] })
176
+ ] })
177
+ ] });
178
+ case "paths":
179
+ return /* @__PURE__ */ jsx(
180
+ DataTable,
181
+ {
182
+ title: "Paths",
183
+ columns: ["Path", "Views", "Average duration"],
184
+ rows: section.rows.map((row) => [row.path, String(row.views), formatSeconds(row.averageDurationSeconds)])
185
+ }
186
+ );
187
+ case "countries":
188
+ return /* @__PURE__ */ jsx(
189
+ DataTable,
190
+ {
191
+ title: "Countries",
192
+ columns: ["Country", "Views"],
193
+ rows: section.rows.map((row) => [row.country, String(row.views)])
194
+ }
195
+ );
196
+ case "cities":
197
+ return /* @__PURE__ */ jsx(
198
+ DataTable,
199
+ {
200
+ title: "Cities",
201
+ columns: ["City", "Views"],
202
+ rows: section.rows.map((row) => [row.city, String(row.views)])
203
+ }
204
+ );
205
+ case "namedClicks":
206
+ return /* @__PURE__ */ jsx(
207
+ DataTable,
208
+ {
209
+ title: "Named clicks",
210
+ columns: ["Event", "Count"],
211
+ rows: section.rows.map((row) => [row.eventName, String(row.count)])
212
+ }
213
+ );
214
+ case "sessions":
215
+ return /* @__PURE__ */ jsxs("section", { children: [
216
+ /* @__PURE__ */ jsx("h2", { children: "Sessions" }),
217
+ /* @__PURE__ */ jsx("div", { className: "analytics-scroll", children: /* @__PURE__ */ jsxs("table", { children: [
218
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
219
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Session" }),
220
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Started" }),
221
+ /* @__PURE__ */ jsx("th", { scope: "col", children: "Pages" })
222
+ ] }) }),
223
+ /* @__PURE__ */ jsx("tbody", { children: section.rows.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 3, children: "None in this range." }) }) : section.rows.map((row) => /* @__PURE__ */ jsxs("tr", { children: [
224
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("a", { href: hrefForSession(row.id), children: row.id }) }),
225
+ /* @__PURE__ */ jsx("td", { children: row.startedAt }),
226
+ /* @__PURE__ */ jsx("td", { children: row.pageCount })
227
+ ] }, row.id)) })
228
+ ] }) })
229
+ ] });
230
+ case "trail":
231
+ return /* @__PURE__ */ jsx(
232
+ DataTable,
233
+ {
234
+ title: "Session events",
235
+ columns: ["Time", "Kind", "Path", "Event"],
236
+ rows: section.events.map((event) => [
237
+ event.occurredAt,
238
+ event.kind === "click" ? "Click" : "View",
239
+ event.path,
240
+ event.eventName ?? ""
241
+ ])
242
+ }
243
+ );
244
+ }
245
+ }
246
+ function DataTable({ title, columns, rows }) {
247
+ return /* @__PURE__ */ jsxs("section", { children: [
248
+ /* @__PURE__ */ jsx("h2", { children: title }),
249
+ /* @__PURE__ */ jsx("div", { className: "analytics-scroll", children: /* @__PURE__ */ jsxs("table", { children: [
250
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: columns.map((column) => /* @__PURE__ */ jsx("th", { scope: "col", children: column }, column)) }) }),
251
+ /* @__PURE__ */ jsx("tbody", { children: rows.length === 0 ? /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: columns.length, children: "None in this range." }) }) : rows.map((row, index) => /* @__PURE__ */ jsx("tr", { children: row.map((cell, cellIndex) => /* @__PURE__ */ jsx("td", { children: cell }, `${columns[cellIndex] ?? cellIndex}`)) }, `${title}-${index}`)) })
252
+ ] }) })
253
+ ] });
254
+ }
255
+ function reportHref(query) {
256
+ const params = new URLSearchParams();
257
+ params.set("from", query.from);
258
+ params.set("to", query.to);
259
+ if (query.country) params.set("country", query.country);
260
+ if (query.path) params.set("path", query.path);
261
+ if (query.session) params.set("session", query.session);
262
+ return `/admin/analytics?${params.toString()}`;
263
+ }
264
+ function resolveRange(searchParams) {
265
+ const now = /* @__PURE__ */ new Date();
266
+ const to = parseTime(firstParam(searchParams?.to), now);
267
+ const from = parseTime(firstParam(searchParams?.from), new Date(to.getTime() - WEEK_MS));
268
+ return { from: from.toISOString(), to: to.toISOString() };
269
+ }
270
+ function parseTime(value, fallback) {
271
+ if (value === null) return fallback;
272
+ const parsed = new Date(value);
273
+ if (Number.isNaN(parsed.getTime())) return fallback;
274
+ return parsed;
275
+ }
276
+ function firstParam(value) {
277
+ const first = Array.isArray(value) ? value[0] : value;
278
+ if (typeof first !== "string") return null;
279
+ const trimmed = first.trim();
280
+ return trimmed.length === 0 ? null : trimmed;
281
+ }
282
+ function formatSeconds(value) {
283
+ if (!Number.isFinite(value)) return "0 seconds";
284
+ const rounded = Math.round(value * 10) / 10;
285
+ const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
286
+ return `${text} ${rounded === 1 ? "second" : "seconds"}`;
287
+ }
288
+ export {
289
+ AnalyticsAdminView
290
+ };
@@ -0,0 +1,70 @@
1
+ import { Access, Config, Payload } from 'payload';
2
+ import { AnalyticsModules, AnalyticsServerOptions } from '../index.js';
3
+ import { AnalyticsSummary, AnalyticsEventStore, NewAnalyticsEvent, StoredAnalyticsEvent, AnalyticsSummaryQuery } from '../index.js';
4
+
5
+ /**
6
+ * Read is allowed for a signed-in user, or by the function the plugin was given.
7
+ * Create, update, and delete stay closed. The ingest endpoint writes with overrideAccess.
8
+ */
9
+ declare function analyticsEventsAccess(read?: Access): {
10
+ read: Access;
11
+ create: Access;
12
+ update: Access;
13
+ delete: Access;
14
+ };
15
+
16
+ interface AdminSectionsQuery {
17
+ session?: string | null;
18
+ }
19
+ type AdminSection = {
20
+ kind: "totals";
21
+ totalViews: number;
22
+ averageDurationSeconds: number;
23
+ } | {
24
+ kind: "paths";
25
+ rows: AnalyticsSummary["byPath"];
26
+ } | {
27
+ kind: "countries";
28
+ rows: NonNullable<AnalyticsSummary["byCountry"]>;
29
+ } | {
30
+ kind: "cities";
31
+ rows: NonNullable<AnalyticsSummary["byCity"]>;
32
+ } | {
33
+ kind: "namedClicks";
34
+ rows: NonNullable<AnalyticsSummary["byEventName"]>;
35
+ } | {
36
+ kind: "sessions";
37
+ rows: NonNullable<AnalyticsSummary["sessions"]>;
38
+ } | {
39
+ kind: "trail";
40
+ events: NonNullable<AnalyticsSummary["events"]>;
41
+ };
42
+ /**
43
+ * Blocks for the admin screen, in display order.
44
+ * A module that is off contributes no block.
45
+ * A session query replaces the session list with one trail of that session's events, in time order.
46
+ */
47
+ declare function adminSections(summary: AnalyticsSummary, modules: AnalyticsModules, query: AdminSectionsQuery): AdminSection[];
48
+
49
+ /**
50
+ * Adds the analytics collection, the ingest endpoint, and the admin screen.
51
+ * Existing collections, endpoints, and views stay in place.
52
+ */
53
+ declare function analyticsPlugin(options: AnalyticsServerOptions & {
54
+ read?: Access;
55
+ }): (config: Config) => Config;
56
+
57
+ /**
58
+ * AnalyticsEventStore on Payload's Local API.
59
+ * Rows in the requested range are grouped by summarizeAnalyticsEvents.
60
+ */
61
+ declare class PayloadAnalyticsEventStore implements AnalyticsEventStore {
62
+ private readonly payload;
63
+ private readonly modules;
64
+ constructor(payload: Payload, modules: AnalyticsModules);
65
+ insertAnalyticsEvent(event: NewAnalyticsEvent, occurredAt: string): Promise<StoredAnalyticsEvent>;
66
+ recordAnalyticsDuration(id: string, durationSeconds: number): Promise<void>;
67
+ summarizeAnalytics(query: AnalyticsSummaryQuery, modules: AnalyticsModules): Promise<AnalyticsSummary>;
68
+ }
69
+
70
+ export { PayloadAnalyticsEventStore, adminSections, analyticsEventsAccess, analyticsPlugin };
@@ -0,0 +1,100 @@
1
+ import {
2
+ recordAnalyticsEvent
3
+ } from "../chunk-GWOVVKP3.js";
4
+ import {
5
+ ANALYTICS_CUSTOM_KEY,
6
+ PayloadAnalyticsEventStore,
7
+ adminSections,
8
+ analyticsEventsAccess,
9
+ analyticsEventsCollection
10
+ } from "../chunk-GVOESWHZ.js";
11
+ import "../chunk-Q4JRXZ6M.js";
12
+ import "../chunk-F7BY2JQ3.js";
13
+
14
+ // src/payload/endpoint.ts
15
+ async function handleAnalyticsEvent(request, deps) {
16
+ const result = await recordAnalyticsEvent(
17
+ { body: request.body, headers: headerRecord(request.headers) },
18
+ deps
19
+ );
20
+ if (result.status === 204 || result.body === null) {
21
+ return new Response(null, { status: result.status });
22
+ }
23
+ return Response.json(result.body, { status: result.status });
24
+ }
25
+ function analyticsEventsEndpoint(options) {
26
+ return {
27
+ path: "/analytics/events",
28
+ method: "post",
29
+ handler: (req) => handlePayloadAnalyticsEvent(req, options)
30
+ };
31
+ }
32
+ async function handlePayloadAnalyticsEvent(req, options) {
33
+ const store = new PayloadAnalyticsEventStore(req.payload, options.modules);
34
+ let body;
35
+ try {
36
+ body = typeof req.json === "function" ? await req.json() : req.data;
37
+ } catch {
38
+ body = void 0;
39
+ }
40
+ return handleAnalyticsEvent({ body, headers: req.headers }, { store, options });
41
+ }
42
+ function headerRecord(headers) {
43
+ if (headers instanceof Headers) {
44
+ const record = {};
45
+ headers.forEach((value, key) => {
46
+ record[key] = value;
47
+ });
48
+ return record;
49
+ }
50
+ return headers;
51
+ }
52
+
53
+ // src/payload/plugin.ts
54
+ function analyticsPlugin(options) {
55
+ return (config) => {
56
+ const admin = config.admin ?? {};
57
+ const components = admin.components ?? {};
58
+ return {
59
+ ...config,
60
+ collections: [...config.collections ?? [], analyticsEventsCollection(options.read)],
61
+ endpoints: [...config.endpoints ?? [], analyticsEventsEndpoint(options)],
62
+ admin: {
63
+ ...admin,
64
+ components: {
65
+ ...components,
66
+ views: {
67
+ ...components.views,
68
+ analytics: {
69
+ Component: {
70
+ path: "@nodewrite-labs/analytics-sdk/payload/admin-view",
71
+ exportName: "AnalyticsAdminView"
72
+ },
73
+ path: "/analytics"
74
+ }
75
+ },
76
+ afterNavLinks: [
77
+ ...components.afterNavLinks ?? [],
78
+ {
79
+ path: "@nodewrite-labs/analytics-sdk/payload/nav-link",
80
+ exportName: "AnalyticsNavLink"
81
+ }
82
+ ]
83
+ }
84
+ },
85
+ custom: {
86
+ ...config.custom,
87
+ [ANALYTICS_CUSTOM_KEY]: {
88
+ enabled: options.enabled,
89
+ modules: options.modules
90
+ }
91
+ }
92
+ };
93
+ };
94
+ }
95
+ export {
96
+ PayloadAnalyticsEventStore,
97
+ adminSections,
98
+ analyticsEventsAccess,
99
+ analyticsPlugin
100
+ };
@@ -0,0 +1,5 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ declare function AnalyticsNavLink(): react_jsx_runtime.JSX.Element;
4
+
5
+ export { AnalyticsNavLink };
@@ -0,0 +1,8 @@
1
+ // src/payload/admin/nav-link.tsx
2
+ import { jsx } from "react/jsx-runtime";
3
+ function AnalyticsNavLink() {
4
+ return /* @__PURE__ */ jsx("a", { href: "/admin/analytics", children: "Analytics" });
5
+ }
6
+ export {
7
+ AnalyticsNavLink
8
+ };
@@ -0,0 +1,17 @@
1
+ import { Pool } from 'pg';
2
+ import { AnalyticsEventStore, NewAnalyticsEvent, StoredAnalyticsEvent, AnalyticsSummaryQuery, AnalyticsSummary } from '../index.js';
3
+ import { AnalyticsModules } from '../index.js';
4
+
5
+ declare class PostgresAnalyticsEventStore implements AnalyticsEventStore {
6
+ private readonly pool;
7
+ private readonly modules;
8
+ constructor(pool: Pool, modules: AnalyticsModules);
9
+ insertAnalyticsEvent(event: NewAnalyticsEvent, occurredAt: string): Promise<StoredAnalyticsEvent>;
10
+ recordAnalyticsDuration(id: string, durationSeconds: number): Promise<void>;
11
+ summarizeAnalytics(query: AnalyticsSummaryQuery, modules: AnalyticsModules): Promise<AnalyticsSummary>;
12
+ }
13
+
14
+ declare const analyticsEventSchemaSql: string;
15
+ declare function applyAnalyticsSchema(pool: Pool): Promise<void>;
16
+
17
+ export { PostgresAnalyticsEventStore, analyticsEventSchemaSql, applyAnalyticsSchema };
@@ -0,0 +1,156 @@
1
+ import {
2
+ acceptDurationSeconds,
3
+ summarizeAnalyticsEvents
4
+ } from "../chunk-Q4JRXZ6M.js";
5
+ import {
6
+ AnalyticsEventKind
7
+ } from "../chunk-F7BY2JQ3.js";
8
+
9
+ // src/postgres/store.ts
10
+ import { randomUUID } from "crypto";
11
+ var INSERT_EVENT = `
12
+ insert into analytics_event (
13
+ id,
14
+ kind,
15
+ path,
16
+ locale,
17
+ occurred_at,
18
+ duration_seconds,
19
+ country,
20
+ city,
21
+ session_id,
22
+ event_name
23
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
24
+ returning
25
+ id,
26
+ kind,
27
+ path,
28
+ locale,
29
+ occurred_at,
30
+ duration_seconds,
31
+ country,
32
+ city,
33
+ session_id,
34
+ event_name
35
+ `;
36
+ var SELECT_IN_RANGE = `
37
+ select
38
+ id,
39
+ kind,
40
+ path,
41
+ locale,
42
+ occurred_at,
43
+ duration_seconds,
44
+ country,
45
+ city,
46
+ session_id,
47
+ event_name
48
+ from analytics_event
49
+ where occurred_at >= $1 and occurred_at <= $2
50
+ `;
51
+ var PostgresAnalyticsEventStore = class {
52
+ constructor(pool, modules) {
53
+ this.pool = pool;
54
+ this.modules = modules;
55
+ }
56
+ pool;
57
+ modules;
58
+ async insertAnalyticsEvent(event, occurredAt) {
59
+ const id = randomUUID();
60
+ const result = await this.pool.query(INSERT_EVENT, [
61
+ id,
62
+ event.kind,
63
+ event.path,
64
+ event.locale,
65
+ occurredAt,
66
+ durationColumn(event.durationSeconds),
67
+ this.modules.country ? event.country : null,
68
+ this.modules.city ? event.city : null,
69
+ this.modules.sessions ? event.sessionId : null,
70
+ this.modules.clicks ? event.eventName : null
71
+ ]);
72
+ return requiredRow(result.rows[0]);
73
+ }
74
+ async recordAnalyticsDuration(id, durationSeconds) {
75
+ const accepted = acceptDurationSeconds(durationSeconds);
76
+ if (accepted === null) return;
77
+ await this.pool.query(
78
+ "update analytics_event set duration_seconds = $1 where id = $2 and kind = $3",
79
+ [Math.round(accepted), id, AnalyticsEventKind.VIEW]
80
+ );
81
+ }
82
+ async summarizeAnalytics(query, modules) {
83
+ const result = await this.pool.query(SELECT_IN_RANGE, [query.from, query.to]);
84
+ const rows = [];
85
+ for (const row of result.rows) {
86
+ const mapped = mapRow(row);
87
+ if (mapped) rows.push(mapped);
88
+ }
89
+ return summarizeAnalyticsEvents(rows, query, modules);
90
+ }
91
+ };
92
+ function durationColumn(value) {
93
+ if (value === null) return null;
94
+ const accepted = acceptDurationSeconds(value);
95
+ if (accepted === null) return null;
96
+ return Math.round(accepted);
97
+ }
98
+ function requiredRow(row) {
99
+ const mapped = row ? mapRow(row) : null;
100
+ if (!mapped) throw new Error("analytics_event insert did not return a row");
101
+ return mapped;
102
+ }
103
+ function mapRow(row) {
104
+ if (row.kind !== AnalyticsEventKind.VIEW && row.kind !== AnalyticsEventKind.CLICK) return null;
105
+ return {
106
+ id: String(row.id),
107
+ kind: row.kind,
108
+ path: row.path,
109
+ locale: row.locale,
110
+ occurredAt: toIso(row.occurred_at),
111
+ durationSeconds: durationFromColumn(row.duration_seconds),
112
+ country: row.country,
113
+ city: row.city,
114
+ sessionId: row.session_id,
115
+ eventName: row.event_name
116
+ };
117
+ }
118
+ function durationFromColumn(value) {
119
+ if (value === null) return null;
120
+ const numeric = typeof value === "number" ? value : Number(value);
121
+ if (!Number.isFinite(numeric)) return null;
122
+ return numeric;
123
+ }
124
+ function toIso(value) {
125
+ if (value instanceof Date) return value.toISOString();
126
+ return new Date(value).toISOString();
127
+ }
128
+
129
+ // src/postgres/schema.ts
130
+ import { readFileSync } from "fs";
131
+ import { dirname, join } from "path";
132
+ import { fileURLToPath } from "url";
133
+ var here = dirname(fileURLToPath(import.meta.url));
134
+ function readSchema() {
135
+ const candidates = [join(here, "schema.sql"), join(here, "..", "..", "src", "postgres", "schema.sql")];
136
+ for (const candidate of candidates) {
137
+ try {
138
+ return readFileSync(candidate, "utf8");
139
+ } catch (error) {
140
+ if (!isMissingFile(error)) throw error;
141
+ }
142
+ }
143
+ throw new Error("analytics schema.sql was not found");
144
+ }
145
+ function isMissingFile(error) {
146
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
147
+ }
148
+ var analyticsEventSchemaSql = readSchema();
149
+ async function applyAnalyticsSchema(pool) {
150
+ await pool.query(analyticsEventSchemaSql);
151
+ }
152
+ export {
153
+ PostgresAnalyticsEventStore,
154
+ analyticsEventSchemaSql,
155
+ applyAnalyticsSchema
156
+ };
@@ -0,0 +1,44 @@
1
+ import { AnalyticsEventStore } from '../index.js';
2
+ import { AnalyticsServerOptions } from '../index.js';
3
+
4
+ interface RequestGeo {
5
+ country: string | null;
6
+ city: string | null;
7
+ }
8
+ /** Reads `country` and `city` from the request object the app passed in. */
9
+ declare function readRequestGeo(request: object): RequestGeo;
10
+
11
+ type AnalyticsHeaderBag = Headers | Record<string, string | string[] | undefined>;
12
+
13
+ interface AnalyticsEventRequest {
14
+ body?: unknown;
15
+ headers?: AnalyticsHeaderBag;
16
+ request?: Request;
17
+ country?: unknown;
18
+ city?: unknown;
19
+ }
20
+ interface RecordAnalyticsEventDeps {
21
+ store: AnalyticsEventStore;
22
+ options: AnalyticsServerOptions;
23
+ now?: () => string;
24
+ }
25
+ interface RecordAnalyticsEventResult {
26
+ status: number;
27
+ body: {
28
+ id: string;
29
+ } | null;
30
+ }
31
+ /**
32
+ * Validates one analytics request and writes it through the store.
33
+ *
34
+ * A stored view or click returns status 200 and `{ id }`.
35
+ * Status 204 and a null body means analytics is disabled, clicks are off, or a duration was recorded.
36
+ * Status 400 and a null body means the request cannot be stored.
37
+ * `occurredAt` comes from `deps.now`, otherwise the server clock.
38
+ * Pass a Fetch `Request`, or `{ body, country, city }`. `request` is read when `body` is omitted.
39
+ * Country and city are the properties on that object. The JSON body is not a source for them.
40
+ */
41
+ declare function recordAnalyticsEvent(request: AnalyticsEventRequest | Request, deps: RecordAnalyticsEventDeps): Promise<RecordAnalyticsEventResult>;
42
+
43
+ export { readRequestGeo, recordAnalyticsEvent };
44
+ export type { AnalyticsEventRequest, AnalyticsHeaderBag, RecordAnalyticsEventDeps, RecordAnalyticsEventResult, RequestGeo };