@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,138 @@
1
+ /**
2
+ * A/B Testing / Experiment handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { analyzeVariants } from '../lib/significance'
7
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
8
+ import { jsonResponse, errorResponse } from '../utils/response'
9
+ import { getQueryParams } from '../../deploy/lambda-adapter'
10
+
11
+ /**
12
+ * POST /api/sites/{siteId}/experiments
13
+ */
14
+ export async function handleCreateExperiment(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const body = await request.json() as Record<string, any>
17
+
18
+ if (!body.name || !body.variants || !Array.isArray(body.variants) || body.variants.length < 2) {
19
+ return jsonResponse({ error: 'Missing required fields: name, variants (min 2)' }, 400)
20
+ }
21
+
22
+ const experimentId = generateId()
23
+ const experiment = {
24
+ pk: `SITE#${siteId}`,
25
+ sk: `EXPERIMENT#${experimentId}`,
26
+ id: experimentId,
27
+ siteId,
28
+ name: body.name,
29
+ description: body.description || '',
30
+ variants: body.variants.map((v: any, i: number) => ({
31
+ id: v.id || generateId(),
32
+ name: v.name || `Variant ${i + 1}`,
33
+ weight: v.weight || Math.floor(100 / body.variants.length),
34
+ conversions: 0,
35
+ visitors: 0,
36
+ })),
37
+ goalId: body.goalId,
38
+ status: 'active',
39
+ createdAt: new Date().toISOString(),
40
+ }
41
+
42
+ await dynamodb.putItem({
43
+ TableName: TABLE_NAME,
44
+ Item: marshall(experiment),
45
+ })
46
+
47
+ return jsonResponse({ experiment }, 201)
48
+ }
49
+ catch (error) {
50
+ console.error('Create experiment error:', error)
51
+ return errorResponse('Failed to create experiment')
52
+ }
53
+ }
54
+
55
+ /**
56
+ * GET /api/sites/{siteId}/experiments
57
+ */
58
+ export async function handleGetExperiments(_request: Request, siteId: string): Promise<Response> {
59
+ try {
60
+ const result = await dynamodb.query({
61
+ TableName: TABLE_NAME,
62
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
63
+ ExpressionAttributeValues: {
64
+ ':pk': { S: `SITE#${siteId}` },
65
+ ':prefix': { S: 'EXPERIMENT#' },
66
+ },
67
+ }) as { Items?: any[] }
68
+
69
+ const experiments = (result.Items || []).map(unmarshall).map((exp: any) => {
70
+ const stats = analyzeVariants(exp.variants || [])
71
+ return { ...exp, variants: (exp.variants || []).map((v: any, i: number) => ({ ...v, ...stats[i] })) }
72
+ })
73
+
74
+ return jsonResponse({ experiments })
75
+ }
76
+ catch (error) {
77
+ console.error('Get experiments error:', error)
78
+ return errorResponse('Failed to fetch experiments')
79
+ }
80
+ }
81
+
82
+ /**
83
+ * POST /api/sites/{siteId}/experiments/event
84
+ */
85
+ export async function handleRecordExperimentEvent(request: Request, siteId: string): Promise<Response> {
86
+ try {
87
+ const body = await request.json() as Record<string, any>
88
+
89
+ if (!body.experimentId || !body.variantId || !body.eventType) {
90
+ return jsonResponse({ error: 'Missing required fields: experimentId, variantId, eventType' }, 400)
91
+ }
92
+
93
+ // Get experiment
94
+ const result = await dynamodb.query({
95
+ TableName: TABLE_NAME,
96
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
97
+ ExpressionAttributeValues: {
98
+ ':pk': { S: `SITE#${siteId}` },
99
+ ':sk': { S: `EXPERIMENT#${body.experimentId}` },
100
+ },
101
+ }) as { Items?: any[] }
102
+
103
+ if (!result.Items || result.Items.length === 0) {
104
+ return jsonResponse({ error: 'Experiment not found' }, 404)
105
+ }
106
+
107
+ const experiment = unmarshall(result.Items[0])
108
+ const variant = experiment.variants.find((v: any) => v.id === body.variantId)
109
+
110
+ if (!variant) {
111
+ return jsonResponse({ error: 'Variant not found' }, 404)
112
+ }
113
+
114
+ // Update variant stats
115
+ if (body.eventType === 'view') {
116
+ variant.visitors = (variant.visitors || 0) + 1
117
+ }
118
+ else if (body.eventType === 'conversion') {
119
+ variant.conversions = (variant.conversions || 0) + 1
120
+ }
121
+
122
+ // Save updated experiment
123
+ await dynamodb.putItem({
124
+ TableName: TABLE_NAME,
125
+ Item: marshall({
126
+ ...experiment,
127
+ variants: experiment.variants,
128
+ updatedAt: new Date().toISOString(),
129
+ }),
130
+ })
131
+
132
+ return jsonResponse({ success: true })
133
+ }
134
+ catch (error) {
135
+ console.error('Record experiment event error:', error)
136
+ return errorResponse('Failed to record experiment event')
137
+ }
138
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Funnel handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { querySessionItemsInRange, queryAllItems, 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}/funnels
13
+ */
14
+ export async function handleCreateFunnel(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const body = await request.json() as Record<string, any>
17
+
18
+ if (!body.name || !body.steps || !Array.isArray(body.steps) || body.steps.length < 2) {
19
+ return jsonResponse({ error: 'Missing required fields: name, steps (min 2 steps)' }, 400)
20
+ }
21
+
22
+ const funnelId = generateId()
23
+ const funnel = {
24
+ pk: `SITE#${siteId}`,
25
+ sk: `FUNNEL#${funnelId}`,
26
+ id: funnelId,
27
+ siteId,
28
+ name: body.name,
29
+ steps: body.steps,
30
+ createdAt: new Date().toISOString(),
31
+ }
32
+
33
+ await dynamodb.putItem({
34
+ TableName: TABLE_NAME,
35
+ Item: marshall(funnel),
36
+ })
37
+
38
+ return jsonResponse({ funnel }, 201)
39
+ }
40
+ catch (error) {
41
+ console.error('Create funnel error:', error)
42
+ return errorResponse('Failed to create funnel')
43
+ }
44
+ }
45
+
46
+ /**
47
+ * GET /api/sites/{siteId}/funnels
48
+ */
49
+ export async function handleGetFunnels(_request: Request, siteId: string): Promise<Response> {
50
+ try {
51
+ const result = await queryAllItems({
52
+ TableName: TABLE_NAME,
53
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
54
+ ExpressionAttributeValues: {
55
+ ':pk': { S: `SITE#${siteId}` },
56
+ ':prefix': { S: 'FUNNEL#' },
57
+ },
58
+ }) as { Items?: any[] }
59
+
60
+ const funnels = (result.Items || []).map(unmarshall)
61
+
62
+ return jsonResponse({ funnels })
63
+ }
64
+ catch (error) {
65
+ console.error('Get funnels error:', error)
66
+ return errorResponse('Failed to fetch funnels')
67
+ }
68
+ }
69
+
70
+ /**
71
+ * GET /api/sites/{siteId}/funnels/{funnelId}
72
+ */
73
+ export async function handleGetFunnelAnalysis(request: Request, siteId: string, funnelId: string): Promise<Response> {
74
+ try {
75
+ const query = getQueryParams(request)
76
+ const { startDate, endDate } = parseDateRange(query)
77
+
78
+ // Get funnel definition
79
+ const funnelResult = await dynamodb.query({
80
+ TableName: TABLE_NAME,
81
+ KeyConditionExpression: 'pk = :pk AND sk = :sk',
82
+ ExpressionAttributeValues: {
83
+ ':pk': { S: `SITE#${siteId}` },
84
+ ':sk': { S: `FUNNEL#${funnelId}` },
85
+ },
86
+ }) as { Items?: any[] }
87
+
88
+ if (!funnelResult.Items || funnelResult.Items.length === 0) {
89
+ return jsonResponse({ error: 'Funnel not found' }, 404)
90
+ }
91
+
92
+ const funnel = unmarshall(funnelResult.Items[0])
93
+
94
+ // Get sessions for analysis
95
+ const sessionsResult = await querySessionItemsInRange(siteId, startDate, endDate) as { Items?: any[] }
96
+
97
+ const sessions = (sessionsResult.Items || []).map(unmarshall).filter(s => {
98
+ const sessionStart = new Date(s.startedAt)
99
+ return sessionStart >= startDate && sessionStart <= endDate
100
+ })
101
+
102
+ // Analyze funnel steps
103
+ const steps = funnel.steps as Array<{ name: string; pattern: string; type: 'pageview' | 'event' }>
104
+ const stepResults = steps.map((step, index) => ({
105
+ name: step.name,
106
+ pattern: step.pattern,
107
+ type: step.type,
108
+ visitors: 0,
109
+ sessions: 0,
110
+ conversionRate: 0,
111
+ dropoffRate: 0,
112
+ stepIndex: index,
113
+ }))
114
+
115
+ // Track visitors through funnel steps
116
+ for (const session of sessions) {
117
+ const pageSequence = session.pageSequence || []
118
+ const events = session.events || []
119
+
120
+ let completedSteps = 0
121
+ for (let i = 0; i < steps.length; i++) {
122
+ const step = steps[i]
123
+ let matched = false
124
+
125
+ if (step.type === 'pageview') {
126
+ matched = pageSequence.some((page: string) => {
127
+ if (step.pattern.includes('*')) {
128
+ const regex = new RegExp(`^${step.pattern.replace(/\*/g, '.*')}$`)
129
+ return regex.test(page)
130
+ }
131
+ return page === step.pattern
132
+ })
133
+ }
134
+ else {
135
+ matched = events.some((event: any) => event.name === step.pattern)
136
+ }
137
+
138
+ if (matched) {
139
+ completedSteps = i + 1
140
+ stepResults[i].sessions++
141
+ }
142
+ else {
143
+ break
144
+ }
145
+ }
146
+ }
147
+
148
+ // Calculate unique visitors and rates
149
+ const totalSessions = sessions.length
150
+ for (let i = 0; i < stepResults.length; i++) {
151
+ const step = stepResults[i]
152
+ const prevStep = i > 0 ? stepResults[i - 1] : null
153
+
154
+ step.visitors = step.sessions // Simplified: using sessions as visitors
155
+ step.conversionRate = totalSessions > 0
156
+ ? Math.round((step.sessions / totalSessions) * 100)
157
+ : 0
158
+
159
+ if (prevStep) {
160
+ step.dropoffRate = prevStep.sessions > 0
161
+ ? Math.round(((prevStep.sessions - step.sessions) / prevStep.sessions) * 100)
162
+ : 0
163
+ }
164
+ }
165
+
166
+ // Calculate overall conversion rate
167
+ const firstStep = stepResults[0]?.sessions || 0
168
+ const lastStep = stepResults[stepResults.length - 1]?.sessions || 0
169
+ const overallConversionRate = firstStep > 0
170
+ ? Math.round((lastStep / firstStep) * 100)
171
+ : 0
172
+
173
+ return jsonResponse({
174
+ funnel: {
175
+ id: funnel.id,
176
+ name: funnel.name,
177
+ },
178
+ steps: stepResults,
179
+ summary: {
180
+ totalSessions,
181
+ enteredFunnel: firstStep,
182
+ completedFunnel: lastStep,
183
+ overallConversionRate,
184
+ },
185
+ dateRange: {
186
+ start: startDate.toISOString(),
187
+ end: endDate.toISOString(),
188
+ },
189
+ })
190
+ }
191
+ catch (error) {
192
+ console.error('Get funnel analysis error:', error)
193
+ return errorResponse('Failed to fetch funnel analysis')
194
+ }
195
+ }
196
+
197
+ /**
198
+ * DELETE /api/sites/{siteId}/funnels/{funnelId}
199
+ */
200
+ export async function handleDeleteFunnel(_request: Request, siteId: string, funnelId: string): Promise<Response> {
201
+ try {
202
+ await dynamodb.deleteItem({
203
+ TableName: TABLE_NAME,
204
+ Key: marshall({
205
+ pk: `SITE#${siteId}`,
206
+ sk: `FUNNEL#${funnelId}`,
207
+ }),
208
+ })
209
+
210
+ return jsonResponse({ success: true })
211
+ }
212
+ catch (error) {
213
+ console.error('Delete funnel error:', error)
214
+ return errorResponse('Failed to delete funnel')
215
+ }
216
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Goal CRUD handlers
3
+ */
4
+
5
+ import { generateId } from '../index'
6
+ import { Goal, Conversion } from '../../src/models/orm'
7
+ import { querySessionItemsInRange, queryAllItems, dynamodb, TABLE_NAME, unmarshall } from '../lib/dynamodb'
8
+ import { invalidateGoalCache } from '../utils/cache'
9
+ import { parseDateRange } from '../utils/date'
10
+ import { jsonResponse, errorResponse } from '../utils/response'
11
+ import { getQueryParams } from '../../deploy/lambda-adapter'
12
+
13
+ /**
14
+ * POST /api/sites/{siteId}/goals
15
+ */
16
+ export async function handleCreateGoal(request: Request, siteId: string): Promise<Response> {
17
+ try {
18
+ const body = await request.json() as Record<string, any>
19
+
20
+ if (!body.name || !body.type) {
21
+ return jsonResponse({ error: 'Missing required fields: name, type' }, 400)
22
+ }
23
+
24
+ if (!['pageview', 'event', 'duration'].includes(body.type)) {
25
+ return jsonResponse({ error: 'Invalid type. Must be: pageview, event, or duration' }, 400)
26
+ }
27
+
28
+ if ((body.type === 'pageview' || body.type === 'event') && !body.pattern) {
29
+ return jsonResponse({ error: 'Pattern is required for pageview and event goals' }, 400)
30
+ }
31
+
32
+ if (body.type === 'duration' && (!body.durationMinutes || body.durationMinutes < 1)) {
33
+ return jsonResponse({ error: 'durationMinutes is required for duration goals (min: 1)' }, 400)
34
+ }
35
+
36
+ const goal = await Goal.create({
37
+ id: generateId(),
38
+ siteId,
39
+ name: body.name,
40
+ type: body.type,
41
+ pattern: body.pattern || '',
42
+ matchType: body.matchType || 'exact',
43
+ durationMinutes: body.durationMinutes,
44
+ value: body.value,
45
+ isActive: body.isActive ?? true,
46
+ })
47
+
48
+ invalidateGoalCache(siteId)
49
+ return jsonResponse({ goal }, 201)
50
+ }
51
+ catch (error) {
52
+ console.error('Create goal error:', error)
53
+ return errorResponse('Failed to create goal')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * GET /api/sites/{siteId}/goals
59
+ */
60
+ export async function handleGetGoals(request: Request, siteId: string): Promise<Response> {
61
+ try {
62
+ const query = getQueryParams(request)
63
+ const includeInactive = query.includeInactive === 'true'
64
+ const { startDate, endDate } = parseDateRange(query)
65
+
66
+ const queryBuilder = Goal.forSite(siteId)
67
+ if (!includeInactive) {
68
+ queryBuilder.active()
69
+ }
70
+ const goals = await queryBuilder.get()
71
+
72
+ const goalsWithStats = await Promise.all(goals.map(async (goal) => {
73
+ let conversions: Conversion[] = []
74
+ try {
75
+ conversions = await Conversion.forGoal(siteId, goal.id)
76
+ .since(startDate)
77
+ .until(endDate)
78
+ .get()
79
+ }
80
+ catch (e) {
81
+ console.log('[GetGoals] Conversion query failed:', e)
82
+ }
83
+
84
+ const uniqueVisitors = new Set(conversions.map(c => c.visitorId)).size
85
+ const totalValue = conversions.reduce((sum, c) => sum + (c.value || 0), 0)
86
+
87
+ return {
88
+ id: goal.id,
89
+ name: goal.name,
90
+ type: goal.type,
91
+ pattern: goal.pattern,
92
+ matchType: goal.matchType,
93
+ durationMinutes: goal.durationMinutes,
94
+ value: goal.value,
95
+ isActive: goal.isActive,
96
+ conversions: conversions.length,
97
+ uniqueConversions: uniqueVisitors,
98
+ totalValue,
99
+ createdAt: goal.createdAt,
100
+ }
101
+ }))
102
+
103
+ return jsonResponse({ goals: goalsWithStats })
104
+ }
105
+ catch (error) {
106
+ console.error('Get goals error:', error)
107
+ return errorResponse('Failed to fetch goals')
108
+ }
109
+ }
110
+
111
+ /**
112
+ * PUT /api/sites/{siteId}/goals/{goalId}
113
+ */
114
+ export async function handleUpdateGoal(request: Request, siteId: string, goalId: string): Promise<Response> {
115
+ try {
116
+ const body = await request.json() as Record<string, any>
117
+
118
+ if (body.type && !['pageview', 'event', 'duration'].includes(body.type)) {
119
+ return jsonResponse({ error: 'Invalid type. Must be: pageview, event, or duration' }, 400)
120
+ }
121
+
122
+ const goal = await Goal.update(siteId, goalId, {
123
+ name: body.name,
124
+ type: body.type,
125
+ pattern: body.pattern,
126
+ matchType: body.matchType,
127
+ durationMinutes: body.durationMinutes,
128
+ value: body.value,
129
+ isActive: body.isActive,
130
+ })
131
+
132
+ invalidateGoalCache(siteId)
133
+ return jsonResponse({ goal })
134
+ }
135
+ catch (error) {
136
+ console.error('Update goal error:', error)
137
+ return errorResponse('Failed to update goal')
138
+ }
139
+ }
140
+
141
+ /**
142
+ * DELETE /api/sites/{siteId}/goals/{goalId}
143
+ */
144
+ export async function handleDeleteGoal(_request: Request, siteId: string, goalId: string): Promise<Response> {
145
+ try {
146
+ await Goal.delete(siteId, goalId)
147
+ invalidateGoalCache(siteId)
148
+ return jsonResponse({ success: true })
149
+ }
150
+ catch (error) {
151
+ console.error('Delete goal error:', error)
152
+ return errorResponse('Failed to delete goal')
153
+ }
154
+ }
155
+
156
+ /**
157
+ * GET /api/sites/{siteId}/goals/stats
158
+ */
159
+ export async function handleGetGoalStats(request: Request, siteId: string): Promise<Response> {
160
+ try {
161
+ const query = getQueryParams(request)
162
+ const { startDate, endDate } = parseDateRange(query)
163
+
164
+ const goals = await Goal.forSite(siteId).get()
165
+
166
+ // Denominator: sessions IN THE REQUESTED RANGE (paginated). The old
167
+ // single-page COUNT was both truncated at 1MB and all-time, while the
168
+ // numerator (conversions) was date-bounded — conversion rates were
169
+ // meaningless on any site with history (#151 residual).
170
+ const sessionsResult = await querySessionItemsInRange(siteId, startDate, endDate) as { Items?: any[] }
171
+ const totalSessions = (sessionsResult.Items || []).map(unmarshall).filter((sess) => {
172
+ const t = new Date(sess.startedAt)
173
+ return t >= startDate && t <= endDate
174
+ }).length
175
+
176
+ const goalStats = await Promise.all(goals.map(async (goal) => {
177
+ const conversions = await Conversion.forGoal(siteId, goal.id)
178
+ .since(startDate)
179
+ .until(endDate)
180
+ .get()
181
+
182
+ const uniqueVisitors = new Set(conversions.map(c => c.visitorId)).size
183
+ const totalValue = conversions.reduce((sum, c) => sum + (c.value || 0), 0)
184
+
185
+ return {
186
+ goalId: goal.id,
187
+ goalName: goal.name,
188
+ goalType: goal.type,
189
+ conversions: conversions.length,
190
+ uniqueConversions: uniqueVisitors,
191
+ conversionRate: totalSessions > 0
192
+ ? Math.round((uniqueVisitors / totalSessions) * 10000) / 100
193
+ : 0,
194
+ totalValue,
195
+ isActive: goal.isActive,
196
+ }
197
+ }))
198
+
199
+ const totalConversions = goalStats.reduce((sum, g) => sum + g.conversions, 0)
200
+ const totalValue = goalStats.reduce((sum, g) => sum + g.totalValue, 0)
201
+
202
+ return jsonResponse({
203
+ goals: goalStats,
204
+ summary: {
205
+ totalGoals: goals.length,
206
+ activeGoals: goals.filter(g => g.isActive).length,
207
+ totalConversions,
208
+ totalValue,
209
+ totalSessions,
210
+ },
211
+ dateRange: { start: startDate.toISOString(), end: endDate.toISOString() },
212
+ })
213
+ }
214
+ catch (error) {
215
+ console.error('Get goal stats error:', error)
216
+ return errorResponse('Failed to fetch goal stats')
217
+ }
218
+ }