@beechcms/api 0.4.0-preview.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -249
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
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 }
|