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