@beechcms/api 0.4.0 → 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 (86) hide show
  1. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  2. package/assets/dashboard/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/auth/bcrypt-hash-provider.ts +20 -0
  5. package/src/auth/constants.ts +3 -3
  6. package/src/auth/generate-refresh-token.test.ts +19 -0
  7. package/src/auth/hash-provider.test.ts +46 -0
  8. package/src/auth/in-memory-hash-provider.ts +13 -0
  9. package/src/auth/jose-token-service.ts +55 -0
  10. package/src/auth/login.test.ts +92 -0
  11. package/src/auth/login.ts +15 -32
  12. package/src/auth/refresh.ts +0 -122
  13. package/src/auth/static-token-service.ts +18 -0
  14. package/src/auth/token-service.test.ts +82 -0
  15. package/src/factory.ts +70 -78
  16. package/src/features/content/handlers/create.ts +14 -10
  17. package/src/features/content/handlers/delete.ts +13 -9
  18. package/src/features/content/handlers/update.ts +13 -9
  19. package/src/features/draft/draft.handler.ts +23 -12
  20. package/src/features/notifications/notifications.handler.ts +25 -54
  21. package/src/features/password-reset/request.ts +17 -41
  22. package/src/features/password-reset/reset.ts +18 -54
  23. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  24. package/src/features/schema/schema.handler.ts +1 -1
  25. package/src/features/settings/settings.handler.ts +62 -175
  26. package/src/features/setup/index.ts +12 -17
  27. package/src/features/stats/stats.handler.ts +110 -138
  28. package/src/middleware/auth-providers.middleware.ts +32 -0
  29. package/src/middleware/observability.middleware.ts +52 -0
  30. package/src/middleware/rate-limit.middleware.ts +41 -0
  31. package/src/middleware/repository.middleware.ts +41 -5
  32. package/src/middleware.ts +15 -35
  33. package/src/public/public-add.ts +5 -15
  34. package/src/public/public-edit.ts +4 -3
  35. package/src/public/public-read.ts +3 -3
  36. package/src/public/public-routes.ts +2 -2
  37. package/src/public/query-builder.test.ts +220 -0
  38. package/src/public/rate-limit-middleware.ts +7 -19
  39. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  40. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  41. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  42. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  43. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  44. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  45. package/src/search-utils.test.ts +207 -0
  46. package/src/search-utils.ts +18 -1
  47. package/src/search.ts +24 -35
  48. package/src/shared/apply-policies.test.ts +77 -0
  49. package/src/shared/background-notification-service.test.ts +58 -0
  50. package/src/shared/background-notification-service.ts +48 -0
  51. package/src/shared/content-utils.test.ts +161 -0
  52. package/src/shared/content.repository.d1.test.ts +312 -0
  53. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  54. package/src/shared/d1-activity-log.repository.ts +101 -0
  55. package/src/shared/d1-activity-logger.test.ts +82 -0
  56. package/src/shared/d1-activity-logger.ts +63 -0
  57. package/src/shared/d1-analytics.repository.test.ts +74 -0
  58. package/src/shared/d1-analytics.repository.ts +81 -0
  59. package/src/shared/d1-content-scan.repository.ts +29 -0
  60. package/src/shared/d1-notification.repository.test.ts +124 -0
  61. package/src/shared/d1-notification.repository.ts +114 -0
  62. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  63. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  64. package/src/shared/d1-search.repository.test.ts +83 -0
  65. package/src/shared/d1-search.repository.ts +84 -0
  66. package/src/shared/d1-session.repository.test.ts +121 -0
  67. package/src/shared/d1-session.repository.ts +98 -0
  68. package/src/shared/d1-user.repository.test.ts +147 -0
  69. package/src/shared/d1-user.repository.ts +109 -0
  70. package/src/shared/d1-widget.repository.test.ts +217 -0
  71. package/src/shared/d1-widget.repository.ts +337 -0
  72. package/src/shared/fixed-clock.ts +21 -0
  73. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  74. package/src/shared/in-memory-activity-logger.ts +15 -0
  75. package/src/shared/in-memory-notification-service.ts +15 -0
  76. package/src/shared/media.repository.d1.test.ts +103 -0
  77. package/src/shared/media.repository.d1.ts +1 -1
  78. package/src/shared/request-utils.ts +22 -0
  79. package/src/shared/sequential-id-generator.ts +22 -0
  80. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  81. package/src/types.ts +20 -3
  82. package/src/upload.ts +14 -7
  83. package/src/widget.ts +112 -253
  84. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  85. package/src/shared/activity-logger.ts +0 -79
  86. package/src/shared/notification-service.ts +0 -56
