@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,625 @@
1
+ /**
2
+ * Data export, GDPR, and retention handlers
3
+ */
4
+
5
+ import { querySessionItemsInRange, dynamodb, TABLE_NAME, unmarshall, marshall, queryAllItems } from '../lib/dynamodb'
6
+ import { parseDateRange } from '../utils/date'
7
+ import { jsonResponse, errorResponse } from '../utils/response'
8
+ import { getQueryParams } from '../../deploy/lambda-adapter'
9
+ import { getUserPlan, planLimits } from '../lib/plans'
10
+
11
+ /**
12
+ * GET /api/sites/{siteId}/export
13
+ */
14
+ export async function handleExport(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const query = getQueryParams(request)
17
+ const { startDate, endDate } = parseDateRange(query)
18
+ const format = query.format || 'json'
19
+ const dataType = query.type || 'pageviews'
20
+
21
+ // Aggregated report export (#139): ?report=pages|referrers|browsers|os|
22
+ // countries|devices|events|campaigns exports the REPORT rows (what the
23
+ // dashboard shows) instead of a raw event dump.
24
+ if (query.report) {
25
+ const stats = await import('./stats')
26
+ const REPORTS: Record<string, { handler: (r: Request, s: string) => Promise<Response>, key: string }> = {
27
+ pages: { handler: stats.handleGetPages, key: 'pages' },
28
+ referrers: { handler: stats.handleGetReferrers, key: 'referrers' },
29
+ browsers: { handler: stats.handleGetBrowsers, key: 'browsers' },
30
+ os: { handler: stats.handleGetOS, key: 'os' },
31
+ countries: { handler: stats.handleGetCountries, key: 'countries' },
32
+ devices: { handler: stats.handleGetDevices, key: 'devices' },
33
+ events: { handler: stats.handleGetEvents, key: 'events' },
34
+ campaigns: { handler: stats.handleGetCampaigns, key: 'campaigns' },
35
+ 'screen-sizes': { handler: stats.handleGetScreenSizes, key: 'screenSizes' },
36
+ }
37
+ const report = REPORTS[query.report]
38
+ if (!report) {
39
+ return jsonResponse({ error: `Unknown report: ${query.report}` }, 400)
40
+ }
41
+ // Reuse the live handler (rolled+raw merge, filters, limits) at max limit.
42
+ const url = new URL(request.url)
43
+ url.searchParams.set('limit', '100')
44
+ const res = await report.handler(new Request(url.toString(), { headers: request.headers }), siteId)
45
+ if (!res.ok)
46
+ return res
47
+ const body = await res.json() as Record<string, any[]>
48
+ const rows = body[report.key] || []
49
+ if (format === 'csv') {
50
+ const headerSet = new Set<string>()
51
+ for (const row of rows) {
52
+ for (const k of Object.keys(row)) headerSet.add(k)
53
+ }
54
+ const headerList = [...headerSet]
55
+ const csv = [
56
+ headerList.join(','),
57
+ ...rows.map(row => headerList.map(k => row[k] === undefined ? '' : JSON.stringify(row[k])).join(',')),
58
+ ].join('\n')
59
+ return new Response(csv, {
60
+ headers: {
61
+ 'Content-Type': 'text/csv',
62
+ 'Content-Disposition': `attachment; filename="${siteId}-${query.report}-report.csv"`,
63
+ },
64
+ })
65
+ }
66
+ return jsonResponse({ data: rows, count: rows.length })
67
+ }
68
+
69
+ // Every raw record type is exportable (#178) — clicks/engagement/vitals
70
+ // were previously unreachable.
71
+ const PREFIXES: Record<string, string> = {
72
+ pageviews: 'PAGEVIEW#',
73
+ sessions: 'SESSION#',
74
+ events: 'EVENT#',
75
+ clicks: 'CLICK#',
76
+ engagement: 'ENGAGEMENT#',
77
+ vitals: 'VITAL#',
78
+ }
79
+ const prefix = PREFIXES[dataType] || 'PAGEVIEW#'
80
+
81
+ // Paginated (#178): the old single-page query silently truncated the
82
+ // export at ~1MB while looking complete.
83
+ const result = await queryAllItems({
84
+ TableName: TABLE_NAME,
85
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
86
+ ExpressionAttributeValues: {
87
+ ':pk': { S: `SITE#${siteId}` },
88
+ ':start': { S: `${prefix}${startDate.toISOString()}` },
89
+ ':end': { S: `${prefix}${endDate.toISOString()}~` },
90
+ },
91
+ })
92
+
93
+ const items = (result.Items || []).map(unmarshall)
94
+
95
+ if (format === 'csv') {
96
+ // Header = the UNION of keys across all rows (#178): rows vary by
97
+ // optional fields (utm, country, …), so first-row headers dropped
98
+ // whole columns from everything after it.
99
+ const headerSet = new Set<string>()
100
+ for (const item of items) {
101
+ for (const k of Object.keys(item)) headerSet.add(k)
102
+ }
103
+ const headerList = [...headerSet].sort()
104
+ const headers = headerList.join(',')
105
+ const rows = items.map(item => headerList.map(k => item[k] === undefined ? '' : JSON.stringify(item[k])).join(','))
106
+ const csv = [headers, ...rows].join('\n')
107
+
108
+ return new Response(csv, {
109
+ headers: {
110
+ 'Content-Type': 'text/csv',
111
+ 'Content-Disposition': `attachment; filename="${siteId}-${dataType}-export.csv"`,
112
+ },
113
+ })
114
+ }
115
+
116
+ return jsonResponse({
117
+ data: items,
118
+ count: items.length,
119
+ dateRange: {
120
+ start: startDate.toISOString(),
121
+ end: endDate.toISOString(),
122
+ },
123
+ })
124
+ }
125
+ catch (error) {
126
+ console.error('Export error:', error)
127
+ return errorResponse('Failed to export data')
128
+ }
129
+ }
130
+
131
+ /**
132
+ * GET /api/sites/{siteId}/retention
133
+ */
134
+ export async function handleGetRetentionSettings(_request: Request, siteId: string): Promise<Response> {
135
+ try {
136
+ const result = await dynamodb.query({
137
+ TableName: TABLE_NAME,
138
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
139
+ ExpressionAttributeValues: {
140
+ ':pk': { S: `SITE#${siteId}` },
141
+ ':sk': { S: 'RETENTION_SETTINGS' },
142
+ },
143
+ }) as { Items?: any[] }
144
+
145
+ const settings = result.Items?.[0] ? unmarshall(result.Items[0]) : {
146
+ retentionDays: 365,
147
+ autoDelete: true,
148
+ anonymizeAfterDays: 90,
149
+ }
150
+
151
+ return jsonResponse({ settings })
152
+ }
153
+ catch (error) {
154
+ console.error('Get retention settings error:', error)
155
+ return errorResponse('Failed to fetch retention settings')
156
+ }
157
+ }
158
+
159
+ /**
160
+ * PUT /api/sites/{siteId}/retention
161
+ */
162
+ export async function handleUpdateRetentionSettings(request: Request, siteId: string): Promise<Response> {
163
+ try {
164
+ const body = await request.json() as Record<string, any>
165
+
166
+ // Plan limit (#62): retention is clamped to the owning account's plan.
167
+ let retentionDays = body.retentionDays || 365
168
+ try {
169
+ const siteRes = await dynamodb.getItem({
170
+ TableName: TABLE_NAME,
171
+ Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${siteId}` } },
172
+ }) as { Item?: Record<string, any> }
173
+ const ownerId = siteRes.Item ? unmarshall(siteRes.Item).ownerId : undefined
174
+ if (ownerId) {
175
+ const limits = planLimits(await getUserPlan(ownerId))
176
+ if (limits.retentionDays > 0 && retentionDays > limits.retentionDays) {
177
+ retentionDays = limits.retentionDays
178
+ }
179
+ }
180
+ }
181
+ catch {
182
+ // clamp is best-effort; the requested value stands if the lookup fails
183
+ }
184
+
185
+ const settings = {
186
+ pk: `SITE#${siteId}`,
187
+ sk: 'RETENTION_SETTINGS',
188
+ siteId,
189
+ retentionDays,
190
+ autoDelete: body.autoDelete ?? true,
191
+ anonymizeAfterDays: body.anonymizeAfterDays || 90,
192
+ updatedAt: new Date().toISOString(),
193
+ }
194
+
195
+ await dynamodb.putItem({
196
+ TableName: TABLE_NAME,
197
+ Item: marshall(settings),
198
+ })
199
+
200
+ return jsonResponse({ settings })
201
+ }
202
+ catch (error) {
203
+ console.error('Update retention settings error:', error)
204
+ return errorResponse('Failed to update retention settings')
205
+ }
206
+ }
207
+
208
+ /**
209
+ * POST /api/sites/{siteId}/import/ga — GA4 CSV import (#155 Phase A).
210
+ * Body: { files: [{ name, content }] } — CSVs exported from GA4 with a Date
211
+ * column. Writes day rollups directly (never fabricates raw events, never
212
+ * overwrites tracked days); see src/lib/ga-import.ts for format detection.
213
+ */
214
+ export async function handleGaImport(request: Request, siteId: string): Promise<Response> {
215
+ try {
216
+ const MAX_IMPORT_BYTES = 15 * 1024 * 1024
217
+ const raw = await request.text()
218
+ if (raw.length > MAX_IMPORT_BYTES) {
219
+ return jsonResponse({ error: 'Import too large (15MB max per request)' }, 413)
220
+ }
221
+ let body: { files?: Array<{ name?: string, content?: string }> }
222
+ try {
223
+ body = JSON.parse(raw)
224
+ }
225
+ catch {
226
+ return jsonResponse({ error: 'Invalid JSON' }, 400)
227
+ }
228
+ const files = (body.files || [])
229
+ .filter(f => typeof f?.content === 'string' && f.content.length > 0)
230
+ .map(f => ({ name: String(f.name || 'upload.csv'), content: f.content as string }))
231
+ if (files.length === 0) {
232
+ return jsonResponse({ error: 'No CSV files provided' }, 400)
233
+ }
234
+ const { importGaData } = await import('../lib/ga-import')
235
+ const result = await importGaData(siteId, files)
236
+ return jsonResponse(result)
237
+ }
238
+ catch (error) {
239
+ console.error('GA import error:', error)
240
+ return errorResponse('Failed to import GA data')
241
+ }
242
+ }
243
+
244
+ /**
245
+ * POST /api/sites/{siteId}/import/ga4-api — GA4 Data API import (#155
246
+ * Phase B). Body: { propertyId, serviceAccountKey, startDate?, endDate? }.
247
+ * The service-account key is used for this request only — never persisted.
248
+ */
249
+ export async function handleGa4ApiImport(request: Request, siteId: string): Promise<Response> {
250
+ try {
251
+ let body: { propertyId?: string, serviceAccountKey?: { client_email?: string, private_key?: string }, startDate?: string, endDate?: string }
252
+ try {
253
+ body = await request.json() as typeof body
254
+ }
255
+ catch {
256
+ return jsonResponse({ error: 'Invalid JSON' }, 400)
257
+ }
258
+ if (!body.propertyId || !body.serviceAccountKey?.client_email || !body.serviceAccountKey?.private_key) {
259
+ return jsonResponse({ error: 'propertyId and serviceAccountKey (client_email, private_key) are required' }, 400)
260
+ }
261
+ const { importFromGa4Api } = await import('../lib/ga4-api')
262
+ const result = await importFromGa4Api(siteId, {
263
+ propertyId: body.propertyId,
264
+ serviceAccountKey: body.serviceAccountKey as { client_email: string, private_key: string },
265
+ startDate: body.startDate,
266
+ endDate: body.endDate,
267
+ })
268
+ return jsonResponse(result)
269
+ }
270
+ catch (error) {
271
+ // Surface Google-side failures (bad key, no property access) to the user —
272
+ // they're actionable, unlike a generic 500.
273
+ const message = (error as Error).message || 'Failed to import from GA4'
274
+ console.error('GA4 API import error:', message)
275
+ const status = /token exchange|runReport|propertyId/.test(message) ? 400 : 500
276
+ return jsonResponse({ error: message }, status)
277
+ }
278
+ }
279
+
280
+ /**
281
+ * GET /api/sites/{siteId}/gdpr/export
282
+ */
283
+ export async function handleGdprExport(request: Request, siteId: string): Promise<Response> {
284
+ try {
285
+ const query = getQueryParams(request)
286
+ const visitorId = query.visitorId
287
+
288
+ if (!visitorId) {
289
+ return jsonResponse({ error: 'Missing required parameter: visitorId' }, 400)
290
+ }
291
+
292
+ // Query the visitor's rows from the main table. The old pageviews query hit
293
+ // a visitor-keyed GSI ('gsi1' / VISITOR#) that doesn't exist — the live GSI
294
+ // is keyed SITE#…#DATE#… — so it always returned nothing (#140). Filter the
295
+ // site partition by visitorId instead, paginated so nothing truncates.
296
+ const byPrefix = (prefix: string) => queryAllItems({
297
+ TableName: TABLE_NAME,
298
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
299
+ FilterExpression: 'visitorId = :visitorId',
300
+ ExpressionAttributeValues: {
301
+ ':pk': { S: `SITE#${siteId}` },
302
+ ':prefix': { S: prefix },
303
+ ':visitorId': { S: visitorId },
304
+ },
305
+ })
306
+ // ALL visitorId-carrying record types (#178) — the old export omitted
307
+ // clicks, engagement, and web-vitals rows entirely.
308
+ const [pageviews, sessions, events, clicks, engagement, vitals] = await Promise.all([
309
+ byPrefix('PAGEVIEW#'),
310
+ byPrefix('SESSION#'),
311
+ byPrefix('EVENT#'),
312
+ byPrefix('CLICK#'),
313
+ byPrefix('ENGAGEMENT#'),
314
+ byPrefix('VITAL#'),
315
+ ])
316
+
317
+ return jsonResponse({
318
+ visitorId,
319
+ data: {
320
+ pageviews: (pageviews.Items || []).map(unmarshall),
321
+ sessions: (sessions.Items || []).map(unmarshall),
322
+ events: (events.Items || []).map(unmarshall),
323
+ clicks: (clicks.Items || []).map(unmarshall),
324
+ engagement: (engagement.Items || []).map(unmarshall),
325
+ vitals: (vitals.Items || []).map(unmarshall),
326
+ },
327
+ // Honest scope (#178): visitor ids rotate with the daily salt, so one id
328
+ // covers roughly ONE UTC day of activity. A full subject-access request
329
+ // needs the id from each day involved (there is no cross-day linkage —
330
+ // that's the privacy design, not a gap in the export).
331
+ note: 'Visitor ids rotate daily; this export covers the ~one UTC day this id maps to.',
332
+ exportedAt: new Date().toISOString(),
333
+ })
334
+ }
335
+ catch (error) {
336
+ console.error('GDPR export error:', error)
337
+ return errorResponse('Failed to export GDPR data')
338
+ }
339
+ }
340
+
341
+ /**
342
+ * POST /api/sites/{siteId}/gdpr/delete
343
+ */
344
+ export async function handleGdprDelete(request: Request, siteId: string): Promise<Response> {
345
+ try {
346
+ const body = await request.json() as Record<string, any>
347
+ const visitorId = body.visitorId
348
+
349
+ if (!visitorId) {
350
+ return jsonResponse({ error: 'Missing required field: visitorId' }, 400)
351
+ }
352
+
353
+ // Find every row in this site's partition belonging to the visitor, across
354
+ // ALL record types. The old code queried a visitor-keyed GSI that never
355
+ // existed, so it deleted nothing while reporting success (#140). Cookieless
356
+ // daily-salting means a visitorId maps to roughly one day's worth of data.
357
+ const result = await queryAllItems({
358
+ TableName: TABLE_NAME,
359
+ KeyConditionExpression: 'pk = :pk',
360
+ FilterExpression: 'visitorId = :visitorId',
361
+ ExpressionAttributeValues: {
362
+ ':pk': { S: `SITE#${siteId}` },
363
+ ':visitorId': { S: visitorId },
364
+ },
365
+ })
366
+
367
+ const items = result.Items.map(unmarshall)
368
+
369
+ // Delete all items
370
+ let deletedCount = 0
371
+ for (const item of items) {
372
+ try {
373
+ await dynamodb.deleteItem({
374
+ TableName: TABLE_NAME,
375
+ Key: marshall({
376
+ pk: item.pk,
377
+ sk: item.sk,
378
+ }),
379
+ })
380
+ deletedCount++
381
+ }
382
+ catch (e) {
383
+ console.error('Failed to delete item:', e)
384
+ }
385
+ }
386
+
387
+ return jsonResponse({
388
+ success: true,
389
+ visitorId,
390
+ deletedCount,
391
+ // Same daily-rotation scope as the export (#178): deleting this id
392
+ // removes ~one UTC day of rows; other days used different ids.
393
+ note: 'Visitor ids rotate daily; this delete covers the ~one UTC day this id maps to.',
394
+ deletedAt: new Date().toISOString(),
395
+ })
396
+ }
397
+ catch (error) {
398
+ console.error('GDPR delete error:', error)
399
+ return errorResponse('Failed to delete GDPR data')
400
+ }
401
+ }
402
+
403
+ /**
404
+ * GET /api/sites/{siteId}/insights — auto-detected, plain-language findings:
405
+ * period-over-period traffic movement (with a first-traffic milestone instead
406
+ * of silence for young sites), top mover pages, top referrer share, busiest
407
+ * day, bounce-rate and mobile-share signals. Emits the Insight contract the
408
+ * tab renders: type ∈ {traffic,referrer,page,device,engagement} (icon) and
409
+ * severity ∈ {success,warning,error,info} (color).
410
+ */
411
+ export async function handleGetInsights(request: Request, siteId: string): Promise<Response> {
412
+ try {
413
+ const query = getQueryParams(request)
414
+ const { startDate, endDate } = parseDateRange(query)
415
+
416
+ // Current and previous periods of equal length
417
+ const duration = endDate.getTime() - startDate.getTime()
418
+ const previousStartDate = new Date(startDate.getTime() - duration)
419
+ const previousEndDate = new Date(startDate.getTime() - 1)
420
+
421
+ // Sessions (paginated — the old raw query silently truncated at ~1MB) and
422
+ // pageviews for both periods in one range query each.
423
+ const [sessionsResult, pageviewsResult] = await Promise.all([
424
+ querySessionItemsInRange(siteId, previousStartDate, endDate) as Promise<{ Items?: any[] }>,
425
+ queryAllItems({
426
+ TableName: TABLE_NAME,
427
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
428
+ ExpressionAttributeValues: {
429
+ ':pk': { S: `SITE#${siteId}` },
430
+ ':start': { S: `PAGEVIEW#${previousStartDate.toISOString()}` },
431
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
432
+ },
433
+ }) as Promise<{ Items?: any[] }>,
434
+ ])
435
+
436
+ const allSessions = (sessionsResult.Items || []).map(unmarshall)
437
+ const currentSessions = allSessions.filter((s) => {
438
+ const t = new Date(s.startedAt)
439
+ return t >= startDate && t <= endDate
440
+ })
441
+ const previousSessions = allSessions.filter((s) => {
442
+ const t = new Date(s.startedAt)
443
+ return t >= previousStartDate && t <= previousEndDate
444
+ })
445
+
446
+ const pageviews = (pageviewsResult.Items || []).map(unmarshall)
447
+ const currentPvs = pageviews.filter(pv => new Date(pv.timestamp) >= startDate)
448
+ const previousPvs = pageviews.filter(pv => new Date(pv.timestamp) < startDate)
449
+
450
+ const insights: Array<{ type: string, severity: string, title: string, description: string, metric?: string, change?: number }> = []
451
+
452
+ // ── Traffic movement (or a first-traffic milestone for young sites — the
453
+ // old previousTraffic>0 guard made the tab permanently empty for exactly
454
+ // the sites that look at it most)
455
+ const currentTraffic = currentSessions.length
456
+ const previousTraffic = previousSessions.length
457
+ if (previousTraffic > 0) {
458
+ const change = ((currentTraffic - previousTraffic) / previousTraffic) * 100
459
+ if (Math.abs(change) > 10) {
460
+ insights.push({
461
+ type: 'traffic',
462
+ severity: change > 0 ? 'success' : 'warning',
463
+ title: change > 0 ? 'Traffic Increase' : 'Traffic Decrease',
464
+ description: `Sessions ${change > 0 ? 'increased' : 'decreased'} by ${Math.abs(Math.round(change))}% compared to the previous period (${currentTraffic} vs ${previousTraffic}).`,
465
+ metric: 'sessions',
466
+ change: Math.round(change),
467
+ })
468
+ }
469
+ }
470
+ else if (currentTraffic > 0) {
471
+ const visitors = new Set(currentSessions.map(s => s.visitorId)).size
472
+ insights.push({
473
+ type: 'traffic',
474
+ severity: 'success',
475
+ title: 'Your Baseline Starts Now',
476
+ description: `${visitors} visitor${visitors === 1 ? '' : 's'} across ${currentTraffic} session${currentTraffic === 1 ? '' : 's'} this period — with no traffic in the one before, this becomes the baseline future periods compare against.`,
477
+ metric: 'sessions',
478
+ })
479
+ }
480
+
481
+ // ── Top mover page (gainer vs previous period; falls back to the top page
482
+ // when there is no previous data)
483
+ const visitorsByPath = (pvs: any[]): Record<string, Set<string>> => {
484
+ const out: Record<string, Set<string>> = {}
485
+ for (const pv of pvs) {
486
+ const path = pv.path || '/'
487
+ if (!out[path]) out[path] = new Set()
488
+ out[path].add(pv.visitorId)
489
+ }
490
+ return out
491
+ }
492
+ const curPaths = visitorsByPath(currentPvs)
493
+ const prevPaths = visitorsByPath(previousPvs)
494
+ if (Object.keys(curPaths).length > 0) {
495
+ if (previousPvs.length > 0) {
496
+ let best: { path: string, delta: number, now: number } | null = null
497
+ for (const [path, set] of Object.entries(curPaths)) {
498
+ const delta = set.size - (prevPaths[path]?.size || 0)
499
+ if (!best || delta > best.delta) best = { path, delta, now: set.size }
500
+ }
501
+ if (best && best.delta >= 2) {
502
+ insights.push({
503
+ type: 'page',
504
+ severity: 'success',
505
+ title: 'Fastest-Growing Page',
506
+ description: `${best.path} gained ${best.delta} visitors vs the previous period (${best.now} this period).`,
507
+ metric: 'pageVisitors',
508
+ change: best.delta,
509
+ })
510
+ }
511
+ }
512
+ else {
513
+ const top = Object.entries(curPaths).sort((a, b) => b[1].size - a[1].size)[0]
514
+ insights.push({
515
+ type: 'page',
516
+ severity: 'info',
517
+ title: 'Top Page',
518
+ description: `${top[0]} is your most-visited page this period with ${top[1].size} visitor${top[1].size === 1 ? '' : 's'}.`,
519
+ metric: 'pageVisitors',
520
+ })
521
+ }
522
+ }
523
+
524
+ // ── Dominant referrer source
525
+ if (currentTraffic >= 5) {
526
+ const bySource: Record<string, number> = {}
527
+ for (const s of currentSessions) {
528
+ const src = s.referrerSource || 'Direct'
529
+ bySource[src] = (bySource[src] || 0) + 1
530
+ }
531
+ const top = Object.entries(bySource).sort((a, b) => b[1] - a[1])[0]
532
+ const share = Math.round((top[1] / currentTraffic) * 100)
533
+ if (share >= 50 && top[0] !== 'Direct') {
534
+ insights.push({
535
+ type: 'referrer',
536
+ severity: 'info',
537
+ title: 'Dominant Traffic Source',
538
+ description: `${share}% of your sessions come from ${top[0]} — a single source this concentrated is worth both nurturing and diversifying.`,
539
+ metric: 'referrerShare',
540
+ change: share,
541
+ })
542
+ }
543
+ else if (share >= 80 && top[0] === 'Direct') {
544
+ insights.push({
545
+ type: 'referrer',
546
+ severity: 'info',
547
+ title: 'Mostly Direct Traffic',
548
+ description: `${share}% of sessions arrive direct (no referrer). Campaign links with UTMs would reveal where these visitors actually come from.`,
549
+ metric: 'referrerShare',
550
+ change: share,
551
+ })
552
+ }
553
+ }
554
+
555
+ // ── Busiest day this period
556
+ if (currentTraffic >= 5) {
557
+ const byDay: Record<string, number> = {}
558
+ for (const s of currentSessions) {
559
+ const day = new Date(s.startedAt).toISOString().slice(0, 10)
560
+ byDay[day] = (byDay[day] || 0) + 1
561
+ }
562
+ const days = Object.entries(byDay)
563
+ if (days.length > 1) {
564
+ const [bestDay, bestCount] = days.sort((a, b) => b[1] - a[1])[0]
565
+ insights.push({
566
+ type: 'engagement',
567
+ severity: 'info',
568
+ title: 'Busiest Day',
569
+ description: `${new Date(`${bestDay}T00:00:00Z`).toUTCString().slice(0, 11)} was your busiest day this period with ${bestCount} sessions.`,
570
+ metric: 'sessions',
571
+ })
572
+ }
573
+ }
574
+
575
+ // ── Bounce rate (both directions)
576
+ const currentBounces = currentSessions.filter(s => s.isBounce).length
577
+ const currentBounceRate = currentTraffic > 0 ? (currentBounces / currentTraffic) * 100 : 0
578
+ if (currentBounceRate > 70 && currentTraffic >= 5) {
579
+ insights.push({
580
+ type: 'engagement',
581
+ severity: 'warning',
582
+ title: 'High Bounce Rate',
583
+ description: `Your bounce rate is ${Math.round(currentBounceRate)}%. Consider improving page load times or content relevance.`,
584
+ metric: 'bounceRate',
585
+ change: Math.round(currentBounceRate),
586
+ })
587
+ }
588
+ else if (currentBounceRate < 30 && currentTraffic >= 10) {
589
+ insights.push({
590
+ type: 'engagement',
591
+ severity: 'success',
592
+ title: 'Visitors Are Sticking Around',
593
+ description: `Only ${Math.round(currentBounceRate)}% of sessions bounce — most visitors view more than one page.`,
594
+ metric: 'bounceRate',
595
+ change: Math.round(currentBounceRate),
596
+ })
597
+ }
598
+
599
+ // ── Mobile share
600
+ const mobileSession = currentSessions.filter(s => s.deviceType === 'mobile').length
601
+ const mobilePercent = currentTraffic > 0 ? (mobileSession / currentTraffic) * 100 : 0
602
+ if (mobilePercent > 50) {
603
+ insights.push({
604
+ type: 'device',
605
+ severity: 'info',
606
+ title: 'Mobile-First Traffic',
607
+ description: `${Math.round(mobilePercent)}% of your traffic comes from mobile devices. Ensure your site is mobile-optimized.`,
608
+ metric: 'mobilePercent',
609
+ change: Math.round(mobilePercent),
610
+ })
611
+ }
612
+
613
+ return jsonResponse({
614
+ insights,
615
+ summary: {
616
+ currentPeriod: { start: startDate.toISOString(), end: endDate.toISOString(), sessions: currentTraffic },
617
+ previousPeriod: { start: previousStartDate.toISOString(), end: previousEndDate.toISOString(), sessions: previousTraffic },
618
+ },
619
+ })
620
+ }
621
+ catch (error) {
622
+ console.error('Get insights error:', error)
623
+ return errorResponse('Failed to fetch insights')
624
+ }
625
+ }