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