@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2

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 (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
package/src/widget.ts CHANGED
@@ -1,212 +1,251 @@
1
- // SPDX-License-Identifier: BUSL-1.1
2
- // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
- // See LICENSE in the repository root for license terms.
4
-
5
- /// <reference types="@cloudflare/workers-types" />
6
- import { Hono } from 'hono'
7
- import { deserializeFromDb } from '@beechcms/core'
8
- import type { AggregateFormula, TimeWindow } from '@beechcms/core'
9
- import type { Env, Variables } from './types'
10
-
11
- const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
-
13
- const DEFAULT_LEADERBOARD_LIMIT = 10
14
- const MAXIMUM_LEADERBOARD_LIMIT = 100
15
- const DEFAULT_LIST_LIMIT = 25
16
- const MAXIMUM_LIST_LIMIT = 100
17
-
18
- function parseFormula(raw: string | undefined): AggregateFormula | null {
19
- if (!raw) return null
20
- try {
21
- const parsed = JSON.parse(raw) as unknown
22
- if (typeof parsed !== 'object' || parsed === null || !('op' in parsed)) return null
23
- return parsed as AggregateFormula
24
- } catch {
25
- return null
26
- }
27
- }
28
-
29
- function parseWindow(raw: string | undefined): TimeWindow {
30
- if (raw === 'week' || raw === 'month' || raw === 'year' || raw === 'all') return raw
31
- return 'all'
32
- }
33
-
34
- function parseBoundedInt(raw: string | undefined, fallback: number, maximum: number, minimum = 1): number {
35
- const parsed = Number.parseInt(raw ?? String(fallback), 10)
36
- if (!Number.isFinite(parsed) || parsed < minimum) return fallback
37
- return Math.min(parsed, maximum)
38
- }
39
-
40
- function problem(status: number, title: string, detail: string) {
41
- return { type: 'about:blank', title, status, detail }
42
- }
43
-
44
- function isUnsafeColumnError(error: unknown): boolean {
45
- return error instanceof Error && error.message === 'UNSAFE_COLUMN'
46
- }
47
-
48
- widgetApp.get('/aggregate/:seed', async (context) => {
49
- const seedSlug = context.req.param('seed')
50
- const seed = context.get('getSeed')(seedSlug)
51
- if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
52
-
53
- const formula = parseFormula(context.req.query('formula'))
54
- if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
55
-
56
- const window = parseWindow(context.req.query('window'))
57
-
58
- try {
59
- const value = await context.get('widgetRepository').aggregate(seed, formula, window)
60
- return context.json({ value, window })
61
- } catch (error) {
62
- if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
63
- console.error('[widget/aggregate] DB error:', error)
64
- return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
65
- }
66
- })
67
-
68
- widgetApp.get('/growth/:seed', async (context) => {
69
- const seedSlug = context.req.param('seed')
70
- const seed = context.get('getSeed')(seedSlug)
71
- if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
72
-
73
- const formula = parseFormula(context.req.query('formula'))
74
- if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
75
-
76
- const window = parseWindow(context.req.query('window'))
77
-
78
- try {
79
- const { currentValue, previousValue } = await context
80
- .get('widgetRepository')
81
- .growth(seed, formula, window)
82
-
83
- let percentageChange = 0
84
- if (previousValue !== 0) {
85
- percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
86
- } else if (currentValue > 0) {
87
- percentageChange = 100
88
- }
89
-
90
- let trend: 'up' | 'down' | 'flat' = 'flat'
91
- if (percentageChange > 0) trend = 'up'
92
- else if (percentageChange < 0) trend = 'down'
93
-
94
- return context.json({ current: currentValue, previous: previousValue, percentageChange, trend })
95
- } catch (error) {
96
- if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
97
- console.error('[widget/growth] DB error:', error)
98
- return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
99
- }
100
- })
101
-
102
- widgetApp.get('/leaderboard/:seed', async (context) => {
103
- const seedSlug = context.req.param('seed')
104
- const seed = context.get('getSeed')(seedSlug)
105
- if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
106
-
107
- const scoreColumn = context.req.query('scoreColumn')
108
- if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
109
-
110
- const limit = parseBoundedInt(context.req.query('limit'), DEFAULT_LEADERBOARD_LIMIT, MAXIMUM_LEADERBOARD_LIMIT)
111
- const orderDirection: 'ASC' | 'DESC' = context.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
112
-
113
- try {
114
- const entries = await context
115
- .get('widgetRepository')
116
- .leaderboard(seed, { scoreColumn, limit, orderDirection })
117
- return context.json(entries)
118
- } catch (error) {
119
- if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
120
- console.error('[widget/leaderboard] DB error:', error)
121
- return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
122
- }
123
- })
124
-
125
- widgetApp.get('/list/:seed', async (context) => {
126
- const seedSlug = context.req.param('seed')
127
- const seed = context.get('getSeed')(seedSlug)
128
- if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
129
-
130
- const query = context.req.query()
131
- const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
132
- const offsetRaw = Number.parseInt(query.offset ?? '0', 10)
133
- const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
134
- const search = query.search?.trim() || undefined
135
- const orderByColumn = query.orderBy || undefined
136
- const orderDirection: 'ASC' | 'DESC' = query.orderDir === 'desc' ? 'DESC' : 'ASC'
137
-
138
- let filters: Array<{ column: string; op: string; value: unknown }> | undefined
139
- if (query.filters) {
140
- try {
141
- filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
142
- } catch {
143
- return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
144
- }
145
- }
146
-
147
- try {
148
- const { entries, totalCount } = await context.get('widgetRepository').list(seed, {
149
- limit,
150
- offset,
151
- search,
152
- filters,
153
- orderByColumn,
154
- orderDirection,
155
- })
156
-
157
- const deserializedEntries = entries.map(row => {
158
- const data: Record<string, unknown> = {}
159
- for (const branch of seed.branches) {
160
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
161
- }
162
- return {
163
- id: row.id as string,
164
- slug: (row.slug as string | null) ?? '',
165
- status: row.status as string,
166
- createdAt: (row.created_at as number) ?? 0,
167
- updatedAt: (row.updated_at as number) ?? 0,
168
- ...data,
169
- }
170
- })
171
-
172
- return context.json({ entries: deserializedEntries, total: totalCount })
173
- } catch (error) {
174
- if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
175
- console.error('[widget/list] DB error:', error)
176
- return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
177
- }
178
- })
179
-
180
- widgetApp.get('/timeseries/:seed', async (context) => {
181
- const seedSlug = context.req.param('seed')
182
- const seed = context.get('getSeed')(seedSlug)
183
- if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
184
-
185
- const valueColumn = context.req.query('valueColumn')
186
- const groupColumn = context.req.query('groupColumn') ?? 'created_at'
187
- const formulaOp = context.req.query('formula') ?? 'count'
188
- const window = parseWindow(context.req.query('window'))
189
-
190
- if (!valueColumn && formulaOp !== 'count') {
191
- return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
192
- }
193
-
194
- let formula: AggregateFormula
195
- if (formulaOp === 'count') formula = { op: 'count' }
196
- else if (formulaOp === 'sum' && valueColumn) formula = { op: 'sum', column: valueColumn }
197
- else if (formulaOp === 'avg' && valueColumn) formula = { op: 'avg', column: valueColumn }
198
- else return context.json(problem(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
199
-
200
- try {
201
- const points = await context
202
- .get('widgetRepository')
203
- .timeseries(seed, formula, window, groupColumn)
204
- return context.json({ points })
205
- } catch (error) {
206
- if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
207
- console.error('[widget/timeseries] DB error:', error)
208
- return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
209
- }
210
- })
211
-
212
- export { widgetApp }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /// <reference types="@cloudflare/workers-types" />
6
+ import { Hono } from 'hono'
7
+ import { deserializeFromDb } from '@beechcms/core'
8
+ import type { AggregateFormula, TimeWindow, WidgetWindow } from '@beechcms/core'
9
+ import type { Env, Variables } from './types'
10
+
11
+ const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
+
13
+ const DEFAULT_LEADERBOARD_LIMIT = 10
14
+ const MAXIMUM_LEADERBOARD_LIMIT = 100
15
+ const DEFAULT_LIST_LIMIT = 25
16
+ const MAXIMUM_LIST_LIMIT = 100
17
+ const DEFAULT_DISTRIBUTION_LIMIT = 8
18
+ const MAXIMUM_DISTRIBUTION_LIMIT = 24
19
+
20
+ function parseFormula(raw: string | undefined): AggregateFormula | null {
21
+ if (!raw) return null
22
+ try {
23
+ const parsed = JSON.parse(raw) as unknown
24
+ if (typeof parsed !== 'object' || parsed === null || !('op' in parsed)) return null
25
+ return parsed as AggregateFormula
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function parseWindow(raw: string | undefined): TimeWindow {
32
+ if (raw === 'week' || raw === 'month' || raw === 'year' || raw === 'all') return raw
33
+ return 'all'
34
+ }
35
+
36
+ /**
37
+ * Resolves the effective query window: an explicit `from`/`to` unix-second
38
+ * range takes priority over the `window` preset, allowing the dashboard to
39
+ * pin widgets to a custom date reference.
40
+ */
41
+ function parseWidgetWindow(query: { window?: string; from?: string; to?: string }): WidgetWindow {
42
+ const from = Number.parseInt(query.from ?? '', 10)
43
+ const to = Number.parseInt(query.to ?? '', 10)
44
+ if (Number.isFinite(from) && Number.isFinite(to) && from <= to) {
45
+ return { from, to }
46
+ }
47
+ return parseWindow(query.window)
48
+ }
49
+
50
+ function parseBoundedInt(raw: string | undefined, fallback: number, maximum: number, minimum = 1): number {
51
+ const parsed = Number.parseInt(raw ?? String(fallback), 10)
52
+ if (!Number.isFinite(parsed) || parsed < minimum) return fallback
53
+ return Math.min(parsed, maximum)
54
+ }
55
+
56
+ function problem(status: number, title: string, detail: string) {
57
+ return { type: 'about:blank', title, status, detail }
58
+ }
59
+
60
+ function isUnsafeColumnError(error: unknown): boolean {
61
+ return error instanceof Error && error.message === 'UNSAFE_COLUMN'
62
+ }
63
+
64
+ widgetApp.get('/aggregate/:seed', async (context) => {
65
+ const seedSlug = context.req.param('seed')
66
+ const seed = context.get('getSeed')(seedSlug)
67
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
68
+
69
+ const formula = parseFormula(context.req.query('formula'))
70
+ if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
71
+
72
+ const window = parseWidgetWindow(context.req.query())
73
+
74
+ try {
75
+ const value = await context.get('widgetRepository').aggregate(seed, formula, window)
76
+ return context.json({ value, window })
77
+ } catch (error) {
78
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
79
+ console.error('[widget/aggregate] DB error:', error)
80
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
81
+ }
82
+ })
83
+
84
+ widgetApp.get('/growth/:seed', async (context) => {
85
+ const seedSlug = context.req.param('seed')
86
+ const seed = context.get('getSeed')(seedSlug)
87
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
88
+
89
+ const formula = parseFormula(context.req.query('formula'))
90
+ if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
91
+
92
+ const window = parseWidgetWindow(context.req.query())
93
+
94
+ try {
95
+ const { currentValue, previousValue } = await context
96
+ .get('widgetRepository')
97
+ .growth(seed, formula, window)
98
+
99
+ let percentageChange = 0
100
+ if (previousValue !== 0) {
101
+ percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
102
+ } else if (currentValue > 0) {
103
+ percentageChange = 100
104
+ }
105
+
106
+ let trend: 'up' | 'down' | 'flat' = 'flat'
107
+ if (percentageChange > 0) trend = 'up'
108
+ else if (percentageChange < 0) trend = 'down'
109
+
110
+ return context.json({ current: currentValue, previous: previousValue, percentageChange, trend })
111
+ } catch (error) {
112
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
113
+ console.error('[widget/growth] DB error:', error)
114
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
115
+ }
116
+ })
117
+
118
+ widgetApp.get('/leaderboard/:seed', async (context) => {
119
+ const seedSlug = context.req.param('seed')
120
+ const seed = context.get('getSeed')(seedSlug)
121
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
122
+
123
+ const scoreColumn = context.req.query('scoreColumn')
124
+ if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
125
+
126
+ const limit = parseBoundedInt(context.req.query('limit'), DEFAULT_LEADERBOARD_LIMIT, MAXIMUM_LEADERBOARD_LIMIT)
127
+ const orderDirection: 'ASC' | 'DESC' = context.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
128
+
129
+ try {
130
+ const entries = await context
131
+ .get('widgetRepository')
132
+ .leaderboard(seed, { scoreColumn, limit, orderDirection })
133
+ return context.json(entries)
134
+ } catch (error) {
135
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
136
+ console.error('[widget/leaderboard] DB error:', error)
137
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
138
+ }
139
+ })
140
+
141
+ widgetApp.get('/list/:seed', async (context) => {
142
+ const seedSlug = context.req.param('seed')
143
+ const seed = context.get('getSeed')(seedSlug)
144
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
145
+
146
+ const query = context.req.query()
147
+ const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
148
+ const offsetRaw = Number.parseInt(query.offset ?? '0', 10)
149
+ const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
150
+ const search = query.search?.trim() || undefined
151
+ const orderByColumn = query.orderBy || undefined
152
+ const orderDirection: 'ASC' | 'DESC' = query.orderDir === 'desc' ? 'DESC' : 'ASC'
153
+
154
+ let filters: Array<{ column: string; op: string; value: unknown }> | undefined
155
+ if (query.filters) {
156
+ try {
157
+ filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
158
+ } catch {
159
+ return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
160
+ }
161
+ }
162
+
163
+ try {
164
+ const { entries, totalCount } = await context.get('widgetRepository').list(seed, {
165
+ limit,
166
+ offset,
167
+ search,
168
+ filters,
169
+ orderByColumn,
170
+ orderDirection,
171
+ })
172
+
173
+ const deserializedEntries = entries.map(row => {
174
+ const data: Record<string, unknown> = {}
175
+ for (const branch of seed.branches) {
176
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
177
+ }
178
+ return {
179
+ id: row.id as string,
180
+ slug: (row.slug as string | null) ?? '',
181
+ status: row.status as string,
182
+ createdAt: (row.created_at as number) ?? 0,
183
+ updatedAt: (row.updated_at as number) ?? 0,
184
+ ...data,
185
+ }
186
+ })
187
+
188
+ return context.json({ entries: deserializedEntries, total: totalCount })
189
+ } catch (error) {
190
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
191
+ console.error('[widget/list] DB error:', error)
192
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
193
+ }
194
+ })
195
+
196
+ widgetApp.get('/timeseries/:seed', async (context) => {
197
+ const seedSlug = context.req.param('seed')
198
+ const seed = context.get('getSeed')(seedSlug)
199
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
200
+
201
+ const valueColumn = context.req.query('valueColumn')
202
+ const groupColumn = context.req.query('groupColumn') ?? 'created_at'
203
+ const formulaOp = context.req.query('formula') ?? 'count'
204
+ const window = parseWidgetWindow(context.req.query())
205
+
206
+ if (!valueColumn && formulaOp !== 'count') {
207
+ return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
208
+ }
209
+
210
+ let formula: AggregateFormula
211
+ if (formulaOp === 'count') formula = { op: 'count' }
212
+ else if (formulaOp === 'sum' && valueColumn) formula = { op: 'sum', column: valueColumn }
213
+ else if (formulaOp === 'avg' && valueColumn) formula = { op: 'avg', column: valueColumn }
214
+ else return context.json(problem(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
215
+
216
+ try {
217
+ const points = await context
218
+ .get('widgetRepository')
219
+ .timeseries(seed, formula, window, groupColumn)
220
+ return context.json({ points })
221
+ } catch (error) {
222
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
223
+ console.error('[widget/timeseries] DB error:', error)
224
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
225
+ }
226
+ })
227
+
228
+ widgetApp.get('/distribution/:seed', async (context) => {
229
+ const seedSlug = context.req.param('seed')
230
+ const seed = context.get('getSeed')(seedSlug)
231
+ if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
232
+
233
+ const column = context.req.query('column')
234
+ if (!column) return context.json(problem(400, 'Bad Request', 'Missing column parameter'), 400)
235
+
236
+ const window = parseWidgetWindow(context.req.query())
237
+ const limit = parseBoundedInt(context.req.query('limit'), DEFAULT_DISTRIBUTION_LIMIT, MAXIMUM_DISTRIBUTION_LIMIT)
238
+
239
+ try {
240
+ const slices = await context
241
+ .get('widgetRepository')
242
+ .distribution(seed, column, window, limit)
243
+ return context.json({ slices })
244
+ } catch (error) {
245
+ if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
246
+ console.error('[widget/distribution] DB error:', error)
247
+ return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
248
+ }
249
+ })
250
+
251
+ export { widgetApp }