@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.
Files changed (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. 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
+ }