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