@beechcms/api 0.4.0-preview.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 (61) hide show
  1. package/README.md +21 -0
  2. package/migrations/0000_v040_base.sql +213 -0
  3. package/package.json +36 -0
  4. package/src/auth/constants.ts +10 -0
  5. package/src/auth/login.ts +91 -0
  6. package/src/auth/refresh.ts +127 -0
  7. package/src/content.ts +502 -0
  8. package/src/factory.ts +56 -0
  9. package/src/features/draft/draft.handler.ts +198 -0
  10. package/src/features/draft/draft.test.ts +315 -0
  11. package/src/features/draft/index.ts +1 -0
  12. package/src/features/email/email.provider.ts +38 -0
  13. package/src/features/email/email.service.ts +80 -0
  14. package/src/features/email/email.types.ts +98 -0
  15. package/src/features/email/index.ts +28 -0
  16. package/src/features/email/providers/resend.ts +63 -0
  17. package/src/features/email/templates/password-changed.ts +59 -0
  18. package/src/features/email/templates/password-reset.ts +64 -0
  19. package/src/features/email/templates/shell.ts +93 -0
  20. package/src/features/notifications/index.ts +1 -0
  21. package/src/features/notifications/notifications.handler.ts +88 -0
  22. package/src/features/password-reset/index.ts +15 -0
  23. package/src/features/password-reset/request.ts +88 -0
  24. package/src/features/password-reset/reset.ts +110 -0
  25. package/src/features/rotate-field/index.ts +1 -0
  26. package/src/features/rotate-field/rotate-field.handler.ts +82 -0
  27. package/src/features/rotate-field/rotate-field.schema.ts +9 -0
  28. package/src/features/rotate-field/rotate-field.test.ts +297 -0
  29. package/src/features/settings/settings.handler.ts +249 -0
  30. package/src/features/setup/index.ts +59 -0
  31. package/src/features/stats/index.ts +1 -0
  32. package/src/features/stats/stats.handler.ts +395 -0
  33. package/src/index.ts +344 -0
  34. package/src/media-utils.ts +78 -0
  35. package/src/middleware.ts +67 -0
  36. package/src/public/access-policy.ts +23 -0
  37. package/src/public/api-key-middleware.ts +53 -0
  38. package/src/public/index.ts +12 -0
  39. package/src/public/problem-details.ts +42 -0
  40. package/src/public/public-add.ts +183 -0
  41. package/src/public/public-edit.ts +183 -0
  42. package/src/public/public-errors.ts +15 -0
  43. package/src/public/public-read.ts +217 -0
  44. package/src/public/public-routes.ts +31 -0
  45. package/src/public/query-builder.ts +241 -0
  46. package/src/public/rate-limit-middleware.ts +42 -0
  47. package/src/public/response-builder.ts +26 -0
  48. package/src/public/sanitize.ts +65 -0
  49. package/src/public/slug-utils.ts +14 -0
  50. package/src/search-utils.ts +188 -0
  51. package/src/search.ts +72 -0
  52. package/src/shared/activity-logger.ts +79 -0
  53. package/src/shared/apply-policies.ts +63 -0
  54. package/src/shared/content-utils.ts +108 -0
  55. package/src/shared/fts-sync.ts +4 -0
  56. package/src/shared/notification-service.ts +56 -0
  57. package/src/shared/query-utils.ts +137 -0
  58. package/src/shared/storage-utils.ts +36 -0
  59. package/src/types.ts +35 -0
  60. package/src/upload.ts +335 -0
  61. package/src/widget.ts +349 -0
