@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,486 @@
1
+ /**
2
+ * Miscellaneous handlers (health, sites list, revenue, site management)
3
+ */
4
+
5
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } 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 { generateApiKey } from './api-keys'
10
+ import { addMembership, getUserMemberships, getMembership, getSiteMembers, removeMembership } from '../lib/membership'
11
+ import { getUserPlan, planLimits } from '../lib/plans'
12
+ import { generateId } from '../index'
13
+ import { generateAppId } from '../lib/crypto-random'
14
+
15
+ /**
16
+ * GET /health — probes DynamoDB with a cheap getItem (#175). A static "ok"
17
+ * kept App Runner routing traffic to instances whose table access was broken.
18
+ */
19
+ export async function handleHealth(_request: Request): Promise<Response> {
20
+ const started = Date.now()
21
+ try {
22
+ await dynamodb.getItem({
23
+ TableName: TABLE_NAME,
24
+ Key: { pk: { S: 'SITES' }, sk: { S: 'SITE#__health__' } },
25
+ })
26
+ return jsonResponse({ status: 'ok', db: 'ok', dbLatencyMs: Date.now() - started, timestamp: new Date().toISOString() })
27
+ }
28
+ catch (e) {
29
+ return jsonResponse({ status: 'degraded', db: 'error', error: (e as Error).message, timestamp: new Date().toISOString() }, 503)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * GET /api/sites/{siteId}/ingest-counters — hourly beacon outcomes (#175):
35
+ * collected vs dropped (bot/firewall/dedup/invalid/rate_limited/excluded/
36
+ * quota) plus tracker-version counts (v_*, #179). The ops view that makes a
37
+ * silent ingest failure (CORS, firewall misconfig, broken tracker rollout)
38
+ * visible as a drop in `collected` with the reason right next to it.
39
+ */
40
+ export async function handleGetIngestCounters(request: Request, siteId: string): Promise<Response> {
41
+ try {
42
+ const query = getQueryParams(request)
43
+ const hours = Math.min(Math.max(Number(query.hours) || 48, 1), 24 * 14)
44
+ const { readIngestCounters, flushIngestCounters } = await import('../lib/ingest-counters')
45
+ await flushIngestCounters() // include this instance's buffered tail
46
+ const counters = await readIngestCounters(siteId, hours)
47
+ return jsonResponse({ counters, hours })
48
+ }
49
+ catch (error) {
50
+ console.error('Ingest counters error:', error)
51
+ return errorResponse('Failed to fetch ingest counters')
52
+ }
53
+ }
54
+
55
+ /**
56
+ * POST /api/sites - Create a new site
57
+ */
58
+ export async function handleCreateSite(request: Request, ownerId?: string): Promise<Response> {
59
+ try {
60
+ const body = await request.json() as { name?: string; domain?: string; domains?: string[] }
61
+
62
+ if (!body.name) {
63
+ return jsonResponse({ error: 'Site name is required' }, 400)
64
+ }
65
+
66
+ const domains = body.domains || (body.domain ? [body.domain] : [])
67
+ const now = new Date().toISOString()
68
+
69
+ // Plan limit (#62): cap owned projects per account.
70
+ if (ownerId) {
71
+ const limits = planLimits(await getUserPlan(ownerId))
72
+ if (limits.maxProjects > 0) {
73
+ const owned = (await getUserMemberships(ownerId)).filter(m => m.role === 'owner').length
74
+ if (owned >= limits.maxProjects) {
75
+ return jsonResponse({ error: `Project limit reached (${limits.maxProjects} on your plan)` }, 403)
76
+ }
77
+ }
78
+ }
79
+
80
+ // Adopt an ownerless site the tracker auto-created under a readable id
81
+ // (legacy #114 flow): the tracking snippet auto-creates ownerless SITES rows
82
+ // via /collect, so a user creating that project by the same name should adopt
83
+ // it (attach ownerId + membership) rather than be blocked out of their data.
84
+ // Match on the name's slug; brand-new sites get a random App ID below.
85
+ const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
86
+ if (slug) {
87
+ const existing = await dynamodb.getItem({
88
+ TableName: TABLE_NAME,
89
+ Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${slug}` } },
90
+ })
91
+ if (existing.Item) {
92
+ const existingSite = unmarshall(existing.Item)
93
+ if (ownerId && !existingSite.ownerId) {
94
+ await dynamodb.updateItem({
95
+ TableName: TABLE_NAME,
96
+ Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${slug}` } },
97
+ UpdateExpression: 'SET ownerId = :o, gsi1pk = :gp, gsi1sk = :gs, updatedAt = :now',
98
+ ExpressionAttributeValues: {
99
+ ':o': { S: ownerId },
100
+ ':gp': { S: `OWNER#${ownerId}` },
101
+ ':gs': { S: `SITE#${slug}` },
102
+ ':now': { S: now },
103
+ },
104
+ })
105
+ await addMembership(ownerId, slug, 'owner')
106
+ return jsonResponse({ site: { ...existingSite, id: slug, ownerId }, claimed: true }, 200)
107
+ }
108
+ // Already owned → fall through and create a fresh site with a random id
109
+ // (names aren't unique now that ids are random, Fathom-style).
110
+ }
111
+ }
112
+
113
+ // Brand-new site: a short, random, unguessable App ID (Fathom-style, ~8 chars).
114
+ const siteId = generateAppId()
115
+
116
+ // Create the site
117
+ const siteItem: Record<string, unknown> = {
118
+ pk: 'SITES',
119
+ sk: `SITE#${siteId}`,
120
+ id: siteId,
121
+ siteId,
122
+ name: body.name,
123
+ domains,
124
+ createdAt: now,
125
+ updatedAt: now,
126
+ }
127
+
128
+ if (ownerId) {
129
+ siteItem.ownerId = ownerId
130
+ siteItem.gsi1pk = `OWNER#${ownerId}`
131
+ siteItem.gsi1sk = `SITE#${siteId}`
132
+ }
133
+
134
+ await dynamodb.putItem({
135
+ TableName: TABLE_NAME,
136
+ Item: marshall(siteItem),
137
+ })
138
+
139
+ // Record the creator as the project owner (membership layer).
140
+ if (ownerId) await addMembership(ownerId, siteId, 'owner')
141
+
142
+ // Auto-generate the first API key
143
+ const keyId = generateId()
144
+ const apiKey = generateApiKey()
145
+ const permissions = ['read']
146
+
147
+ await dynamodb.putItem({
148
+ TableName: TABLE_NAME,
149
+ Item: marshall({
150
+ pk: `SITE#${siteId}`,
151
+ sk: `API_KEY#${keyId}`,
152
+ gsi1pk: `API_KEY#${apiKey}`,
153
+ gsi1sk: `SITE#${siteId}`,
154
+ id: keyId,
155
+ siteId,
156
+ name: 'Default',
157
+ key: apiKey,
158
+ keyPrefix: apiKey.slice(0, 8),
159
+ permissions,
160
+ lastUsed: null,
161
+ usageCount: 0,
162
+ isActive: true,
163
+ createdAt: now,
164
+ }),
165
+ })
166
+
167
+ return jsonResponse({
168
+ success: true,
169
+ site: {
170
+ id: siteId,
171
+ name: body.name,
172
+ domains,
173
+ createdAt: now,
174
+ },
175
+ apiKey: {
176
+ id: keyId,
177
+ key: apiKey,
178
+ name: 'Default',
179
+ permissions,
180
+ },
181
+ }, 201)
182
+ }
183
+ catch (error) {
184
+ console.error('Create site error:', error)
185
+ return errorResponse('Failed to create site')
186
+ }
187
+ }
188
+
189
+ /**
190
+ * DELETE /api/sites/{siteId} — owner-only. Removes the project, its memberships,
191
+ * and everything stored under its SITE# partition (API keys, errors, stats,
192
+ * pageviews, sessions, …). Large projects are purged via pagination.
193
+ */
194
+ export async function handleDeleteSite(_request: Request, siteId: string, userId?: string): Promise<Response> {
195
+ try {
196
+ if (!userId) return jsonResponse({ error: 'Authentication required' }, 401)
197
+ const role = await getMembership(userId, siteId)
198
+ if (role !== 'owner') return jsonResponse({ error: 'Only the project owner can delete it' }, 403)
199
+
200
+ // The site metadata record (lives in the shared SITES partition).
201
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${siteId}` } } })
202
+
203
+ // Memberships (both mirrored records per member).
204
+ for (const m of await getSiteMembers(siteId)) {
205
+ await removeMembership(m.userId, siteId)
206
+ }
207
+
208
+ // Everything under the project's own partition: API keys, error groups +
209
+ // occurrences, statuses, alerts, pageviews, sessions, etc.
210
+ let lastKey: Record<string, unknown> | undefined
211
+ do {
212
+ const res = await dynamodb.query({
213
+ TableName: TABLE_NAME,
214
+ KeyConditionExpression: 'pk = :pk',
215
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` } },
216
+ ExclusiveStartKey: lastKey,
217
+ Limit: 200,
218
+ }) as { Items?: any[]; LastEvaluatedKey?: Record<string, unknown> }
219
+ for (const raw of (res.Items || [])) {
220
+ const it = unmarshall(raw)
221
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: it.pk }, sk: { S: it.sk } } })
222
+ }
223
+ lastKey = res.LastEvaluatedKey
224
+ } while (lastKey)
225
+
226
+ return jsonResponse({ success: true, siteId })
227
+ }
228
+ catch (error) {
229
+ console.error('Delete site error:', error)
230
+ return errorResponse('Failed to delete site')
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Ensure a site exists (auto-create if not) - used by collect handler
236
+ */
237
+ export async function ensureSiteExists(siteId: string, hostname?: string, ownerId?: string): Promise<Record<string, any> | null> {
238
+ try {
239
+ // Check if site exists
240
+ const existing = await dynamodb.getItem({
241
+ TableName: TABLE_NAME,
242
+ Key: {
243
+ pk: { S: 'SITES' },
244
+ sk: { S: `SITE#${siteId}` },
245
+ },
246
+ })
247
+
248
+ if (existing.Item) {
249
+ return unmarshall(existing.Item)
250
+ }
251
+
252
+ if (!existing.Item) {
253
+ // Ingress firewall (#170): site ids are public in the tag, so open
254
+ // auto-provisioning lets anyone spray junk site ids as a write-cost
255
+ // attack. Auto-create stays on for dev convenience but is OFF in
256
+ // production unless explicitly enabled.
257
+ const autoCreate = process.env.ANALYTICS_AUTO_CREATE_SITES
258
+ ? process.env.ANALYTICS_AUTO_CREATE_SITES === 'true'
259
+ : process.env.NODE_ENV !== 'production'
260
+ if (!autoCreate)
261
+ return null
262
+
263
+ const now = new Date().toISOString()
264
+ const domains = hostname ? [hostname] : []
265
+
266
+ const siteItem: Record<string, unknown> = {
267
+ pk: 'SITES',
268
+ sk: `SITE#${siteId}`,
269
+ id: siteId,
270
+ siteId,
271
+ name: siteId,
272
+ domains,
273
+ createdAt: now,
274
+ updatedAt: now,
275
+ autoCreated: true,
276
+ }
277
+
278
+ if (ownerId) {
279
+ siteItem.ownerId = ownerId
280
+ siteItem.gsi1pk = `OWNER#${ownerId}`
281
+ siteItem.gsi1sk = `SITE#${siteId}`
282
+ }
283
+
284
+ // Auto-create the site
285
+ await dynamodb.putItem({
286
+ TableName: TABLE_NAME,
287
+ Item: marshall(siteItem),
288
+ })
289
+ console.log(`[ensureSiteExists] Auto-created site: ${siteId}`)
290
+ return siteItem
291
+ }
292
+ return null
293
+ }
294
+ catch (error) {
295
+ // Log but don't fail - site creation is best-effort
296
+ console.error('[ensureSiteExists] Error:', error)
297
+ return null
298
+ }
299
+ }
300
+
301
+ /**
302
+ * GET /api/sites
303
+ */
304
+ export async function handleGetSites(_request: Request, ownerId?: string): Promise<Response> {
305
+ try {
306
+ // #131: with auth enforcement off AND no session, the fallback below
307
+ // lists EVERY tenant's sites. That's the intended kiosk/dev behavior,
308
+ // but in production it turns one misconfiguration into total exposure —
309
+ // hard-gate it there (mirrors the #170 auto-provisioning gate).
310
+ if (!ownerId && process.env.NODE_ENV === 'production' && process.env.ANALYTICS_OPEN_SITES_LIST !== 'true') {
311
+ return jsonResponse({ sites: [] })
312
+ }
313
+ // Dedupe by site sort-key across owned + member-shared projects.
314
+ const bySk = new Map<string, any>()
315
+
316
+ if (ownerId) {
317
+ // Sites this user owns (OWNER# GSI)...
318
+ const owned = await dynamodb.query({
319
+ TableName: TABLE_NAME,
320
+ IndexName: 'GSI1',
321
+ KeyConditionExpression: 'gsi1pk = :pk',
322
+ ExpressionAttributeValues: { ':pk': { S: `OWNER#${ownerId}` } },
323
+ }) as { Items?: any[] }
324
+ for (const raw of (owned.Items || [])) {
325
+ const s = unmarshall(raw)
326
+ bySk.set(s.sk || `SITE#${s.siteId}`, s)
327
+ }
328
+
329
+ // ...plus projects shared with them via membership (teams).
330
+ const memberships = await getUserMemberships(ownerId)
331
+ for (const m of memberships) {
332
+ const sk = `SITE#${m.siteId}`
333
+ if (bySk.has(sk)) {
334
+ bySk.get(sk).role = m.role
335
+ continue
336
+ }
337
+ const got = await dynamodb.getItem({ TableName: TABLE_NAME, Key: { pk: { S: 'SITES' }, sk: { S: sk } } })
338
+ if (got.Item) {
339
+ const s = unmarshall(got.Item)
340
+ s.role = m.role
341
+ bySk.set(sk, s)
342
+ }
343
+ }
344
+ }
345
+ else {
346
+ // No session → all sites (legacy/unauthenticated access; gated by #54).
347
+ const all = await dynamodb.query({
348
+ TableName: TABLE_NAME,
349
+ KeyConditionExpression: 'pk = :pk',
350
+ ExpressionAttributeValues: { ':pk': { S: 'SITES' } },
351
+ }) as { Items?: any[] }
352
+ for (const raw of (all.Items || [])) {
353
+ const s = unmarshall(raw)
354
+ bySk.set(s.sk || `SITE#${s.siteId}`, s)
355
+ }
356
+ }
357
+
358
+ const sites = [...bySk.values()].map((s: any) => ({
359
+ id: s.id || s.siteId,
360
+ name: s.name,
361
+ domains: s.domains || [],
362
+ role: s.role || (ownerId && s.ownerId === ownerId ? 'owner' : undefined),
363
+ createdAt: s.createdAt,
364
+ }))
365
+
366
+ sites.sort((a: any, b: any) => a.name.localeCompare(b.name))
367
+
368
+ return jsonResponse({
369
+ sites,
370
+ total: sites.length,
371
+ })
372
+ }
373
+ catch (error) {
374
+ console.error('Get sites error:', error)
375
+ return errorResponse('Failed to fetch sites')
376
+ }
377
+ }
378
+
379
+ /**
380
+ * GET /api/sites/{siteId}/verify-install (#84)
381
+ *
382
+ * Installation check: did any pageview land for this site in the last few
383
+ * minutes? The Settings "Verify installation" button fires a synthetic
384
+ * pageview through the public /collect endpoint, then calls this to confirm
385
+ * the full ingest round-trip (HTTP → handler → DynamoDB → read-back).
386
+ */
387
+ export async function handleVerifyInstall(_request: Request, siteId: string): Promise<Response> {
388
+ try {
389
+ const now = new Date()
390
+ const windowStart = new Date(now.getTime() - 5 * 60_000)
391
+
392
+ const result = await dynamodb.query({
393
+ TableName: TABLE_NAME,
394
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
395
+ ExpressionAttributeValues: {
396
+ ':pk': { S: `SITE#${siteId}` },
397
+ ':start': { S: `PAGEVIEW#${windowStart.toISOString()}` },
398
+ ':end': { S: `PAGEVIEW#${now.toISOString()}~` },
399
+ },
400
+ Select: 'COUNT',
401
+ }) as { Count?: number }
402
+
403
+ const recentEvents = result.Count || 0
404
+ return jsonResponse({
405
+ verified: recentEvents > 0,
406
+ recentEvents,
407
+ windowMinutes: 5,
408
+ checkedAt: now.toISOString(),
409
+ })
410
+ }
411
+ catch (error) {
412
+ console.error('Verify install error:', error)
413
+ return errorResponse('Failed to verify installation')
414
+ }
415
+ }
416
+
417
+ /**
418
+ * GET /api/sites/{siteId}/revenue
419
+ */
420
+ export async function handleGetRevenue(request: Request, siteId: string): Promise<Response> {
421
+ try {
422
+ const query = getQueryParams(request)
423
+ const { startDate, endDate } = parseDateRange(query)
424
+
425
+ // Query events with revenue data
426
+ const result = await dynamodb.query({
427
+ TableName: TABLE_NAME,
428
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
429
+ ExpressionAttributeValues: {
430
+ ':pk': { S: `SITE#${siteId}` },
431
+ ':start': { S: `EVENT#${startDate.toISOString()}` },
432
+ ':end': { S: `EVENT#${endDate.toISOString()}` },
433
+ },
434
+ }) as { Items?: any[] }
435
+
436
+ // Stored custom-event fields are `value` and `name` (not revenue/eventName),
437
+ // so the old filter matched nothing and revenue was always 0 (#141).
438
+ const events = (result.Items || []).map(unmarshall)
439
+ .filter(e => typeof e.value === 'number' || e.name === 'purchase' || e.name === 'conversion')
440
+
441
+ // Calculate revenue metrics
442
+ let totalRevenue = 0
443
+ let transactionCount = 0
444
+ const revenueByDay: Record<string, number> = {}
445
+ const revenueBySource: Record<string, number> = {}
446
+
447
+ for (const event of events) {
448
+ const revenue = typeof event.value === 'number' ? event.value : 0
449
+ totalRevenue += revenue
450
+ transactionCount++
451
+
452
+ const day = event.timestamp.slice(0, 10)
453
+ revenueByDay[day] = (revenueByDay[day] || 0) + revenue
454
+
455
+ const source = event.utmSource || event.referrerSource || 'direct'
456
+ revenueBySource[source] = (revenueBySource[source] || 0) + revenue
457
+ }
458
+
459
+ const avgOrderValue = transactionCount > 0 ? totalRevenue / transactionCount : 0
460
+
461
+ const dailyRevenue = Object.entries(revenueByDay)
462
+ .map(([date, revenue]) => ({ date, revenue }))
463
+ .sort((a, b) => a.date.localeCompare(b.date))
464
+
465
+ const revenueBySourceList = Object.entries(revenueBySource)
466
+ .map(([source, revenue]) => ({ source, revenue }))
467
+ .sort((a, b) => b.revenue - a.revenue)
468
+
469
+ return jsonResponse({
470
+ totalRevenue,
471
+ transactionCount,
472
+ avgOrderValue,
473
+ dailyRevenue,
474
+ revenueBySource: revenueBySourceList,
475
+ currency: 'USD',
476
+ dateRange: {
477
+ start: startDate.toISOString(),
478
+ end: endDate.toISOString(),
479
+ },
480
+ })
481
+ }
482
+ catch (error) {
483
+ console.error('Get revenue error:', error)
484
+ return errorResponse('Failed to fetch revenue')
485
+ }
486
+ }