@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- 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
|
+
}
|