package/src/upload.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Media Engine: upload e servizio file da Cloudflare R2.
3
+ *
4
+ * Usa l'API S3-compatibile (@aws-sdk/client-s3) con chiavi di accesso per
5
+ * portabilità e configurabilità. Le credenziali vanno in .dev.vars (locale)
6
+ * o wrangler secret (produzione).
7
+ *
8
+ * @see docs/media-engine.md
9
+ */
10
+ /// <reference types="@cloudflare/workers-types" />
11
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
12
+ import { Hono } from 'hono'
13
+ import { authMiddleware } from './middleware'
14
+ import { logActivity } from './shared/activity-logger'
15
+
16
+ /** Variabili d'ambiente per upload e media (R2 via S3 API) */
17
+ type UploadBindings = {
18
+ JWT_SECRET: string
19
+ MEDIA_BASE_URL?: string
20
+ ENV?: string
21
+ R2_ACCESS_KEY_ID?: string
22
+ R2_SECRET_ACCESS_KEY?: string
23
+ R2_ENDPOINT?: string
24
+ R2_BUCKET_NAME?: string
25
+ DB: D1Database
26
+ }
27
+
28
+ type Variables = {
29
+ jwtPayload: { sub: string; email?: string }
30
+ }
31
+
32
+ /** Prefissi MIME consentiti (immagini e PDF) */
33
+ const ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf']
34
+
35
+ /** Dimensione massima file: 5 MB */
36
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
37
+
38
+ type FileLike = {
39
+ name: string
40
+ type: string
41
+ size: number
42
+ arrayBuffer: () => Promise<ArrayBuffer>
43
+ }
44
+
45
+ function isFileLike(value: unknown): value is FileLike {
46
+ if (!value || typeof value === 'string') return false
47
+ const v = value as Record<string, unknown>
48
+
49
+ return (
50
+ typeof v.name === 'string' &&
51
+ typeof v.type === 'string' &&
52
+ typeof v.size === 'number' &&
53
+ typeof v.arrayBuffer === 'function'
54
+ )
55
+ }
56
+
57
+ /** Sanitizza il nome file: rimuove caratteri non sicuri, mantiene estensione */
58
+ function sanitizeFilename(name: string): string {
59
+ const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
60
+ return base || 'file'
61
+ }
62
+
63
+ /** Genera chiave univoca per R2 (timestamp-sanitized-name) */
64
+ function generateObjectKey(originalName: string): string {
65
+ const timestamp = Math.floor(Date.now() / 1000)
66
+ const sanitized = sanitizeFilename(originalName)
67
+ return `${timestamp}-${sanitized}`
68
+ }
69
+
70
+ /** Restituisce l'URL base per costruire gli URL pubblici dei media */
71
+ function getMediaBaseUrl(c: { req: { url: string }; env: UploadBindings }): string {
72
+ const base = c.env.MEDIA_BASE_URL?.trim()
73
+ if (base) return base.replace(/\/$/, '')
74
+ return new URL(c.req.url).origin
75
+ }
76
+
77
+ /** Crea client S3 per R2 */
78
+ export function createR2Client(env: UploadBindings): S3Client {
79
+ if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.R2_ENDPOINT) {
80
+ throw new Error('R2 credentials not configured')
81
+ }
82
+ return new S3Client({
83
+ region: 'auto',
84
+ endpoint: env.R2_ENDPOINT,
85
+ credentials: {
86
+ accessKeyId: env.R2_ACCESS_KEY_ID!,
87
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
88
+ },
89
+ forcePathStyle: true,
90
+ })
91
+ }
92
+
93
+ /** Env minimale per delete R2 (solo R2_* e ENV) */
94
+ export type R2DeleteEnv = Pick<
95
+ UploadBindings,
96
+ 'R2_ACCESS_KEY_ID' | 'R2_SECRET_ACCESS_KEY' | 'R2_ENDPOINT' | 'R2_BUCKET_NAME' | 'ENV' | 'DB'
97
+ >
98
+
99
+ /**
100
+ * Elimina oggetti da R2 per le chiavi date.
101
+ * Usato alla cancellazione entry per rimuovere i file associati da R2.
102
+ */
103
+ export async function deleteR2Objects(
104
+ env: R2DeleteEnv,
105
+ objectKeys: string[]
106
+ ): Promise<void> {
107
+ const isR2Configured =
108
+ env.R2_ACCESS_KEY_ID &&
109
+ env.R2_SECRET_ACCESS_KEY &&
110
+ env.R2_ENDPOINT &&
111
+ env.R2_BUCKET_NAME
112
+
113
+ if (!isR2Configured || objectKeys.length === 0) {
114
+ return
115
+ }
116
+
117
+ const s3Client = createR2Client(env as UploadBindings)
118
+ for (const objectKey of objectKeys) {
119
+ try {
120
+ // Ottieni la dimensione prima della cancellazione per aggiornare il contatore
121
+ let fileSize = 0
122
+ try {
123
+ const head = await s3Client.send(
124
+ new HeadObjectCommand({
125
+ Bucket: env.R2_BUCKET_NAME,
126
+ Key: objectKey,
127
+ })
128
+ )
129
+ fileSize = head.ContentLength ?? 0
130
+ } catch (headErr) {
131
+ // Se il file non esiste già su R2, fileSize resta 0
132
+ if (env.ENV !== 'production') {
133
+ console.warn('R2 head failed for key (skip size update)', objectKey, headErr)
134
+ }
135
+ }
136
+
137
+ await s3Client.send(
138
+ new DeleteObjectCommand({
139
+ Bucket: env.R2_BUCKET_NAME,
140
+ Key: objectKey,
141
+ })
142
+ )
143
+
144
+ // Decrementa contatore storage in D1 se abbiamo trovato la dimensione
145
+ if (fileSize > 0) {
146
+ await env.DB.prepare(
147
+ "UPDATE system_stats SET value = MAX(0, CAST(value AS INTEGER) - ?) WHERE id = 'total_storage_bytes'"
148
+ ).bind(fileSize).run()
149
+ }
150
+
151
+ // Rimuovi dalla media library
152
+ await env.DB.prepare('DELETE FROM media_objects WHERE key = ?').bind(objectKey).run()
153
+ } catch (err) {
154
+ if (env.ENV !== 'production') {
155
+ console.warn('R2 delete failed for key', objectKey, err)
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ export const uploadRoutes = new Hono<{
162
+ Bindings: UploadBindings
163
+ Variables: Variables
164
+ }>()
165
+
166
+ /** POST /upload - Carica file su R2, restituisce URL pubblico */
167
+ uploadRoutes.post('/upload', async (c, next) => {
168
+ await authMiddleware(c.env.JWT_SECRET)(c, next)
169
+ }, async (c) => {
170
+ try {
171
+ const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
172
+ if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
173
+ return c.json({ error: 'R2 not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME' }, 500)
174
+ }
175
+
176
+ const contentType = c.req.header('Content-Type') ?? ''
177
+ if (!contentType.includes('multipart/form-data')) {
178
+ return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
179
+ }
180
+
181
+ const formData = await c.req.formData()
182
+ const fileEntry = formData.get('file')
183
+ // In Cloudflare/Workers il value può essere `string` o un oggetto (File/Blob-like).
184
+ // Usiamo un guard sulle proprietà richieste.
185
+ if (!isFileLike(fileEntry)) {
186
+ return c.json({ error: 'No file provided. Use field name "file"' }, 400)
187
+ }
188
+ const file = fileEntry
189
+
190
+ const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
191
+ if (!mimeOk) {
192
+ return c.json(
193
+ { error: 'File type not allowed. Allowed: images and PDF' },
194
+ 400
195
+ )
196
+ }
197
+
198
+ if (file.size > MAX_FILE_SIZE_BYTES) {
199
+ return c.json({ error: 'File too large. Max 5MB' }, 400)
200
+ }
201
+
202
+ const objectKey = generateObjectKey(file.name)
203
+ const client = createR2Client(c.env)
204
+
205
+ const body = await file.arrayBuffer()
206
+ await client.send(
207
+ new PutObjectCommand({
208
+ Bucket: R2_BUCKET_NAME,
209
+ Key: objectKey,
210
+ Body: new Uint8Array(body),
211
+ ContentType: file.type,
212
+ })
213
+ )
214
+
215
+ // Aggiorna contatore storage in D1
216
+ let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
217
+ try {
218
+ executionCtx = c.executionCtx
219
+ } catch {
220
+ // In ambiente di test Hono lancia se non presente
221
+ }
222
+
223
+ const uploadedBy = c.var.jwtPayload?.sub ?? ''
224
+ if (executionCtx) {
225
+ executionCtx.waitUntil((async () => {
226
+ try {
227
+ await c.env.DB.prepare(
228
+ "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
229
+ ).bind(file.size).run()
230
+ } catch (err) {
231
+ console.error('Failed to update storage stats on upload:', err)
232
+ }
233
+ try {
234
+ await c.env.DB.prepare(
235
+ 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
236
+ ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run()
237
+ } catch (err) {
238
+ console.error('Failed to track media_objects on upload:', err)
239
+ }
240
+ })())
241
+ } else {
242
+ // Fallback sync
243
+ c.env.DB.prepare(
244
+ "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
245
+ ).bind(file.size).run().catch(err => console.error('Failed to update storage stats on upload (sync fallback):', err))
246
+ c.env.DB.prepare(
247
+ 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
248
+ ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run().catch(err => console.error('Failed to track media_objects (sync fallback):', err))
249
+ }
250
+
251
+ const baseUrl = getMediaBaseUrl(c)
252
+ const publicUrl = `${baseUrl}/api/media/${encodeURIComponent(objectKey)}`
253
+
254
+ logActivity(c, {
255
+ action: 'upload',
256
+ entityType: 'media',
257
+ entityId: objectKey,
258
+ details: { name: file.name, size: file.size, type: file.type }
259
+ })
260
+
261
+ return c.json({ url: publicUrl }, 200)
262
+ } catch (err) {
263
+ if (c.env.ENV !== 'production') {
264
+ console.error('Upload error:', err)
265
+ }
266
+ return c.json({ error: 'Upload failed' }, 500)
267
+ }
268
+ })
269
+
270
+ /** DELETE /upload/:key - Elimina un file da R2 */
271
+ uploadRoutes.delete('/:key', async (c, next) => {
272
+ await authMiddleware(c.env.JWT_SECRET)(c, next)
273
+ }, async (c) => {
274
+ const key = c.req.param('key')
275
+ if (!key) return c.json({ error: 'Missing key' }, 400)
276
+
277
+ await deleteR2Objects(c.env, [decodeURIComponent(key)])
278
+ return c.json({ success: true }, 200)
279
+ })
280
+
281
+ /**
282
+ * Serve un file da R2. Route pubblica (senza auth) per permettere
283
+ * il caricamento delle immagini nei tag <img>.
284
+ */
285
+ export async function serveMediaHandler(
286
+ c: { env: UploadBindings; req: { param: (key: string) => string } }
287
+ ): Promise<Response> {
288
+ const key = c.req.param('key')
289
+ if (!key) {
290
+ return new Response(JSON.stringify({ error: 'Missing key' }), {
291
+ status: 400,
292
+ headers: { 'Content-Type': 'application/json' },
293
+ })
294
+ }
295
+
296
+ const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
297
+ if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
298
+ return new Response(JSON.stringify({ error: 'R2 not configured' }), {
299
+ status: 500,
300
+ headers: { 'Content-Type': 'application/json' },
301
+ })
302
+ }
303
+
304
+ try {
305
+ const client = createR2Client(c.env)
306
+ const response = await client.send(
307
+ new GetObjectCommand({
308
+ Bucket: R2_BUCKET_NAME,
309
+ Key: decodeURIComponent(key),
310
+ })
311
+ )
312
+
313
+ if (!response.Body) {
314
+ return new Response(JSON.stringify({ error: 'Not found' }), {
315
+ status: 404,
316
+ headers: { 'Content-Type': 'application/json' },
317
+ })
318
+ }
319
+
320
+ const headers = new Headers()
321
+ const ct = response.ContentType ?? 'application/octet-stream'
322
+ headers.set('Content-Type', ct)
323
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable')
324
+
325
+ return new Response(response.Body as ReadableStream, {
326
+ status: 200,
327
+ headers,
328
+ })
329
+ } catch {
330
+ return new Response(JSON.stringify({ error: 'Not found' }), {
331
+ status: 404,
332
+ headers: { 'Content-Type': 'application/json' },
333
+ })
334
+ }
335
+ }
package/src/widget.ts ADDED
@@ -0,0 +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 }