package/src/widget.ts CHANGED
@@ -1,91 +1,15 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
3
  import { deserializeFromDb } from '@beechcms/core'
4
- import type { Seed } from '@beechcms/core'
4
+ import type { AggregateFormula, TimeWindow } from '@beechcms/core'
5
5
  import type { Env, Variables } from './types'
6
6
 
7
7
  const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
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
- }
9
+ const DEFAULT_LEADERBOARD_LIMIT = 10
10
+ const MAXIMUM_LEADERBOARD_LIMIT = 100
11
+ const DEFAULT_LIST_LIMIT = 25
12
+ const MAXIMUM_LIST_LIMIT = 100
89
13
 
90
14
  function parseFormula(raw: string | undefined): AggregateFormula | null {
91
15
  if (!raw) return null
@@ -103,178 +27,130 @@ function parseWindow(raw: string | undefined): TimeWindow {
103
27
  return 'all'
104
28
  }
105
29
 
106
- function error(status: number, title: string, detail: string) {
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) {
107
37
  return { type: 'about:blank', title, status, detail }
108
38
  }
109
39
 
110
- // ─── Routes ─────────────────────────────────────────────────────────────────
40
+ function isUnsafeColumnError(error: unknown): boolean {
41
+ return error instanceof Error && error.message === 'UNSAFE_COLUMN'
42
+ }
111
43
 
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)
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)
116
48
 
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)
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)
119
51
 
120
- const window = parseWindow(c.req.query('window'))
121
- const aggExpr = buildAggregateExpr(seed, formula)
52
+ const window = parseWindow(context.req.query('window'))
122
53
 
123
54
  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)
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)
131
61
  }
132
62
  })
133
63
 
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)
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)
138
68
 
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)
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)
141
71
 
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}`
72
+ const window = parseWindow(context.req.query('window'))
146
73
 
147
74
  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
75
+ const { currentValue, previousValue } = await context
76
+ .get('widgetRepository')
77
+ .growth(seed, formula, window)
155
78
 
156
79
  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) {
80
+ if (previousValue !== 0) {
81
+ percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
82
+ } else if (currentValue > 0) {
162
83
  percentageChange = 100
163
84
  }
164
85
 
86
+ let trend: 'up' | 'down' | 'flat' = 'flat'
165
87
  if (percentageChange > 0) trend = 'up'
166
88
  else if (percentageChange < 0) trend = 'down'
167
89
 
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)
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)
172
95
  }
173
96
  })
174
97
 
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)
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)
179
102
 
180
- const scoreColumn = c.req.query('scoreColumn')
181
- if (!scoreColumn) return c.json(error(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
103
+ const scoreColumn = context.req.query('scoreColumn')
104
+ if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
182
105
 
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}`
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'
189
108
 
190
109
  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)
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)
209
118
  }
210
119
  })
211
120
 
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}`
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)
220
125
 
221
- const limitRaw = parseInt(query.limit ?? '25', 10)
222
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 25
126
+ const query = context.req.query()
127
+ const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
223
128
  const offsetRaw = parseInt(query.offset ?? '0', 10)
224
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'
225
133
 
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
-
134
+ let filters: Array<{ column: string; op: string; value: unknown }> | undefined
237
135
  if (query.filters) {
238
136
  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
- }
137
+ filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
254
138
  } catch {
255
- return c.json(error(400, 'Bad Request', 'Invalid filters JSON'), 400)
139
+ return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
256
140
  }
257
141
  }
258
142
 
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
143
  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 => {
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 => {
278
154
  const data: Record<string, unknown> = {}
279
155
  for (const branch of seed.branches) {
280
156
  data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
@@ -289,60 +165,43 @@ widgetApp.get('/list/:seed', async (c) => {
289
165
  }
290
166
  })
291
167
 
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)
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)
296
173
  }
297
174
  })
298
175
 
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)
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)
303
180
 
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}`
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'))
309
185
 
310
186
  if (!valueColumn && formulaOp !== 'count') {
311
- return c.json(error(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
187
+ return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
312
188
  }
313
189
 
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
- }
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)
327
195
 
328
196
  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)
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)
346
205
  }
347
206
  })
348
207