@beechcms/api 0.4.0-preview.11 → 0.4.0-preview.13

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/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -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/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -267
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-9Ch2xWJr.js +0 -554
  86. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
package/src/widget.ts CHANGED
@@ -1,349 +1,349 @@
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 { 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 }