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