@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,328 @@
1
+ /**
2
+ * Alerts and Email Reports handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
7
+ import { jsonResponse, errorResponse } from '../utils/response'
8
+ import { getQueryParams } from '../../deploy/lambda-adapter'
9
+ import { sendEmail } from '../lib/email'
10
+
11
+ /**
12
+ * POST /api/sites/{siteId}/alerts
13
+ */
14
+ export async function handleCreateAlert(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const body = await request.json() as Record<string, any>
17
+
18
+ if (!body.name || !body.metric || !body.condition || body.threshold === undefined) {
19
+ return jsonResponse({ error: 'Missing required fields: name, metric, condition, threshold' }, 400)
20
+ }
21
+
22
+ const validConditions = ['above', 'below', 'equals', 'change_percent']
23
+ if (!validConditions.includes(body.condition)) {
24
+ return jsonResponse({ error: `Invalid condition. Must be one of: ${validConditions.join(', ')}` }, 400)
25
+ }
26
+
27
+ const alertId = generateId()
28
+ const alert = {
29
+ pk: `SITE#${siteId}`,
30
+ sk: `ALERT#${alertId}`,
31
+ id: alertId,
32
+ siteId,
33
+ name: body.name,
34
+ metric: body.metric,
35
+ condition: body.condition,
36
+ threshold: body.threshold,
37
+ email: body.email,
38
+ webhookUrl: body.webhookUrl,
39
+ isActive: body.isActive ?? true,
40
+ lastTriggered: null,
41
+ createdAt: new Date().toISOString(),
42
+ }
43
+
44
+ await dynamodb.putItem({
45
+ TableName: TABLE_NAME,
46
+ Item: marshall(alert),
47
+ })
48
+
49
+ return jsonResponse({ alert }, 201)
50
+ }
51
+ catch (error) {
52
+ console.error('Create alert error:', error)
53
+ return errorResponse('Failed to create alert')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * GET /api/sites/{siteId}/alerts
59
+ */
60
+ export async function handleGetAlerts(_request: Request, siteId: string): Promise<Response> {
61
+ try {
62
+ const result = await dynamodb.query({
63
+ TableName: TABLE_NAME,
64
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
65
+ ExpressionAttributeValues: {
66
+ ':pk': { S: `SITE#${siteId}` },
67
+ ':prefix': { S: 'ALERT#' },
68
+ },
69
+ }) as { Items?: any[] }
70
+
71
+ const alerts = (result.Items || []).map(unmarshall)
72
+
73
+ return jsonResponse({ alerts })
74
+ }
75
+ catch (error) {
76
+ console.error('Get alerts error:', error)
77
+ return errorResponse('Failed to fetch alerts')
78
+ }
79
+ }
80
+
81
+ /**
82
+ * DELETE /api/sites/{siteId}/alerts/{alertId}
83
+ */
84
+ export async function handleDeleteAlert(_request: Request, siteId: string, alertId: string): Promise<Response> {
85
+ try {
86
+ await dynamodb.deleteItem({
87
+ TableName: TABLE_NAME,
88
+ Key: marshall({
89
+ pk: `SITE#${siteId}`,
90
+ sk: `ALERT#${alertId}`,
91
+ }),
92
+ })
93
+
94
+ return jsonResponse({ success: true })
95
+ }
96
+ catch (error) {
97
+ console.error('Delete alert error:', error)
98
+ return errorResponse('Failed to delete alert')
99
+ }
100
+ }
101
+
102
+ /**
103
+ * POST /api/sites/{siteId}/email-reports
104
+ */
105
+ export async function handleCreateEmailReport(request: Request, siteId: string): Promise<Response> {
106
+ try {
107
+ const body = await request.json() as Record<string, any>
108
+
109
+ if (!body.email || !body.schedule) {
110
+ return jsonResponse({ error: 'Missing required fields: email, schedule' }, 400)
111
+ }
112
+
113
+ const validSchedules = ['daily', 'weekly', 'monthly']
114
+ if (!validSchedules.includes(body.schedule)) {
115
+ return jsonResponse({ error: `Invalid schedule. Must be one of: ${validSchedules.join(', ')}` }, 400)
116
+ }
117
+
118
+ const reportId = generateId()
119
+ const report = {
120
+ pk: `SITE#${siteId}`,
121
+ sk: `EMAIL_REPORT#${reportId}`,
122
+ id: reportId,
123
+ siteId,
124
+ email: body.email,
125
+ schedule: body.schedule,
126
+ metrics: body.metrics || ['visitors', 'pageviews', 'bounceRate', 'avgDuration'],
127
+ isActive: body.isActive ?? true,
128
+ lastSent: null,
129
+ createdAt: new Date().toISOString(),
130
+ }
131
+
132
+ await dynamodb.putItem({
133
+ TableName: TABLE_NAME,
134
+ Item: marshall(report),
135
+ })
136
+
137
+ return jsonResponse({ report }, 201)
138
+ }
139
+ catch (error) {
140
+ console.error('Create email report error:', error)
141
+ return errorResponse('Failed to create email report')
142
+ }
143
+ }
144
+
145
+ /**
146
+ * GET /api/sites/{siteId}/email-reports
147
+ */
148
+ export async function handleGetEmailReports(_request: Request, siteId: string): Promise<Response> {
149
+ try {
150
+ const result = await dynamodb.query({
151
+ TableName: TABLE_NAME,
152
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
153
+ ExpressionAttributeValues: {
154
+ ':pk': { S: `SITE#${siteId}` },
155
+ ':prefix': { S: 'EMAIL_REPORT#' },
156
+ },
157
+ }) as { Items?: any[] }
158
+
159
+ const reports = (result.Items || []).map(unmarshall)
160
+
161
+ return jsonResponse({ reports })
162
+ }
163
+ catch (error) {
164
+ console.error('Get email reports error:', error)
165
+ return errorResponse('Failed to fetch email reports')
166
+ }
167
+ }
168
+
169
+ /**
170
+ * DELETE /api/sites/{siteId}/email-reports/{reportId}
171
+ */
172
+ export async function handleDeleteEmailReport(_request: Request, siteId: string, reportId: string): Promise<Response> {
173
+ try {
174
+ await dynamodb.deleteItem({
175
+ TableName: TABLE_NAME,
176
+ Key: marshall({
177
+ pk: `SITE#${siteId}`,
178
+ sk: `EMAIL_REPORT#${reportId}`,
179
+ }),
180
+ })
181
+
182
+ return jsonResponse({ success: true })
183
+ }
184
+ catch (error) {
185
+ console.error('Delete email report error:', error)
186
+ return errorResponse('Failed to delete email report')
187
+ }
188
+ }
189
+
190
+ // ── Scheduled email digests (#93) ──────────────────────────────────────────
191
+ const DIGEST_PERIOD_MS: Record<string, number> = {
192
+ daily: 24 * 60 * 60 * 1000,
193
+ weekly: 7 * 24 * 60 * 60 * 1000,
194
+ monthly: 30 * 24 * 60 * 60 * 1000,
195
+ }
196
+
197
+ /** Pure: whether a digest on `schedule` is due given its last send. */
198
+ export function digestDue(schedule: string, lastSentMs: number | null, now: number): boolean {
199
+ const period = DIGEST_PERIOD_MS[schedule] ?? DIGEST_PERIOD_MS.weekly
200
+ return lastSentMs === null || now - lastSentMs >= period
201
+ }
202
+
203
+ /**
204
+ * Build the digest body from the report's CONFIGURED metrics (#139) — the
205
+ * old digest ignored them and sent a single pageview count regardless.
206
+ */
207
+ export async function buildDigestText(siteId: string, siteName: string, startIso: string, endIso: string, metrics: string[], label: string): Promise<string> {
208
+ const { queryAllItems, querySessionItemsInRange } = await import('../lib/dynamodb')
209
+ const [pvRes, sessRes] = await Promise.all([
210
+ queryAllItems({
211
+ TableName: TABLE_NAME,
212
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :s AND :e',
213
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':s': { S: `PAGEVIEW#${startIso}` }, ':e': { S: `PAGEVIEW#${endIso}~` } },
214
+ }),
215
+ querySessionItemsInRange(siteId, new Date(startIso), new Date(endIso)),
216
+ ])
217
+ const pageviews = (pvRes.Items || []).map(unmarshall)
218
+ const sessions = (sessRes.Items || []).map(unmarshall)
219
+
220
+ const wants = (m: string): boolean => metrics.length === 0 || metrics.includes(m)
221
+ const lines: string[] = [`Analytics summary for ${siteName} — last ${label}`, '']
222
+
223
+ // Daily-unique sums (the same semantics the dashboard reports).
224
+ const dailyVisitors = new Map<string, Set<string>>()
225
+ for (const pv of pageviews) {
226
+ const day = String(pv.timestamp).slice(0, 10)
227
+ let set = dailyVisitors.get(day)
228
+ if (!set) {
229
+ set = new Set()
230
+ dailyVisitors.set(day, set)
231
+ }
232
+ if (pv.visitorId)
233
+ set.add(pv.visitorId)
234
+ }
235
+ const visitors = [...dailyVisitors.values()].reduce((sum, set) => sum + set.size, 0)
236
+ if (wants('visitors'))
237
+ lines.push(`Visitors: ${visitors}`)
238
+ if (wants('pageviews'))
239
+ lines.push(`Pageviews: ${pageviews.length}`)
240
+ if (wants('sessions'))
241
+ lines.push(`Sessions: ${sessions.length}`)
242
+ if (wants('bounceRate')) {
243
+ const bounces = sessions.filter(x => x.isBounce).length
244
+ lines.push(`Bounce rate: ${sessions.length > 0 ? Math.round((bounces / sessions.length) * 100) : 0}%`)
245
+ }
246
+ if (wants('avgDuration')) {
247
+ const totalMs = sessions.reduce((sum, x) => sum + (x.activeTime > 0 ? x.activeTime : (x.duration || 0)), 0)
248
+ const avgS = sessions.length > 0 ? Math.round(totalMs / sessions.length / 1000) : 0
249
+ lines.push(`Avg time on site: ${Math.floor(avgS / 60)}:${String(avgS % 60).padStart(2, '0')}`)
250
+ }
251
+
252
+ if (wants('topPages') || metrics.length === 0) {
253
+ const byPath = new Map<string, number>()
254
+ for (const pv of pageviews) byPath.set(pv.path || '/', (byPath.get(pv.path || '/') || 0) + 1)
255
+ const top = [...byPath.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
256
+ if (top.length > 0) {
257
+ lines.push('', 'Top pages:')
258
+ for (const [path, count] of top) lines.push(` ${path} — ${count} views`)
259
+ }
260
+ }
261
+ if (wants('topReferrers') || metrics.length === 0) {
262
+ const bySource = new Map<string, number>()
263
+ for (const sess of sessions) {
264
+ const source = sess.referrerSource || 'Direct'
265
+ bySource.set(source, (bySource.get(source) || 0) + 1)
266
+ }
267
+ const top = [...bySource.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
268
+ if (top.length > 0) {
269
+ lines.push('', 'Top sources:')
270
+ for (const [source, count] of top) lines.push(` ${source} — ${count} sessions`)
271
+ }
272
+ }
273
+ return lines.join('\n')
274
+ }
275
+
276
+ /** Send every due email-report digest across all sites (scheduled job, #93/#90). */
277
+ export async function sendDueEmailDigests(): Promise<number> {
278
+ const sites = await dynamodb.query({
279
+ TableName: TABLE_NAME,
280
+ KeyConditionExpression: 'pk = :pk',
281
+ ExpressionAttributeValues: { ':pk': { S: 'SITES' } },
282
+ }) as { Items?: any[] }
283
+
284
+ const now = Date.now()
285
+ const nowIso = new Date(now).toISOString()
286
+ let sent = 0
287
+
288
+ for (const raw of (sites.Items || [])) {
289
+ const site = unmarshall(raw)
290
+ const siteId = site.siteId || site.id
291
+ if (!siteId)
292
+ continue
293
+ const reports = await dynamodb.query({
294
+ TableName: TABLE_NAME,
295
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :p)',
296
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':p': { S: 'EMAIL_REPORT#' } },
297
+ }) as { Items?: any[] }
298
+
299
+ for (const rraw of (reports.Items || [])) {
300
+ const report = unmarshall(rraw)
301
+ if (report.isActive === false)
302
+ continue
303
+ const lastSentMs = report.lastSent ? new Date(report.lastSent).getTime() : null
304
+ if (!digestDue(report.schedule, lastSentMs, now))
305
+ continue
306
+
307
+ const period = DIGEST_PERIOD_MS[report.schedule] ?? DIGEST_PERIOD_MS.weekly
308
+ const sinceIso = new Date(now - period).toISOString()
309
+ const label = report.schedule === 'daily' ? 'day' : report.schedule === 'monthly' ? 'month' : 'week'
310
+ const metrics = Array.isArray(report.metrics) ? report.metrics : []
311
+ const text = await buildDigestText(siteId, site.name || siteId, sinceIso, nowIso, metrics, label)
312
+
313
+ await sendEmail({
314
+ to: report.email,
315
+ subject: `Your ${report.schedule} analytics report — ${site.name || siteId}`,
316
+ text,
317
+ })
318
+ await dynamodb.updateItem({
319
+ TableName: TABLE_NAME,
320
+ Key: { pk: { S: `SITE#${siteId}` }, sk: { S: report.sk } },
321
+ UpdateExpression: 'SET lastSent = :now',
322
+ ExpressionAttributeValues: { ':now': { S: nowIso } },
323
+ })
324
+ sent++
325
+ }
326
+ }
327
+ return sent
328
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Annotation handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
7
+ import { parseDateRange } from '../utils/date'
8
+ import { jsonResponse, errorResponse } from '../utils/response'
9
+ import { getQueryParams } from '../../deploy/lambda-adapter'
10
+
11
+ /**
12
+ * POST /api/sites/{siteId}/annotations
13
+ */
14
+ export async function handleCreateAnnotation(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const body = await request.json() as Record<string, any>
17
+
18
+ if (!body.text) {
19
+ return jsonResponse({ error: 'Missing required field: text' }, 400)
20
+ }
21
+
22
+ const annotationId = generateId()
23
+ const timestamp = body.timestamp || new Date().toISOString()
24
+ const annotation = {
25
+ pk: `SITE#${siteId}`,
26
+ sk: `ANNOTATION#${timestamp}#${annotationId}`,
27
+ id: annotationId,
28
+ siteId,
29
+ text: body.text,
30
+ type: body.type || 'general',
31
+ timestamp,
32
+ createdAt: new Date().toISOString(),
33
+ createdBy: body.createdBy || 'system',
34
+ }
35
+
36
+ await dynamodb.putItem({
37
+ TableName: TABLE_NAME,
38
+ Item: marshall(annotation),
39
+ })
40
+
41
+ return jsonResponse({ annotation }, 201)
42
+ }
43
+ catch (error) {
44
+ console.error('Create annotation error:', error)
45
+ return errorResponse('Failed to create annotation')
46
+ }
47
+ }
48
+
49
+ /**
50
+ * GET /api/sites/{siteId}/annotations
51
+ */
52
+ export async function handleGetAnnotations(request: Request, siteId: string): Promise<Response> {
53
+ try {
54
+ const query = getQueryParams(request)
55
+ const { startDate, endDate } = parseDateRange(query)
56
+
57
+ const result = await dynamodb.query({
58
+ TableName: TABLE_NAME,
59
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
60
+ ExpressionAttributeValues: {
61
+ ':pk': { S: `SITE#${siteId}` },
62
+ ':start': { S: `ANNOTATION#${startDate.toISOString()}` },
63
+ ':end': { S: `ANNOTATION#${endDate.toISOString()}Z` },
64
+ },
65
+ }) as { Items?: any[] }
66
+
67
+ const annotations = (result.Items || []).map(unmarshall)
68
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
69
+
70
+ return jsonResponse({ annotations })
71
+ }
72
+ catch (error) {
73
+ console.error('Get annotations error:', error)
74
+ return errorResponse('Failed to fetch annotations')
75
+ }
76
+ }
77
+
78
+ /**
79
+ * DELETE /api/sites/{siteId}/annotations/{annotationId}
80
+ */
81
+ export async function handleDeleteAnnotation(request: Request, siteId: string, annotationId: string): Promise<Response> {
82
+ try {
83
+ const query = getQueryParams(request)
84
+ const timestamp = query.timestamp
85
+
86
+ if (!timestamp) {
87
+ // Need to find the annotation first
88
+ const result = await dynamodb.query({
89
+ TableName: TABLE_NAME,
90
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
91
+ ExpressionAttributeValues: {
92
+ ':pk': { S: `SITE#${siteId}` },
93
+ ':prefix': { S: 'ANNOTATION#' },
94
+ },
95
+ }) as { Items?: any[] }
96
+
97
+ const annotations = (result.Items || []).map(unmarshall)
98
+ const annotation = annotations.find(a => a.id === annotationId)
99
+
100
+ if (!annotation) {
101
+ return jsonResponse({ error: 'Annotation not found' }, 404)
102
+ }
103
+
104
+ await dynamodb.deleteItem({
105
+ TableName: TABLE_NAME,
106
+ Key: marshall({
107
+ pk: `SITE#${siteId}`,
108
+ sk: annotation.sk,
109
+ }),
110
+ })
111
+ }
112
+ else {
113
+ await dynamodb.deleteItem({
114
+ TableName: TABLE_NAME,
115
+ Key: marshall({
116
+ pk: `SITE#${siteId}`,
117
+ sk: `ANNOTATION#${timestamp}#${annotationId}`,
118
+ }),
119
+ })
120
+ }
121
+
122
+ return jsonResponse({ success: true })
123
+ }
124
+ catch (error) {
125
+ console.error('Delete annotation error:', error)
126
+ return errorResponse('Failed to delete annotation')
127
+ }
128
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * API Key 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 { rateLimitAllow } from '../lib/rate-limit'
9
+ import { jsonResponse, errorResponse } from '../utils/response'
10
+ import { getClientIP, getQueryParams } from '../../deploy/lambda-adapter'
11
+
12
+ /** Max key-validation cache MISSES per minute per IP — throttles brute-forcing
13
+ * of unknown keys without affecting legit clients (their valid key is cached). */
14
+ const KEY_VALIDATION_LIMIT_PER_MIN = 60
15
+
16
+ /**
17
+ * Generate a secure API key
18
+ */
19
+ export function generateApiKey(): string {
20
+ // CSPRNG so keys aren't guessable/enumerable (#130).
21
+ return `ak_${randomToken(32)}`
22
+ }
23
+
24
+ /**
25
+ * Token validation result
26
+ */
27
+ export interface ApiKeyValidationResult {
28
+ valid: boolean
29
+ siteId?: string
30
+ keyId?: string
31
+ }
32
+
33
+ // In-memory cache for validated tokens (5-minute TTL)
34
+ const tokenCache = new Map<string, { result: ApiKeyValidationResult; expires: number }>()
35
+
36
+ /**
37
+ * Validate an API key from request headers.
38
+ * Checks X-Analytics-Token or Authorization: Bearer header.
39
+ * Requires the key to be active and have the specified permission.
40
+ */
41
+ export async function handleValidateApiKey(
42
+ request: Request,
43
+ requiredPermission: string = 'read',
44
+ ): Promise<ApiKeyValidationResult> {
45
+ const token = request.headers.get('X-Analytics-Token')
46
+ || request.headers.get('Authorization')?.replace('Bearer ', '')
47
+
48
+ if (!token || !token.startsWith('ak_')) {
49
+ return { valid: false }
50
+ }
51
+
52
+ // Check cache
53
+ const cached = tokenCache.get(`${token}:${requiredPermission}`)
54
+ if (cached && cached.expires > Date.now()) {
55
+ return cached.result
56
+ }
57
+
58
+ // Only cache MISSES reach here. Brute-force tries many distinct keys (all
59
+ // misses), so rate-limiting misses per IP throttles enumeration while legit
60
+ // clients (reusing one valid, cached key) are unaffected (#158).
61
+ if (!rateLimitAllow(`keyauth:${getClientIP(request)}`, KEY_VALIDATION_LIMIT_PER_MIN, 60_000)) {
62
+ return { valid: false }
63
+ }
64
+
65
+ try {
66
+ // Query GSI1 to look up key by value
67
+ const queryResult = await dynamodb.query({
68
+ TableName: TABLE_NAME,
69
+ IndexName: 'GSI1',
70
+ KeyConditionExpression: 'gsi1pk = :pk',
71
+ ExpressionAttributeValues: {
72
+ ':pk': { S: `API_KEY#${token}` },
73
+ },
74
+ }) as { Items?: any[] }
75
+
76
+ if (!queryResult.Items || queryResult.Items.length === 0) {
77
+ const invalid = { valid: false } as const
78
+ tokenCache.set(`${token}:${requiredPermission}`, { result: invalid, expires: Date.now() + 60_000 })
79
+ return invalid
80
+ }
81
+
82
+ const keyRecord = unmarshall(queryResult.Items[0])
83
+
84
+ if (!keyRecord.isActive) {
85
+ return { valid: false }
86
+ }
87
+
88
+ const permissions: string[] = keyRecord.permissions || []
89
+ if (!permissions.includes(requiredPermission)) {
90
+ return { valid: false }
91
+ }
92
+
93
+ const result: ApiKeyValidationResult = {
94
+ valid: true,
95
+ siteId: keyRecord.siteId,
96
+ keyId: keyRecord.id,
97
+ }
98
+
99
+ // Cache for 5 minutes
100
+ tokenCache.set(`${token}:${requiredPermission}`, { result, expires: Date.now() + 5 * 60 * 1000 })
101
+
102
+ // Fire-and-forget: update lastUsed and usageCount
103
+ dynamodb.updateItem({
104
+ TableName: TABLE_NAME,
105
+ Key: {
106
+ pk: { S: `SITE#${keyRecord.siteId}` },
107
+ sk: { S: `API_KEY#${keyRecord.id}` },
108
+ },
109
+ UpdateExpression: 'SET lastUsed = :now, usageCount = if_not_exists(usageCount, :zero) + :one',
110
+ ExpressionAttributeValues: {
111
+ ':now': { S: new Date().toISOString() },
112
+ ':zero': { N: '0' },
113
+ ':one': { N: '1' },
114
+ },
115
+ }).catch((e: unknown) => console.error('Failed to update API key usage:', e))
116
+
117
+ return result
118
+ }
119
+ catch (error) {
120
+ console.error('Validate API key error:', error)
121
+ return { valid: false }
122
+ }
123
+ }
124
+
125
+
126
+
127
+
128
+
129
+ /**
130
+ * GET /api/sites/{siteId}/api-keys
131
+ * Returns the single API key for the site
132
+ */
133
+ export async function handleGetApiKey(_request: Request, siteId: string): Promise<Response> {
134
+ try {
135
+ const result = await dynamodb.query({
136
+ TableName: TABLE_NAME,
137
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
138
+ ExpressionAttributeValues: {
139
+ ':pk': { S: `SITE#${siteId}` },
140
+ ':prefix': { S: 'API_KEY#' },
141
+ },
142
+ }) as { Items?: any[] }
143
+
144
+ const keys = (result.Items || []).map(unmarshall)
145
+ const active = keys.find(k => k.isActive) || keys[0]
146
+
147
+ if (!active) {
148
+ return jsonResponse({ apiKey: null })
149
+ }
150
+
151
+ return jsonResponse({
152
+ apiKey: {
153
+ id: active.id,
154
+ key: active.key,
155
+ name: active.name,
156
+ permissions: active.permissions,
157
+ createdAt: active.createdAt,
158
+ lastUsed: active.lastUsed,
159
+ usageCount: active.usageCount,
160
+ },
161
+ })
162
+ }
163
+ catch (error) {
164
+ console.error('Get API key error:', error)
165
+ return errorResponse('Failed to fetch API key')
166
+ }
167
+ }
168
+
169
+ /**
170
+ * POST /api/sites/{siteId}/api-keys/regenerate
171
+ * Deletes all existing keys and creates a fresh one
172
+ */
173
+ export async function handleRegenerateApiKey(_request: Request, siteId: string): Promise<Response> {
174
+ try {
175
+ // 1. Query existing keys
176
+ const result = await dynamodb.query({
177
+ TableName: TABLE_NAME,
178
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
179
+ ExpressionAttributeValues: {
180
+ ':pk': { S: `SITE#${siteId}` },
181
+ ':prefix': { S: 'API_KEY#' },
182
+ },
183
+ }) as { Items?: any[] }
184
+
185
+ // 2. Delete each existing key
186
+ for (const item of result.Items || []) {
187
+ const record = unmarshall(item)
188
+ // Invalidate cache entries for old key
189
+ tokenCache.delete(`${record.key}:read`)
190
+ tokenCache.delete(`${record.key}:error-tracking`)
191
+ await dynamodb.deleteItem({
192
+ TableName: TABLE_NAME,
193
+ Key: marshall({
194
+ pk: `SITE#${siteId}`,
195
+ sk: `API_KEY#${record.id}`,
196
+ }),
197
+ })
198
+ }
199
+
200
+ // 3. Create new key
201
+ const keyId = generateId()
202
+ const apiKey = generateApiKey()
203
+ const permissions = ['read']
204
+
205
+ await dynamodb.putItem({
206
+ TableName: TABLE_NAME,
207
+ Item: marshall({
208
+ pk: `SITE#${siteId}`,
209
+ sk: `API_KEY#${keyId}`,
210
+ gsi1pk: `API_KEY#${apiKey}`,
211
+ gsi1sk: `SITE#${siteId}`,
212
+ id: keyId,
213
+ siteId,
214
+ name: 'Default',
215
+ key: apiKey,
216
+ keyPrefix: apiKey.slice(0, 8),
217
+ permissions,
218
+ lastUsed: null,
219
+ usageCount: 0,
220
+ isActive: true,
221
+ createdAt: new Date().toISOString(),
222
+ }),
223
+ })
224
+
225
+ // 4. Return the new key
226
+ return jsonResponse({
227
+ apiKey: {
228
+ id: keyId,
229
+ key: apiKey,
230
+ name: 'Default',
231
+ permissions,
232
+ createdAt: new Date().toISOString(),
233
+ },
234
+ })
235
+ }
236
+ catch (error) {
237
+ console.error('Regenerate API key error:', error)
238
+ return errorResponse('Failed to regenerate API key')
239
+ }
240
+ }