@stacksjs/ts-analytics 0.1.6
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/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { querySessionItemsInRange, queryAllItems, dynamodb, TABLE_NAME, unmarshall } from '../lib/dynamodb'
|
|
6
|
+
import { readDimRollupPrefix } from '../lib/rollups'
|
|
7
|
+
import { parseDateRange, formatDuration } from '../utils/date'
|
|
8
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
9
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/sites/{siteId}/sessions
|
|
13
|
+
*/
|
|
14
|
+
export async function handleGetSessions(request: Request, siteId: string): Promise<Response> {
|
|
15
|
+
try {
|
|
16
|
+
const query = getQueryParams(request)
|
|
17
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
18
|
+
const limit = Math.min(Number(query.limit) || 50, 200)
|
|
19
|
+
const filter = query.filter || ''
|
|
20
|
+
|
|
21
|
+
// Time-keyed GSI query (#171): date-bounded and startedAt-DESC ordered
|
|
22
|
+
// (the raw sk is random-id order, so "recent" was arbitrary before).
|
|
23
|
+
const result = await queryAllItems({
|
|
24
|
+
TableName: TABLE_NAME,
|
|
25
|
+
IndexName: 'GSI1',
|
|
26
|
+
KeyConditionExpression: 'gsi1pk = :pk AND gsi1sk BETWEEN :start AND :end',
|
|
27
|
+
ExpressionAttributeValues: {
|
|
28
|
+
':pk': { S: `SITE#${siteId}#SESSIONS` },
|
|
29
|
+
':start': { S: startDate.toISOString() },
|
|
30
|
+
':end': { S: `${endDate.toISOString()}~` },
|
|
31
|
+
},
|
|
32
|
+
ScanIndexForward: false,
|
|
33
|
+
Limit: limit * 2,
|
|
34
|
+
}, limit * 2) as { Items?: any[] }
|
|
35
|
+
|
|
36
|
+
let sessions = (result.Items || []).map(unmarshall).filter((s: any) => {
|
|
37
|
+
const sessionTime = new Date(s.startedAt || s.endedAt || s.timestamp)
|
|
38
|
+
return sessionTime >= startDate && sessionTime <= endDate
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (filter) {
|
|
42
|
+
const f = filter.toLowerCase()
|
|
43
|
+
sessions = sessions.filter((s: any) =>
|
|
44
|
+
(s.country?.toLowerCase().includes(f)) ||
|
|
45
|
+
(s.browser?.toLowerCase().includes(f)) ||
|
|
46
|
+
(s.deviceType?.toLowerCase().includes(f)) ||
|
|
47
|
+
(s.os?.toLowerCase().includes(f)) ||
|
|
48
|
+
(s.referrerSource?.toLowerCase().includes(f))
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sessions = sessions.slice(0, limit)
|
|
53
|
+
|
|
54
|
+
const formattedSessions = sessions.map((s: any) => ({
|
|
55
|
+
id: s.id || s.sessionId,
|
|
56
|
+
visitorId: s.visitorId,
|
|
57
|
+
startedAt: s.startedAt,
|
|
58
|
+
endedAt: s.endedAt,
|
|
59
|
+
duration: s.duration,
|
|
60
|
+
durationFormatted: formatDuration(s.duration || 0),
|
|
61
|
+
pageViewCount: s.pageViewCount || 0,
|
|
62
|
+
eventCount: s.eventCount || 0,
|
|
63
|
+
isBounce: s.isBounce,
|
|
64
|
+
entryPage: s.entryPage,
|
|
65
|
+
exitPage: s.exitPage,
|
|
66
|
+
referrerSource: s.referrerSource || 'direct',
|
|
67
|
+
country: s.country,
|
|
68
|
+
region: s.region,
|
|
69
|
+
city: s.city,
|
|
70
|
+
deviceType: s.deviceType,
|
|
71
|
+
browser: s.browser,
|
|
72
|
+
os: s.os,
|
|
73
|
+
utmSource: s.utmSource,
|
|
74
|
+
utmMedium: s.utmMedium,
|
|
75
|
+
utmCampaign: s.utmCampaign,
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
return jsonResponse({
|
|
79
|
+
sessions: formattedSessions,
|
|
80
|
+
total: formattedSessions.length,
|
|
81
|
+
dateRange: {
|
|
82
|
+
start: startDate.toISOString(),
|
|
83
|
+
end: endDate.toISOString(),
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error('Get sessions error:', error)
|
|
89
|
+
return errorResponse('Failed to fetch sessions')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /api/sites/{siteId}/sessions/{sessionId}
|
|
95
|
+
*/
|
|
96
|
+
export async function handleGetSessionDetail(request: Request, siteId: string, sessionId: string): Promise<Response> {
|
|
97
|
+
try {
|
|
98
|
+
const query = getQueryParams(request)
|
|
99
|
+
const includeEvents = query.includeEvents !== 'false'
|
|
100
|
+
const includePageviews = query.includePageviews !== 'false'
|
|
101
|
+
|
|
102
|
+
// Get session
|
|
103
|
+
const sessionResult = await dynamodb.query({
|
|
104
|
+
TableName: TABLE_NAME,
|
|
105
|
+
KeyConditionExpression: 'pk = :pk AND sk = :sk',
|
|
106
|
+
ExpressionAttributeValues: {
|
|
107
|
+
':pk': { S: `SITE#${siteId}` },
|
|
108
|
+
':sk': { S: `SESSION#${sessionId}` },
|
|
109
|
+
},
|
|
110
|
+
}) as { Items?: any[] }
|
|
111
|
+
|
|
112
|
+
if (!sessionResult.Items || sessionResult.Items.length === 0) {
|
|
113
|
+
return jsonResponse({ error: 'Session not found' }, 404)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = unmarshall(sessionResult.Items[0])
|
|
117
|
+
|
|
118
|
+
// The session timeline was PERMANENTLY EMPTY (#171): these queries used
|
|
119
|
+
// gsi1pk = SESSION#{id}, but pageviews/events write DATE-keyed gsi1pk
|
|
120
|
+
// values — the key could never match (and the index name was the wrong
|
|
121
|
+
// case). Query the session's own time window on the main table instead
|
|
122
|
+
// and filter by sessionId.
|
|
123
|
+
const windowStart = typeof session.startedAt === 'string' ? session.startedAt : new Date(session.startedAt).toISOString()
|
|
124
|
+
const windowEnd = session.endedAt
|
|
125
|
+
? (typeof session.endedAt === 'string' ? session.endedAt : new Date(session.endedAt).toISOString())
|
|
126
|
+
: new Date().toISOString()
|
|
127
|
+
|
|
128
|
+
const itemsInWindow = async (prefix: string): Promise<any[]> => {
|
|
129
|
+
const res = await queryAllItems({
|
|
130
|
+
TableName: TABLE_NAME,
|
|
131
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
132
|
+
ExpressionAttributeValues: {
|
|
133
|
+
':pk': { S: `SITE#${siteId}` },
|
|
134
|
+
':start': { S: `${prefix}${windowStart}` },
|
|
135
|
+
':end': { S: `${prefix}${windowEnd}~` },
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
return (res.Items || []).map(unmarshall)
|
|
139
|
+
.filter(item => item.sessionId === sessionId)
|
|
140
|
+
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Get pageviews for this session
|
|
144
|
+
let pageviews: any[] = []
|
|
145
|
+
if (includePageviews) {
|
|
146
|
+
pageviews = await itemsInWindow('PAGEVIEW#')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get events for this session
|
|
150
|
+
let events: any[] = []
|
|
151
|
+
if (includeEvents) {
|
|
152
|
+
events = await itemsInWindow('EVENT#')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build timeline of all activities
|
|
156
|
+
const timeline = [
|
|
157
|
+
...pageviews.map(pv => ({
|
|
158
|
+
type: 'pageview' as const,
|
|
159
|
+
timestamp: pv.timestamp,
|
|
160
|
+
path: pv.path,
|
|
161
|
+
title: pv.title,
|
|
162
|
+
referrer: pv.referrer,
|
|
163
|
+
})),
|
|
164
|
+
...events.map(e => ({
|
|
165
|
+
type: 'event' as const,
|
|
166
|
+
timestamp: e.timestamp,
|
|
167
|
+
eventName: e.eventName,
|
|
168
|
+
properties: e.properties,
|
|
169
|
+
})),
|
|
170
|
+
].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
171
|
+
|
|
172
|
+
return jsonResponse({
|
|
173
|
+
session: {
|
|
174
|
+
id: session.id || session.sessionId,
|
|
175
|
+
visitorId: session.visitorId,
|
|
176
|
+
startedAt: session.startedAt,
|
|
177
|
+
endedAt: session.endedAt,
|
|
178
|
+
duration: session.duration,
|
|
179
|
+
durationFormatted: formatDuration(session.duration || 0),
|
|
180
|
+
pageViewCount: session.pageViewCount || pageviews.length,
|
|
181
|
+
eventCount: session.eventCount || events.length,
|
|
182
|
+
isBounce: session.isBounce,
|
|
183
|
+
entryPage: session.entryPage,
|
|
184
|
+
exitPage: session.exitPage,
|
|
185
|
+
referrerSource: session.referrerSource || 'direct',
|
|
186
|
+
referrer: session.referrer,
|
|
187
|
+
country: session.country,
|
|
188
|
+
region: session.region,
|
|
189
|
+
city: session.city,
|
|
190
|
+
deviceType: session.deviceType,
|
|
191
|
+
browser: session.browser,
|
|
192
|
+
os: session.os,
|
|
193
|
+
screenSize: session.screenSize,
|
|
194
|
+
language: session.language,
|
|
195
|
+
utmSource: session.utmSource,
|
|
196
|
+
utmMedium: session.utmMedium,
|
|
197
|
+
utmCampaign: session.utmCampaign,
|
|
198
|
+
utmContent: session.utmContent,
|
|
199
|
+
utmTerm: session.utmTerm,
|
|
200
|
+
},
|
|
201
|
+
pageviews,
|
|
202
|
+
events,
|
|
203
|
+
timeline,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
console.error('Get session detail error:', error)
|
|
208
|
+
return errorResponse('Failed to fetch session detail')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* GET /api/sites/{siteId}/flow
|
|
214
|
+
*/
|
|
215
|
+
export async function handleGetUserFlow(request: Request, siteId: string): Promise<Response> {
|
|
216
|
+
try {
|
|
217
|
+
const query = getQueryParams(request)
|
|
218
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
219
|
+
const depth = Math.min(Number(query.depth) || 5, 10)
|
|
220
|
+
|
|
221
|
+
// Query sessions
|
|
222
|
+
const result = await querySessionItemsInRange(siteId, startDate, endDate) as { Items?: any[] }
|
|
223
|
+
|
|
224
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
225
|
+
const sessionStart = new Date(s.startedAt)
|
|
226
|
+
return sessionStart >= startDate && sessionStart <= endDate
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Build flow data from entry/exit pages and page sequences
|
|
230
|
+
const flows: Record<string, { from: string; to: string; count: number }> = {}
|
|
231
|
+
const entryPages: Record<string, number> = {}
|
|
232
|
+
const exitPages: Record<string, number> = {}
|
|
233
|
+
|
|
234
|
+
for (const session of sessions) {
|
|
235
|
+
// Track entry pages
|
|
236
|
+
if (session.entryPage) {
|
|
237
|
+
entryPages[session.entryPage] = (entryPages[session.entryPage] || 0) + 1
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Track exit pages
|
|
241
|
+
if (session.exitPage) {
|
|
242
|
+
exitPages[session.exitPage] = (exitPages[session.exitPage] || 0) + 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Track page flows if we have page sequence
|
|
246
|
+
const pageSequence = session.pageSequence || []
|
|
247
|
+
for (let i = 0; i < Math.min(pageSequence.length - 1, depth); i++) {
|
|
248
|
+
const key = `${pageSequence[i]}|${pageSequence[i + 1]}`
|
|
249
|
+
if (!flows[key]) {
|
|
250
|
+
flows[key] = { from: pageSequence[i], to: pageSequence[i + 1], count: 0 }
|
|
251
|
+
}
|
|
252
|
+
flows[key].count++
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const flowData = Object.values(flows)
|
|
257
|
+
.sort((a, b) => b.count - a.count)
|
|
258
|
+
.slice(0, 100)
|
|
259
|
+
|
|
260
|
+
const topEntryPages = Object.entries(entryPages)
|
|
261
|
+
.map(([path, count]) => ({ path, count }))
|
|
262
|
+
.sort((a, b) => b.count - a.count)
|
|
263
|
+
.slice(0, 10)
|
|
264
|
+
|
|
265
|
+
const topExitPages = Object.entries(exitPages)
|
|
266
|
+
.map(([path, count]) => ({ path, count }))
|
|
267
|
+
.sort((a, b) => b.count - a.count)
|
|
268
|
+
.slice(0, 10)
|
|
269
|
+
|
|
270
|
+
return jsonResponse({
|
|
271
|
+
flows: flowData,
|
|
272
|
+
entryPages: topEntryPages,
|
|
273
|
+
exitPages: topExitPages,
|
|
274
|
+
totalSessions: sessions.length,
|
|
275
|
+
dateRange: {
|
|
276
|
+
start: startDate.toISOString(),
|
|
277
|
+
end: endDate.toISOString(),
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
console.error('Get user flow error:', error)
|
|
283
|
+
return errorResponse('Failed to fetch user flow')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* GET /api/sites/{siteId}/entry-exit
|
|
289
|
+
*/
|
|
290
|
+
export async function handleGetEntryExitPages(request: Request, siteId: string): Promise<Response> {
|
|
291
|
+
try {
|
|
292
|
+
const query = getQueryParams(request)
|
|
293
|
+
const { startDate, endDate } = parseDateRange(query)
|
|
294
|
+
const limit = Math.min(Number(query.limit) || 10, 100)
|
|
295
|
+
|
|
296
|
+
// Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
|
|
297
|
+
const { days: rolled, rawWindowStart } = await readDimRollupPrefix(siteId, startDate, endDate)
|
|
298
|
+
|
|
299
|
+
const result = rawWindowStart <= endDate
|
|
300
|
+
? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
|
|
301
|
+
: { Items: [] }
|
|
302
|
+
|
|
303
|
+
const sessions = (result.Items || []).map(unmarshall).filter(s => {
|
|
304
|
+
const sessionStart = new Date(s.startedAt)
|
|
305
|
+
return sessionStart >= startDate && sessionStart <= endDate
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const entryStats: Record<string, { sessions: number; bounces: number }> = {}
|
|
309
|
+
const exitStats: Record<string, { sessions: number }> = {}
|
|
310
|
+
|
|
311
|
+
for (const session of sessions) {
|
|
312
|
+
// Sessions store entryPath/exitPath — this handler read the nonexistent
|
|
313
|
+
// entryPage/exitPage fields, so the report was ALWAYS empty (#172 audit).
|
|
314
|
+
if (session.entryPath) {
|
|
315
|
+
if (!entryStats[session.entryPath]) {
|
|
316
|
+
entryStats[session.entryPath] = { sessions: 0, bounces: 0 }
|
|
317
|
+
}
|
|
318
|
+
entryStats[session.entryPath].sessions++
|
|
319
|
+
if (session.isBounce) entryStats[session.entryPath].bounces++
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (session.exitPath) {
|
|
323
|
+
if (!exitStats[session.exitPath]) {
|
|
324
|
+
exitStats[session.exitPath] = { sessions: 0 }
|
|
325
|
+
}
|
|
326
|
+
exitStats[session.exitPath].sessions++
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Merge the rolled prefix.
|
|
331
|
+
for (const dayR of rolled) {
|
|
332
|
+
for (const [path, cell] of Object.entries(dayR.entries || {})) {
|
|
333
|
+
const e = (entryStats[path] ||= { sessions: 0, bounces: 0 })
|
|
334
|
+
e.sessions += cell.s
|
|
335
|
+
e.bounces += cell.b
|
|
336
|
+
}
|
|
337
|
+
for (const [path, cell] of Object.entries(dayR.exits || {})) {
|
|
338
|
+
const x = (exitStats[path] ||= { sessions: 0 })
|
|
339
|
+
x.sessions += cell.s
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const entryPages = Object.entries(entryStats)
|
|
344
|
+
.map(([path, stats]) => ({
|
|
345
|
+
path,
|
|
346
|
+
sessions: stats.sessions,
|
|
347
|
+
bounceRate: stats.sessions > 0 ? Math.round((stats.bounces / stats.sessions) * 100) : 0,
|
|
348
|
+
}))
|
|
349
|
+
.sort((a, b) => b.sessions - a.sessions)
|
|
350
|
+
.slice(0, limit)
|
|
351
|
+
|
|
352
|
+
const exitPages = Object.entries(exitStats)
|
|
353
|
+
.map(([path, stats]) => ({
|
|
354
|
+
path,
|
|
355
|
+
sessions: stats.sessions,
|
|
356
|
+
}))
|
|
357
|
+
.sort((a, b) => b.sessions - a.sessions)
|
|
358
|
+
.slice(0, limit)
|
|
359
|
+
|
|
360
|
+
return jsonResponse({ entryPages, exitPages })
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
console.error('Get entry/exit pages error:', error)
|
|
364
|
+
return errorResponse('Failed to fetch entry/exit pages')
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* GET /api/sites/{siteId}/live
|
|
370
|
+
*/
|
|
371
|
+
export async function handleGetLiveView(request: Request, siteId: string): Promise<Response> {
|
|
372
|
+
try {
|
|
373
|
+
const query = getQueryParams(request)
|
|
374
|
+
const minutes = Number(query.minutes) || 5
|
|
375
|
+
const cutoff = new Date(Date.now() - minutes * 60 * 1000)
|
|
376
|
+
|
|
377
|
+
const result = await dynamodb.query({
|
|
378
|
+
TableName: TABLE_NAME,
|
|
379
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
380
|
+
ExpressionAttributeValues: {
|
|
381
|
+
':pk': { S: `SITE#${siteId}` },
|
|
382
|
+
':start': { S: `PAGEVIEW#${cutoff.toISOString()}` },
|
|
383
|
+
':end': { S: 'PAGEVIEW#Z' },
|
|
384
|
+
},
|
|
385
|
+
ScanIndexForward: false,
|
|
386
|
+
Limit: 100,
|
|
387
|
+
}) as { Items?: any[] }
|
|
388
|
+
|
|
389
|
+
const pageviews = (result.Items || []).map(unmarshall)
|
|
390
|
+
|
|
391
|
+
const liveVisitors = pageviews.map(pv => ({
|
|
392
|
+
visitorId: pv.visitorId?.substring(0, 8) + '...',
|
|
393
|
+
path: pv.path,
|
|
394
|
+
timestamp: pv.timestamp,
|
|
395
|
+
country: pv.country,
|
|
396
|
+
deviceType: pv.deviceType,
|
|
397
|
+
browser: pv.browser,
|
|
398
|
+
}))
|
|
399
|
+
|
|
400
|
+
const uniqueVisitors = new Set(pageviews.map(pv => pv.visitorId)).size
|
|
401
|
+
|
|
402
|
+
return jsonResponse({
|
|
403
|
+
visitors: liveVisitors,
|
|
404
|
+
count: uniqueVisitors,
|
|
405
|
+
pageviews: pageviews.length,
|
|
406
|
+
lastUpdated: new Date().toISOString(),
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
console.error('Get live view error:', error)
|
|
411
|
+
return errorResponse('Failed to fetch live view')
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share link handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateId } from '../index'
|
|
6
|
+
import { randomToken } from '../lib/crypto-random'
|
|
7
|
+
import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
|
|
8
|
+
import { jsonResponse, errorResponse } from '../utils/response'
|
|
9
|
+
import { getQueryParams } from '../../deploy/lambda-adapter'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a secure share token (CSPRNG so tokens aren't guessable — #130).
|
|
13
|
+
*/
|
|
14
|
+
function generateShareToken(): string {
|
|
15
|
+
return randomToken(24)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* POST /api/sites/{siteId}/share
|
|
20
|
+
*/
|
|
21
|
+
export async function handleCreateShareLink(request: Request, siteId: string): Promise<Response> {
|
|
22
|
+
try {
|
|
23
|
+
const body = await request.json() as Record<string, any>
|
|
24
|
+
|
|
25
|
+
const token = generateShareToken()
|
|
26
|
+
const shareLink = {
|
|
27
|
+
pk: `SHARE#${token}`,
|
|
28
|
+
sk: `SITE#${siteId}`,
|
|
29
|
+
// Per-site listing (#152) — share rows were unreachable by site before.
|
|
30
|
+
gsi1pk: `SITE#${siteId}#SHARES`,
|
|
31
|
+
gsi1sk: new Date().toISOString(),
|
|
32
|
+
token,
|
|
33
|
+
siteId,
|
|
34
|
+
permissions: body.permissions || ['view'],
|
|
35
|
+
expiresAt: body.expiresAt || null,
|
|
36
|
+
password: body.password || null,
|
|
37
|
+
allowedMetrics: body.allowedMetrics || ['all'],
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await dynamodb.putItem({
|
|
42
|
+
TableName: TABLE_NAME,
|
|
43
|
+
Item: marshall(shareLink),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return jsonResponse({
|
|
47
|
+
shareLink: {
|
|
48
|
+
token,
|
|
49
|
+
url: `/shared/${token}`,
|
|
50
|
+
expiresAt: shareLink.expiresAt,
|
|
51
|
+
createdAt: shareLink.createdAt,
|
|
52
|
+
},
|
|
53
|
+
}, 201)
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('Create share link error:', error)
|
|
57
|
+
return errorResponse('Failed to create share link')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a share token to its link record (60s in-process cache — the guard
|
|
63
|
+
* consults this on every shared-dashboard API call, #152). Returns null for
|
|
64
|
+
* unknown or expired tokens.
|
|
65
|
+
*/
|
|
66
|
+
const tokenCache = new Map<string, { link: Record<string, any> | null, at: number }>()
|
|
67
|
+
|
|
68
|
+
export async function resolveShareToken(token: string): Promise<Record<string, any> | null> {
|
|
69
|
+
if (!/^[\w-]{16,64}$/.test(token))
|
|
70
|
+
return null
|
|
71
|
+
const hit = tokenCache.get(token)
|
|
72
|
+
if (hit && Date.now() - hit.at < 60_000)
|
|
73
|
+
return hit.link
|
|
74
|
+
const result = await dynamodb.query({
|
|
75
|
+
TableName: TABLE_NAME,
|
|
76
|
+
KeyConditionExpression: 'pk = :pk',
|
|
77
|
+
ExpressionAttributeValues: { ':pk': { S: `SHARE#${token}` } },
|
|
78
|
+
}) as { Items?: any[] }
|
|
79
|
+
const link = result.Items?.[0] ? unmarshall(result.Items[0]) : null
|
|
80
|
+
tokenCache.set(token, { link, at: Date.now() })
|
|
81
|
+
if (!link)
|
|
82
|
+
return null
|
|
83
|
+
if (link.expiresAt && new Date(link.expiresAt) < new Date())
|
|
84
|
+
return null
|
|
85
|
+
return link
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test hook. */
|
|
89
|
+
export function clearShareTokenCache(): void {
|
|
90
|
+
tokenCache.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /api/sites/{siteId}/share — list this site's share links (owner view;
|
|
95
|
+
* behind the site auth guard like every management endpoint).
|
|
96
|
+
*/
|
|
97
|
+
export async function handleListShareLinks(_request: Request, siteId: string): Promise<Response> {
|
|
98
|
+
try {
|
|
99
|
+
const { queryAllItems } = await import('../lib/dynamodb')
|
|
100
|
+
const result = await queryAllItems({
|
|
101
|
+
TableName: TABLE_NAME,
|
|
102
|
+
IndexName: 'GSI1',
|
|
103
|
+
KeyConditionExpression: 'gsi1pk = :pk',
|
|
104
|
+
ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}#SHARES` } },
|
|
105
|
+
})
|
|
106
|
+
const links = (result.Items || []).map(unmarshall).map(link => ({
|
|
107
|
+
token: link.token,
|
|
108
|
+
url: `/shared/${link.token}`,
|
|
109
|
+
hasPassword: !!link.password,
|
|
110
|
+
expiresAt: link.expiresAt || null,
|
|
111
|
+
createdAt: link.createdAt,
|
|
112
|
+
}))
|
|
113
|
+
return jsonResponse({ links })
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
console.error('List share links error:', error)
|
|
117
|
+
return errorResponse('Failed to list share links')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* DELETE /api/sites/{siteId}/share/{token} — revoke a share link.
|
|
123
|
+
*/
|
|
124
|
+
export async function handleRevokeShareLink(_request: Request, siteId: string, token: string): Promise<Response> {
|
|
125
|
+
try {
|
|
126
|
+
const link = await resolveShareToken(token)
|
|
127
|
+
if (link && link.siteId !== siteId) {
|
|
128
|
+
return jsonResponse({ error: 'Share link does not belong to this site' }, 403)
|
|
129
|
+
}
|
|
130
|
+
await dynamodb.deleteItem({
|
|
131
|
+
TableName: TABLE_NAME,
|
|
132
|
+
Key: { pk: { S: `SHARE#${token}` }, sk: { S: `SITE#${siteId}` } },
|
|
133
|
+
})
|
|
134
|
+
tokenCache.delete(token)
|
|
135
|
+
return jsonResponse({ success: true, token })
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error('Revoke share link error:', error)
|
|
139
|
+
return errorResponse('Failed to revoke share link')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* GET /api/share/{token}
|
|
145
|
+
*/
|
|
146
|
+
export async function handleGetSharedDashboard(request: Request, token: string): Promise<Response> {
|
|
147
|
+
try {
|
|
148
|
+
// Find share link
|
|
149
|
+
const result = await dynamodb.query({
|
|
150
|
+
TableName: TABLE_NAME,
|
|
151
|
+
KeyConditionExpression: 'pk = :pk',
|
|
152
|
+
ExpressionAttributeValues: {
|
|
153
|
+
':pk': { S: `SHARE#${token}` },
|
|
154
|
+
},
|
|
155
|
+
}) as { Items?: any[] }
|
|
156
|
+
|
|
157
|
+
if (!result.Items || result.Items.length === 0) {
|
|
158
|
+
return jsonResponse({ error: 'Invalid or expired share link' }, 404)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const shareLink = unmarshall(result.Items[0])
|
|
162
|
+
|
|
163
|
+
// Check expiration
|
|
164
|
+
if (shareLink.expiresAt && new Date(shareLink.expiresAt) < new Date()) {
|
|
165
|
+
return jsonResponse({ error: 'Share link has expired' }, 410)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check password if required
|
|
169
|
+
const query = getQueryParams(request)
|
|
170
|
+
if (shareLink.password && query.password !== shareLink.password) {
|
|
171
|
+
return jsonResponse({ error: 'Password required', requiresPassword: true }, 401)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Site display name for the public page header.
|
|
175
|
+
let siteName = shareLink.siteId
|
|
176
|
+
try {
|
|
177
|
+
const siteRes = await dynamodb.getItem({
|
|
178
|
+
TableName: TABLE_NAME,
|
|
179
|
+
Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${shareLink.siteId}` } },
|
|
180
|
+
}) as { Item?: Record<string, any> }
|
|
181
|
+
if (siteRes.Item)
|
|
182
|
+
siteName = unmarshall(siteRes.Item).name || siteName
|
|
183
|
+
}
|
|
184
|
+
catch {}
|
|
185
|
+
|
|
186
|
+
return jsonResponse({
|
|
187
|
+
valid: true,
|
|
188
|
+
siteId: shareLink.siteId,
|
|
189
|
+
siteName,
|
|
190
|
+
permissions: shareLink.permissions,
|
|
191
|
+
allowedMetrics: shareLink.allowedMetrics,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
console.error('Get shared dashboard error:', error)
|
|
196
|
+
return errorResponse('Failed to validate share link')
|
|
197
|
+
}
|
|
198
|
+
}
|