@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
package/src/widget.ts CHANGED
@@ -1,349 +1,208 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import { deserializeFromDb } from '@beechcms/core'
4
- import type { Seed } from '@beechcms/core'
5
- import type { Env, Variables } from './types'
6
-
7
- const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
-
9
- // ─── Helpers ────────────────────────────────────────────────────────────────
10
-
11
- type AggregateFormula =
12
- | { op: 'count' }
13
- | { op: 'sum'; column: string }
14
- | { op: 'avg'; column: string }
15
- | { op: 'min'; column: string }
16
- | { op: 'max'; column: string }
17
- | { op: 'countWhere'; column: string; value: unknown }
18
- | { op: 'percentageOf'; numeratorColumn: string; denominatorColumn: string }
19
-
20
- type TimeWindow = 'week' | 'month' | 'year' | 'all'
21
-
22
- function timeWindowSql(window: TimeWindow): string {
23
- switch (window) {
24
- case 'week': return "created_at >= unixepoch('now', '-7 days')"
25
- case 'month': return "created_at >= unixepoch('now', '-1 month')"
26
- case 'year': return "created_at >= unixepoch('now', '-1 year')"
27
- case 'all': return '1=1'
28
- }
29
- }
30
-
31
- function previousWindowSql(window: TimeWindow): { current: string; previous: string } {
32
- switch (window) {
33
- case 'week':
34
- return {
35
- current: "created_at >= unixepoch('now', '-7 days')",
36
- previous: "created_at >= unixepoch('now', '-14 days') AND created_at < unixepoch('now', '-7 days')",
37
- }
38
- case 'month':
39
- return {
40
- current: "created_at >= unixepoch('now', '-1 month')",
41
- previous: "created_at >= unixepoch('now', '-2 months') AND created_at < unixepoch('now', '-1 month')",
42
- }
43
- case 'year':
44
- return {
45
- current: "created_at >= unixepoch('now', '-1 year')",
46
- previous: "created_at >= unixepoch('now', '-2 years') AND created_at < unixepoch('now', '-1 year')",
47
- }
48
- case 'all':
49
- return { current: '1=1', previous: '1=0' }
50
- }
51
- }
52
-
53
- const SYSTEM_COLUMNS = new Set(['created_at', 'updated_at', 'status', 'id', 'slug'])
54
-
55
- // In v0.4.0 alias = column name. Validate against seed to prevent injection.
56
- function resolveColumnExpr(seed: Seed, alias: string): string {
57
- if (SYSTEM_COLUMNS.has(alias)) return alias
58
- const branch = seed.branches.find(b => b.alias === alias)
59
- return branch ? branch.alias : 'id'
60
- }
61
-
62
- function buildAggregateExpr(seed: Seed, formula: AggregateFormula): string {
63
- switch (formula.op) {
64
- case 'count':
65
- return 'COUNT(*)'
66
- case 'sum':
67
- return `SUM(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
68
- case 'avg':
69
- return `AVG(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
70
- case 'min':
71
- return `MIN(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
72
- case 'max':
73
- return `MAX(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
74
- case 'countWhere': {
75
- const expr = resolveColumnExpr(seed, formula.column)
76
- const val = formula.value
77
- if (val === null) return `COUNT(CASE WHEN ${expr} IS NULL THEN 1 END)`
78
- if (typeof val === 'boolean') return `COUNT(CASE WHEN ${expr} = ${val ? 1 : 0} THEN 1 END)`
79
- if (typeof val === 'number') return `COUNT(CASE WHEN CAST(${expr} AS REAL) = ${val} THEN 1 END)`
80
- return `COUNT(CASE WHEN ${expr} = '${String(val).replace(/'/g, "''")}' THEN 1 END)`
81
- }
82
- case 'percentageOf': {
83
- const num = resolveColumnExpr(seed, formula.numeratorColumn)
84
- const den = resolveColumnExpr(seed, formula.denominatorColumn)
85
- return `CASE WHEN SUM(CAST(${den} AS REAL)) = 0 THEN 0 ELSE (SUM(CAST(${num} AS REAL)) * 100.0 / SUM(CAST(${den} AS REAL))) END`
86
- }
87
- }
88
- }
89
-
90
- function parseFormula(raw: string | undefined): AggregateFormula | null {
91
- if (!raw) return null
92
- try {
93
- const parsed = JSON.parse(raw) as unknown
94
- if (typeof parsed !== 'object' || parsed === null || !('op' in parsed)) return null
95
- return parsed as AggregateFormula
96
- } catch {
97
- return null
98
- }
99
- }
100
-
101
- function parseWindow(raw: string | undefined): TimeWindow {
102
- if (raw === 'week' || raw === 'month' || raw === 'year' || raw === 'all') return raw
103
- return 'all'
104
- }
105
-
106
- function error(status: number, title: string, detail: string) {
107
- return { type: 'about:blank', title, status, detail }
108
- }
109
-
110
- // ─── Routes ─────────────────────────────────────────────────────────────────
111
-
112
- widgetApp.get('/aggregate/:seed', async (c) => {
113
- const seedSlug = c.req.param('seed')
114
- const seed = c.get('getSeed')(seedSlug)
115
- if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
116
-
117
- const formula = parseFormula(c.req.query('formula'))
118
- if (!formula) return c.json(error(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
119
-
120
- const window = parseWindow(c.req.query('window'))
121
- const aggExpr = buildAggregateExpr(seed, formula)
122
-
123
- try {
124
- const row = await c.env.DB.prepare(
125
- `SELECT ${aggExpr} as value FROM content_${seed.slug} WHERE (${timeWindowSql(window)})`
126
- ).first<{ value: number | null }>()
127
- return c.json({ value: row?.value ?? 0, window })
128
- } catch (err) {
129
- console.error('[widget/aggregate] DB error:', err)
130
- return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
131
- }
132
- })
133
-
134
- widgetApp.get('/growth/:seed', async (c) => {
135
- const seedSlug = c.req.param('seed')
136
- const seed = c.get('getSeed')(seedSlug)
137
- if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
138
-
139
- const formula = parseFormula(c.req.query('formula'))
140
- if (!formula) return c.json(error(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
141
-
142
- const window = parseWindow(c.req.query('window'))
143
- const { current: currentSql, previous: previousSql } = previousWindowSql(window)
144
- const aggExpr = buildAggregateExpr(seed, formula)
145
- const table = `content_${seed.slug}`
146
-
147
- try {
148
- const [currentRow, previousRow] = await Promise.all([
149
- c.env.DB.prepare(`SELECT ${aggExpr} as value FROM ${table} WHERE (${currentSql})`).first<{ value: number | null }>(),
150
- c.env.DB.prepare(`SELECT ${aggExpr} as value FROM ${table} WHERE (${previousSql})`).first<{ value: number | null }>(),
151
- ])
152
-
153
- const current = currentRow?.value ?? 0
154
- const previous = previousRow?.value ?? 0
155
-
156
- let percentageChange = 0
157
- let trend: 'up' | 'down' | 'flat' = 'flat'
158
-
159
- if (previous !== 0) {
160
- percentageChange = Math.round(((current - previous) / Math.abs(previous)) * 1000) / 10
161
- } else if (current > 0) {
162
- percentageChange = 100
163
- }
164
-
165
- if (percentageChange > 0) trend = 'up'
166
- else if (percentageChange < 0) trend = 'down'
167
-
168
- return c.json({ current, previous, percentageChange, trend })
169
- } catch (err) {
170
- console.error('[widget/growth] DB error:', err)
171
- return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
172
- }
173
- })
174
-
175
- widgetApp.get('/leaderboard/:seed', async (c) => {
176
- const seedSlug = c.req.param('seed')
177
- const seed = c.get('getSeed')(seedSlug)
178
- if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
179
-
180
- const scoreColumn = c.req.query('scoreColumn')
181
- if (!scoreColumn) return c.json(error(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
182
-
183
- const limitRaw = parseInt(c.req.query('limit') ?? '10', 10)
184
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 10
185
- const orderDir = c.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
186
- const scoreExpr = resolveColumnExpr(seed, scoreColumn)
187
- const labelCol = resolveColumnExpr(seed, seed.displayNameAlias)
188
- const table = `content_${seed.slug}`
189
-
190
- try {
191
- const rows = await c.env.DB.prepare(
192
- `SELECT id, ${labelCol} as label, ${scoreExpr} as score
193
- FROM ${table}
194
- WHERE ${scoreExpr} IS NOT NULL
195
- ORDER BY CAST(${scoreExpr} AS REAL) ${orderDir}
196
- LIMIT ?`
197
- ).bind(limit).all<{ id: string; label: string | null; score: number | string | null }>()
198
-
199
- const entries = (rows.results ?? []).map(row => ({
200
- id: row.id,
201
- label: row.label ?? row.id,
202
- score: row.score ?? 0,
203
- }))
204
-
205
- return c.json(entries)
206
- } catch (err) {
207
- console.error('[widget/leaderboard] DB error:', err)
208
- return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
209
- }
210
- })
211
-
212
- widgetApp.get('/list/:seed', async (c) => {
213
- const seedSlug = c.req.param('seed')
214
- const seed = c.get('getSeed')(seedSlug)
215
- if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
216
-
217
- const { DB } = c.env
218
- const query = c.req.query()
219
- const table = `content_${seed.slug}`
220
-
221
- const limitRaw = parseInt(query.limit ?? '25', 10)
222
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 25
223
- const offsetRaw = parseInt(query.offset ?? '0', 10)
224
- const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
225
-
226
- const search = query.search?.trim() ?? ''
227
- const displayCol = resolveColumnExpr(seed, seed.displayNameAlias)
228
-
229
- const conditions: string[] = []
230
- const bindings: unknown[] = []
231
-
232
- if (search) {
233
- conditions.push(`${displayCol} LIKE ?`)
234
- bindings.push(`%${search}%`)
235
- }
236
-
237
- if (query.filters) {
238
- try {
239
- const rawFilters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
240
- for (const f of rawFilters) {
241
- const expr = resolveColumnExpr(seed, f.column)
242
- switch (f.op) {
243
- case '=':
244
- case 'eq': conditions.push(`${expr} = ?`); bindings.push(f.value); break
245
- case '!=':
246
- case 'neq': conditions.push(`${expr} != ?`); bindings.push(f.value); break
247
- case 'like': conditions.push(`${expr} LIKE ?`); bindings.push(f.value); break
248
- case '>':
249
- case 'gt': conditions.push(`CAST(${expr} AS REAL) > ?`); bindings.push(f.value); break
250
- case '<':
251
- case 'lt': conditions.push(`CAST(${expr} AS REAL) < ?`); bindings.push(f.value); break
252
- }
253
- }
254
- } catch {
255
- return c.json(error(400, 'Bad Request', 'Invalid filters JSON'), 400)
256
- }
257
- }
258
-
259
- const orderByAlias = query.orderBy ?? ''
260
- const orderDir = query.orderDir === 'desc' ? 'DESC' : 'ASC'
261
- const orderExpr = orderByAlias ? resolveColumnExpr(seed, orderByAlias) : 'created_at'
262
- const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
263
-
264
- try {
265
- const [countRow, listRows] = await Promise.all([
266
- DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
267
- .bind(...bindings)
268
- .first<{ total: number }>(),
269
- DB.prepare(
270
- `SELECT id, slug, status, created_at, updated_at, ${seed.branches.map(b => b.alias).join(', ')}
271
- FROM ${table} ${whereSql} ORDER BY ${orderExpr} ${orderDir} LIMIT ? OFFSET ?`
272
- )
273
- .bind(...bindings, limit, offset)
274
- .all<Record<string, unknown>>(),
275
- ])
276
-
277
- const entries = (listRows.results ?? []).map(row => {
278
- const data: Record<string, unknown> = {}
279
- for (const branch of seed.branches) {
280
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
281
- }
282
- return {
283
- id: row.id as string,
284
- slug: (row.slug as string | null) ?? '',
285
- status: row.status as string,
286
- createdAt: (row.created_at as number) ?? 0,
287
- updatedAt: (row.updated_at as number) ?? 0,
288
- ...data,
289
- }
290
- })
291
-
292
- return c.json({ entries, total: countRow?.total ?? 0 })
293
- } catch (err) {
294
- console.error('[widget/list] DB error:', err)
295
- return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
296
- }
297
- })
298
-
299
- widgetApp.get('/timeseries/:seed', async (c) => {
300
- const seedSlug = c.req.param('seed')
301
- const seed = c.get('getSeed')(seedSlug)
302
- if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
303
-
304
- const valueColumn = c.req.query('valueColumn')
305
- const groupColumn = c.req.query('groupColumn') ?? 'created_at'
306
- const formulaOp = c.req.query('formula') ?? 'count'
307
- const window = parseWindow(c.req.query('window'))
308
- const table = `content_${seed.slug}`
309
-
310
- if (!valueColumn && formulaOp !== 'count') {
311
- return c.json(error(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
312
- }
313
-
314
- const groupExpr = resolveColumnExpr(seed, groupColumn)
315
- const dateBucketExpr = `strftime('%Y-%m-%d', ${groupExpr === 'created_at' ? groupExpr : `CAST(${groupExpr} AS INTEGER)`}, 'unixepoch')`
316
-
317
- let aggExpr: string
318
- if (formulaOp === 'count') {
319
- aggExpr = 'COUNT(*)'
320
- } else if (formulaOp === 'sum' && valueColumn) {
321
- aggExpr = `SUM(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
322
- } else if (formulaOp === 'avg' && valueColumn) {
323
- aggExpr = `AVG(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
324
- } else {
325
- return c.json(error(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
326
- }
327
-
328
- try {
329
- const rows = await c.env.DB.prepare(
330
- `SELECT ${dateBucketExpr} as label, ${aggExpr} as value
331
- FROM ${table}
332
- WHERE (${timeWindowSql(window)})
333
- GROUP BY ${dateBucketExpr}
334
- ORDER BY ${dateBucketExpr} ASC`
335
- ).bind().all<{ label: string | null; value: number | null }>()
336
-
337
- const points = (rows.results ?? []).map(row => ({
338
- label: row.label ?? '',
339
- value: row.value ?? 0,
340
- }))
341
-
342
- return c.json({ points })
343
- } catch (err) {
344
- console.error('[widget/timeseries] DB error:', err)
345
- return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
346
- }
347
- })
348
-
349
- export { widgetApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import { deserializeFromDb } from '@beechcms/core'
4
+ import type { AggregateFormula, TimeWindow } from '@beechcms/core'
5
+ import type { Env, Variables } from './types'
6
+
7
+ const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
+
9
+ const DEFAULT_LEADERBOARD_LIMIT = 10
10
+ const MAXIMUM_LEADERBOARD_LIMIT = 100
11
+ const DEFAULT_LIST_LIMIT = 25
12
+ const MAXIMUM_LIST_LIMIT = 100
13
+
14
+ function parseFormula(raw: string | undefined): AggregateFormula | null {
15
+ if (!raw) return null
16
+ try {
17
+ const parsed = JSON.parse(raw) as unknown
18
+ if (typeof parsed !== 'object' || parsed === null || !('op' in parsed)) return null
19
+ return parsed as AggregateFormula
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ function parseWindow(raw: string | undefined): TimeWindow {
26
+ if (raw === 'week' || raw === 'month' || raw === 'year' || raw === 'all') return raw
27
+ return 'all'
28
+ }
29
+
30
+ function parseBoundedInt(raw: string | undefined, fallback: number, maximum: number, minimum = 1): number {
31
+ const parsed = parseInt(raw ?? String(fallback), 10)
32
+ if (!Number.isFinite(parsed) || parsed < minimum) return fallback
33
+ return Math.min(parsed, maximum)
34
+ }
35
+
36
+ function problem(status: number, title: string, detail: string) {
37
+ return { type: 'about:blank', title, status, detail }
38
+ }
39
+
40
+ function isUnsafeColumnError(error: unknown): boolean {
41
+ return error instanceof Error && error.message === 'UNSAFE_COLUMN'
42
+ }
43
+
44
+ widgetApp.get('/aggregate/:seed', async (context) => {
45
+ const seedSlug = context.req.param('seed')
46
+ const seed = context.get('getSeed')(seedSlug)
47
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
48
+
49
+ const formula = parseFormula(context.req.query('formula'))
50
+ if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
51
+
52
+ const window = parseWindow(context.req.query('window'))
53
+
54
+ try {
55
+ const value = await context.get('widgetRepository').aggregate(seed, formula, window)
56
+ return context.json({ value, window })
57
+ } catch (error) {
58
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
59
+ console.error('[widget/aggregate] DB error:', error)
60
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
61
+ }
62
+ })
63
+
64
+ widgetApp.get('/growth/:seed', async (context) => {
65
+ const seedSlug = context.req.param('seed')
66
+ const seed = context.get('getSeed')(seedSlug)
67
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
68
+
69
+ const formula = parseFormula(context.req.query('formula'))
70
+ if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
71
+
72
+ const window = parseWindow(context.req.query('window'))
73
+
74
+ try {
75
+ const { currentValue, previousValue } = await context
76
+ .get('widgetRepository')
77
+ .growth(seed, formula, window)
78
+
79
+ let percentageChange = 0
80
+ if (previousValue !== 0) {
81
+ percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
82
+ } else if (currentValue > 0) {
83
+ percentageChange = 100
84
+ }
85
+
86
+ let trend: 'up' | 'down' | 'flat' = 'flat'
87
+ if (percentageChange > 0) trend = 'up'
88
+ else if (percentageChange < 0) trend = 'down'
89
+
90
+ return context.json({ current: currentValue, previous: previousValue, percentageChange, trend })
91
+ } catch (error) {
92
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
93
+ console.error('[widget/growth] DB error:', error)
94
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
95
+ }
96
+ })
97
+
98
+ widgetApp.get('/leaderboard/:seed', async (context) => {
99
+ const seedSlug = context.req.param('seed')
100
+ const seed = context.get('getSeed')(seedSlug)
101
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
102
+
103
+ const scoreColumn = context.req.query('scoreColumn')
104
+ if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
105
+
106
+ const limit = parseBoundedInt(context.req.query('limit'), DEFAULT_LEADERBOARD_LIMIT, MAXIMUM_LEADERBOARD_LIMIT)
107
+ const orderDirection: 'ASC' | 'DESC' = context.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
108
+
109
+ try {
110
+ const entries = await context
111
+ .get('widgetRepository')
112
+ .leaderboard(seed, { scoreColumn, limit, orderDirection })
113
+ return context.json(entries)
114
+ } catch (error) {
115
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
116
+ console.error('[widget/leaderboard] DB error:', error)
117
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
118
+ }
119
+ })
120
+
121
+ widgetApp.get('/list/:seed', async (context) => {
122
+ const seedSlug = context.req.param('seed')
123
+ const seed = context.get('getSeed')(seedSlug)
124
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
125
+
126
+ const query = context.req.query()
127
+ const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
128
+ const offsetRaw = parseInt(query.offset ?? '0', 10)
129
+ const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
130
+ const search = query.search?.trim() || undefined
131
+ const orderByColumn = query.orderBy || undefined
132
+ const orderDirection: 'ASC' | 'DESC' = query.orderDir === 'desc' ? 'DESC' : 'ASC'
133
+
134
+ let filters: Array<{ column: string; op: string; value: unknown }> | undefined
135
+ if (query.filters) {
136
+ try {
137
+ filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
138
+ } catch {
139
+ return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
140
+ }
141
+ }
142
+
143
+ try {
144
+ const { entries, totalCount } = await context.get('widgetRepository').list(seed, {
145
+ limit,
146
+ offset,
147
+ search,
148
+ filters,
149
+ orderByColumn,
150
+ orderDirection,
151
+ })
152
+
153
+ const deserializedEntries = entries.map(row => {
154
+ const data: Record<string, unknown> = {}
155
+ for (const branch of seed.branches) {
156
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
157
+ }
158
+ return {
159
+ id: row.id as string,
160
+ slug: (row.slug as string | null) ?? '',
161
+ status: row.status as string,
162
+ createdAt: (row.created_at as number) ?? 0,
163
+ updatedAt: (row.updated_at as number) ?? 0,
164
+ ...data,
165
+ }
166
+ })
167
+
168
+ return context.json({ entries: deserializedEntries, total: totalCount })
169
+ } catch (error) {
170
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
171
+ console.error('[widget/list] DB error:', error)
172
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
173
+ }
174
+ })
175
+
176
+ widgetApp.get('/timeseries/:seed', async (context) => {
177
+ const seedSlug = context.req.param('seed')
178
+ const seed = context.get('getSeed')(seedSlug)
179
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
180
+
181
+ const valueColumn = context.req.query('valueColumn')
182
+ const groupColumn = context.req.query('groupColumn') ?? 'created_at'
183
+ const formulaOp = context.req.query('formula') ?? 'count'
184
+ const window = parseWindow(context.req.query('window'))
185
+
186
+ if (!valueColumn && formulaOp !== 'count') {
187
+ return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
188
+ }
189
+
190
+ let formula: AggregateFormula
191
+ if (formulaOp === 'count') formula = { op: 'count' }
192
+ else if (formulaOp === 'sum' && valueColumn) formula = { op: 'sum', column: valueColumn }
193
+ else if (formulaOp === 'avg' && valueColumn) formula = { op: 'avg', column: valueColumn }
194
+ else return context.json(problem(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
195
+
196
+ try {
197
+ const points = await context
198
+ .get('widgetRepository')
199
+ .timeseries(seed, formula, window, groupColumn)
200
+ return context.json({ points })
201
+ } catch (error) {
202
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
203
+ console.error('[widget/timeseries] DB error:', error)
204
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
205
+ }
206
+ })
207
+
208
+ export { widgetApp }