@stacksjs/ts-analytics 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Team management 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 { addMembership, removeMembership, addPendingInvite } from '../lib/membership'
10
+ import { getUserByEmail } from './auth'
11
+
12
+ /**
13
+ * POST /api/sites/{siteId}/team
14
+ */
15
+ export async function handleInviteTeamMember(request: Request, siteId: string): Promise<Response> {
16
+ try {
17
+ const body = await request.json() as Record<string, any>
18
+
19
+ if (!body.email || !body.role) {
20
+ return jsonResponse({ error: 'Missing required fields: email, role' }, 400)
21
+ }
22
+
23
+ const validRoles = ['admin', 'editor', 'viewer']
24
+ if (!validRoles.includes(body.role)) {
25
+ return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
26
+ }
27
+
28
+ const memberId = generateId()
29
+ const existingUser = await getUserByEmail(body.email)
30
+ const now = new Date().toISOString()
31
+ const member = {
32
+ pk: `SITE#${siteId}`,
33
+ sk: `TEAM#${memberId}`,
34
+ id: memberId,
35
+ siteId,
36
+ email: body.email,
37
+ role: body.role,
38
+ userId: existingUser?.userId || null,
39
+ status: existingUser ? 'active' : 'invited',
40
+ invitedAt: now,
41
+ acceptedAt: existingUser ? now : null,
42
+ }
43
+
44
+ await dynamodb.putItem({
45
+ TableName: TABLE_NAME,
46
+ Item: marshall(member),
47
+ })
48
+
49
+ // Existing users get immediate project access via the membership layer
50
+ // (what the authz guard checks). New emails stay 'invited' until accept (#60).
51
+ if (existingUser) await addMembership(existingUser.userId, siteId, body.role)
52
+ else await addPendingInvite(body.email, siteId, body.role)
53
+
54
+ // Tell the invitee (#129) — invites previously created silently. Best
55
+ // effort: mail trouble must not fail the invite itself.
56
+ try {
57
+ const { sendEmail } = await import('../lib/email')
58
+ const appUrl = (process.env.APP_URL || '').replace(/\/$/, '')
59
+ await sendEmail({
60
+ to: body.email,
61
+ subject: `You've been invited to an analytics dashboard (${siteId})`,
62
+ text: [
63
+ `You've been invited as ${body.role} on the analytics dashboard for ${siteId}.`,
64
+ existingUser
65
+ ? `Your existing account already has access — sign in to view it.`
66
+ : `Sign up with this email address to accept the invite.`,
67
+ appUrl ? `\n${appUrl}/login` : '',
68
+ ].filter(Boolean).join('\n'),
69
+ })
70
+ }
71
+ catch (e) {
72
+ console.error('Invite email failed:', (e as Error).message)
73
+ }
74
+
75
+ return jsonResponse({ member }, 201)
76
+ }
77
+ catch (error) {
78
+ console.error('Invite team member error:', error)
79
+ return errorResponse('Failed to invite team member')
80
+ }
81
+ }
82
+
83
+ /**
84
+ * GET /api/sites/{siteId}/team
85
+ */
86
+ export async function handleGetTeamMembers(_request: Request, siteId: string): Promise<Response> {
87
+ try {
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: 'TEAM#' },
94
+ },
95
+ }) as { Items?: any[] }
96
+
97
+ const members = (result.Items || []).map(unmarshall)
98
+
99
+ return jsonResponse({ members })
100
+ }
101
+ catch (error) {
102
+ console.error('Get team members error:', error)
103
+ return errorResponse('Failed to fetch team members')
104
+ }
105
+ }
106
+
107
+ /**
108
+ * DELETE /api/sites/{siteId}/team/{memberId}
109
+ */
110
+ export async function handleRemoveTeamMember(request: Request, siteId: string, memberId: string): Promise<Response> {
111
+ try {
112
+ // Load the record first so we can revoke the linked project membership.
113
+ const got = await dynamodb.getItem({
114
+ TableName: TABLE_NAME,
115
+ Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `TEAM#${memberId}` } },
116
+ })
117
+
118
+ // Rank rules (#157): the guard already requires admin+ for team writes,
119
+ // but an admin must not be able to remove the OWNER or peer admins —
120
+ // that's an authorization bypass (team takeover). Owner removal is never
121
+ // allowed here (transfer ownership instead); removing an admin requires
122
+ // the owner. Self-removal (leaving the team) is always allowed except
123
+ // for the owner.
124
+ if (got.Item) {
125
+ const target = unmarshall(got.Item)
126
+ const targetRole = String(target.role || 'viewer')
127
+ const { getSessionFromRequest } = await import('./auth')
128
+ const session = await getSessionFromRequest(request)
129
+ const isSelf = !!session && target.userId === session.userId
130
+ if (targetRole === 'owner') {
131
+ return jsonResponse({ error: 'The owner cannot be removed. Transfer ownership first.' }, 403)
132
+ }
133
+ if (targetRole === 'admin' && !isSelf && session) {
134
+ const { getMembership } = await import('../lib/membership')
135
+ const requesterRole = await getMembership(session.userId, siteId)
136
+ if (requesterRole !== 'owner') {
137
+ return jsonResponse({ error: 'Only the owner can remove an admin.' }, 403)
138
+ }
139
+ }
140
+ }
141
+
142
+ if (got.Item) {
143
+ const m = unmarshall(got.Item)
144
+ if (m.userId) await removeMembership(m.userId, siteId)
145
+ }
146
+
147
+ await dynamodb.deleteItem({
148
+ TableName: TABLE_NAME,
149
+ Key: marshall({
150
+ pk: `SITE#${siteId}`,
151
+ sk: `TEAM#${memberId}`,
152
+ }),
153
+ })
154
+
155
+ return jsonResponse({ success: true })
156
+ }
157
+ catch (error) {
158
+ console.error('Remove team member error:', error)
159
+ return errorResponse('Failed to remove team member')
160
+ }
161
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Uptime monitoring 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
+ import { sendEmail } from '../lib/email'
11
+
12
+ /**
13
+ * POST /api/sites/{siteId}/uptime
14
+ */
15
+ export async function handleCreateUptimeMonitor(request: Request, siteId: string): Promise<Response> {
16
+ try {
17
+ const body = await request.json() as Record<string, any>
18
+
19
+ if (!body.url) {
20
+ return jsonResponse({ error: 'Missing required field: url' }, 400)
21
+ }
22
+
23
+ const monitorId = generateId()
24
+ const monitor = {
25
+ pk: `SITE#${siteId}`,
26
+ sk: `UPTIME#${monitorId}`,
27
+ id: monitorId,
28
+ siteId,
29
+ url: body.url,
30
+ name: body.name || body.url,
31
+ interval: body.interval || 60, // seconds
32
+ timeout: body.timeout || 30, // seconds
33
+ expectedStatus: body.expectedStatus || 200,
34
+ alertEmail: body.alertEmail,
35
+ alertWebhook: body.alertWebhook,
36
+ isActive: body.isActive ?? true,
37
+ status: 'unknown',
38
+ lastChecked: null,
39
+ lastResponseTime: null,
40
+ uptimePercent: 100,
41
+ createdAt: new Date().toISOString(),
42
+ }
43
+
44
+ await dynamodb.putItem({
45
+ TableName: TABLE_NAME,
46
+ Item: marshall(monitor),
47
+ })
48
+
49
+ return jsonResponse({ monitor }, 201)
50
+ }
51
+ catch (error) {
52
+ console.error('Create uptime monitor error:', error)
53
+ return errorResponse('Failed to create uptime monitor')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * GET /api/sites/{siteId}/uptime
59
+ */
60
+ export async function handleGetUptimeMonitors(_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: 'UPTIME#' },
68
+ },
69
+ }) as { Items?: any[] }
70
+
71
+ const monitors = (result.Items || []).map(unmarshall)
72
+
73
+ return jsonResponse({ monitors })
74
+ }
75
+ catch (error) {
76
+ console.error('Get uptime monitors error:', error)
77
+ return errorResponse('Failed to fetch uptime monitors')
78
+ }
79
+ }
80
+
81
+ /**
82
+ * GET /api/sites/{siteId}/uptime/{monitorId}/history
83
+ */
84
+ export async function handleGetUptimeHistory(request: Request, siteId: string, monitorId: string): Promise<Response> {
85
+ try {
86
+ const query = getQueryParams(request)
87
+ const { startDate, endDate } = parseDateRange(query)
88
+
89
+ const result = await dynamodb.query({
90
+ TableName: TABLE_NAME,
91
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
92
+ ExpressionAttributeValues: {
93
+ ':pk': { S: `UPTIME_CHECK#${siteId}#${monitorId}` },
94
+ ':start': { S: `CHECK#${startDate.toISOString()}` },
95
+ ':end': { S: `CHECK#${endDate.toISOString()}` },
96
+ },
97
+ ScanIndexForward: false,
98
+ Limit: 1000,
99
+ }) as { Items?: any[] }
100
+
101
+ const checks = (result.Items || []).map(unmarshall)
102
+
103
+ // Calculate uptime stats
104
+ const totalChecks = checks.length
105
+ const successfulChecks = checks.filter(c => c.status === 'up').length
106
+ const uptimePercent = totalChecks > 0 ? Math.round((successfulChecks / totalChecks) * 10000) / 100 : 100
107
+
108
+ const avgResponseTime = totalChecks > 0
109
+ ? Math.round(checks.reduce((sum, c) => sum + (c.responseTime || 0), 0) / totalChecks)
110
+ : 0
111
+
112
+ // Get incidents (status changes)
113
+ const incidents: Array<{ timestamp: string; status: string; duration?: number }> = []
114
+ let currentIncident: { start: string; status: string } | null = null
115
+
116
+ for (let i = checks.length - 1; i >= 0; i--) {
117
+ const check = checks[i]
118
+ if (check.status === 'down' && !currentIncident) {
119
+ currentIncident = { start: check.timestamp, status: 'down' }
120
+ }
121
+ else if (check.status === 'up' && currentIncident) {
122
+ incidents.push({
123
+ timestamp: currentIncident.start,
124
+ status: currentIncident.status,
125
+ duration: new Date(check.timestamp).getTime() - new Date(currentIncident.start).getTime(),
126
+ })
127
+ currentIncident = null
128
+ }
129
+ }
130
+
131
+ if (currentIncident) {
132
+ incidents.push({
133
+ timestamp: currentIncident.start,
134
+ status: currentIncident.status,
135
+ })
136
+ }
137
+
138
+ return jsonResponse({
139
+ checks: checks.slice(0, 100).map(c => ({
140
+ timestamp: c.timestamp,
141
+ status: c.status,
142
+ responseTime: c.responseTime,
143
+ statusCode: c.statusCode,
144
+ error: c.error,
145
+ })),
146
+ summary: {
147
+ totalChecks,
148
+ uptimePercent,
149
+ avgResponseTime,
150
+ incidents: incidents.length,
151
+ },
152
+ incidents,
153
+ dateRange: {
154
+ start: startDate.toISOString(),
155
+ end: endDate.toISOString(),
156
+ },
157
+ })
158
+ }
159
+ catch (error) {
160
+ console.error('Get uptime history error:', error)
161
+ return errorResponse('Failed to fetch uptime history')
162
+ }
163
+ }
164
+
165
+ /**
166
+ * DELETE /api/sites/{siteId}/uptime/{monitorId}
167
+ */
168
+ export async function handleDeleteUptimeMonitor(_request: Request, siteId: string, monitorId: string): Promise<Response> {
169
+ try {
170
+ await dynamodb.deleteItem({
171
+ TableName: TABLE_NAME,
172
+ Key: marshall({
173
+ pk: `SITE#${siteId}`,
174
+ sk: `UPTIME#${monitorId}`,
175
+ }),
176
+ })
177
+
178
+ return jsonResponse({ success: true })
179
+ }
180
+ catch (error) {
181
+ console.error('Delete uptime monitor error:', error)
182
+ return errorResponse('Failed to delete uptime monitor')
183
+ }
184
+ }
185
+
186
+ /** Probe a URL with a timeout; up = expected status (or any 2xx/3xx). */
187
+ async function probeUrl(url: string, timeoutMs: number, expectedStatus?: number): Promise<{ up: boolean, httpStatus: number, responseTime: number }> {
188
+ const start = Date.now()
189
+ const ctrl = new AbortController()
190
+ const t = setTimeout(() => ctrl.abort(), timeoutMs)
191
+ try {
192
+ const res = await fetch(url, { signal: ctrl.signal, redirect: 'follow' })
193
+ const up = expectedStatus ? res.status === expectedStatus : (res.status >= 200 && res.status < 400)
194
+ return { up, httpStatus: res.status, responseTime: Date.now() - start }
195
+ }
196
+ catch {
197
+ return { up: false, httpStatus: 0, responseTime: Date.now() - start }
198
+ }
199
+ finally {
200
+ clearTimeout(t)
201
+ }
202
+ }
203
+
204
+ async function probeAndRecord(siteId: string, m: any): Promise<void> {
205
+ const r = await probeUrl(m.url, (m.timeout || 30) * 1000, m.expectedStatus)
206
+ const nowIso = new Date().toISOString()
207
+ const status = r.up ? 'up' : 'down'
208
+ const wasDown = m.status === 'down'
209
+
210
+ await dynamodb.putItem({
211
+ TableName: TABLE_NAME,
212
+ Item: marshall({
213
+ pk: `UPTIME_CHECK#${siteId}#${m.id}`,
214
+ sk: `CHECK#${nowIso}`,
215
+ status,
216
+ httpStatus: r.httpStatus,
217
+ responseTime: r.responseTime,
218
+ timestamp: nowIso,
219
+ ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
220
+ }),
221
+ })
222
+
223
+ await dynamodb.updateItem({
224
+ TableName: TABLE_NAME,
225
+ Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `UPTIME#${m.id}` } },
226
+ UpdateExpression: 'SET #s = :s, lastChecked = :now, lastResponseTime = :rt',
227
+ ExpressionAttributeNames: { '#s': 'status' },
228
+ ExpressionAttributeValues: { ':s': { S: status }, ':now': { S: nowIso }, ':rt': { N: String(r.responseTime) } },
229
+ })
230
+
231
+ // Notify once on a down transition (up -> down).
232
+ if (status === 'down' && !wasDown) {
233
+ const msg = `Uptime alert: ${m.name || m.url} is DOWN (HTTP ${r.httpStatus || 'timeout'})`
234
+ if (m.alertEmail)
235
+ await sendEmail({ to: m.alertEmail, subject: `[Uptime] ${m.name || m.url} is down`, text: msg })
236
+ if (m.alertWebhook) {
237
+ try {
238
+ await fetch(m.alertWebhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ monitor: m.name || m.url, url: m.url, status, httpStatus: r.httpStatus, at: nowIso }) })
239
+ }
240
+ catch (e) {
241
+ console.error('Uptime webhook failed:', (e as Error).message)
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Probe every active monitor whose interval has elapsed (scheduled job, #91/#90). */
248
+ export async function runUptimeChecks(): Promise<number> {
249
+ const sites = await dynamodb.query({
250
+ TableName: TABLE_NAME,
251
+ KeyConditionExpression: 'pk = :pk',
252
+ ExpressionAttributeValues: { ':pk': { S: 'SITES' } },
253
+ }) as { Items?: any[] }
254
+
255
+ let probed = 0
256
+ for (const raw of (sites.Items || [])) {
257
+ const site = unmarshall(raw)
258
+ const siteId = site.siteId || site.id
259
+ if (!siteId)
260
+ continue
261
+ const mons = await dynamodb.query({
262
+ TableName: TABLE_NAME,
263
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :p)',
264
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':p': { S: 'UPTIME#' } },
265
+ }) as { Items?: any[] }
266
+ for (const mraw of (mons.Items || [])) {
267
+ const m = unmarshall(mraw)
268
+ if (m.isActive === false)
269
+ continue
270
+ const intervalMs = (m.interval || 60) * 1000
271
+ if (m.lastChecked && Date.now() - new Date(m.lastChecked).getTime() < intervalMs)
272
+ continue
273
+ try {
274
+ await probeAndRecord(siteId, m)
275
+ probed++
276
+ }
277
+ catch (e) {
278
+ console.error(`[jobs] uptime probe ${m.id} failed:`, (e as Error).message)
279
+ }
280
+ }
281
+ }
282
+ return probed
283
+ }