@beechcms/api 0.4.0 → 0.4.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 (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1SystemStatsRepository } from './system-stats.repository.d1'
3
+
4
+ function makeMockDb(firstResult: unknown = null) {
5
+ const runMock = vi.fn().mockResolvedValue({ success: true })
6
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
7
+ // bind() returns the same statement interface for chained calls.
8
+ // The statement also exposes first() directly for queries without bind (e.g. getStorageUsage).
9
+ const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
10
+ const stmt = { bind: bindMock, run: runMock, first: firstMock }
11
+ const prepareMock = vi.fn(() => stmt)
12
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock }
13
+ }
14
+
15
+ describe('D1SystemStatsRepository', () => {
16
+ describe('incrementStorage', () => {
17
+ it('calls UPDATE system_stats with the byte count', async () => {
18
+ const { db, prepareMock, bindMock } = makeMockDb()
19
+ await new D1SystemStatsRepository(db).incrementStorage(512)
20
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
21
+ expect(bindMock).toHaveBeenCalledWith(512)
22
+ })
23
+ })
24
+
25
+ describe('decrementStorage', () => {
26
+ it('calls UPDATE system_stats with MAX(0, ...) and the byte count', async () => {
27
+ const { db, prepareMock, bindMock } = makeMockDb()
28
+ await new D1SystemStatsRepository(db).decrementStorage(256)
29
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('MAX(0'))
30
+ expect(bindMock).toHaveBeenCalledWith(256)
31
+ })
32
+ })
33
+
34
+ describe('setStorage', () => {
35
+ it('calls UPDATE system_stats with the stringified byte count', async () => {
36
+ const { db, prepareMock, bindMock } = makeMockDb()
37
+ await new D1SystemStatsRepository(db).setStorage(1024)
38
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
39
+ expect(bindMock).toHaveBeenCalledWith('1024')
40
+ })
41
+ })
42
+
43
+ describe('getStorageUsage', () => {
44
+ it('parses and returns the integer value from D1', async () => {
45
+ const { db } = makeMockDb({ value: '2048' })
46
+ expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(2048)
47
+ })
48
+
49
+ it('returns 0 when D1 returns null (stat not initialised)', async () => {
50
+ const { db } = makeMockDb(null)
51
+ expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(0)
52
+ })
53
+ })
54
+ })
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
2
+ import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository, IHashProvider, ITokenService, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogger, IActivityLogRepository, INotificationRepository, INotificationService, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, ISeedRegistry, IClock, IIdGenerator, IAutomationRunner, IAutomationRepository, IScheduler } from '@beechcms/core'
3
+ import type { IRateLimiterRegistry } from './middleware/rate-limit.middleware'
3
4
 
4
5
  export interface Env {
5
6
  DB: D1Database
@@ -22,6 +23,7 @@ export interface Env {
22
23
  MEDIA_BASE_URL?: string
23
24
  MEDIA_CDN_URL?: string
24
25
  RESEND_API_KEY?: string
26
+ EMAIL_API_KEY?: string
25
27
  APP_URL?: string
26
28
  EMAIL_FROM?: string
27
29
  FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
@@ -33,14 +35,33 @@ export interface Env {
33
35
  }
34
36
 
35
37
  export interface Variables {
36
- jwtPayload: { sub: string; email?: string }
38
+ jwtPayload: { sub: string; email?: string; name?: string | null }
37
39
  getSeed: (slug: string) => Seed | null
38
- seedRegistry: Record<string, Seed>
40
+ seedRegistry: ISeedRegistry
39
41
  repository: ContentRepository
40
42
  idempotencyRepository: IdempotencyRepository
41
43
  bucket: BeechBucket
42
44
  mediaRepository: MediaRepository
43
45
  systemStatsRepository: SystemStatsRepository
46
+ hashProvider: IHashProvider
47
+ tokenService: ITokenService
48
+ userRepository: IUserRepository
49
+ sessionRepository: ISessionRepository
50
+ passwordResetTokenRepository: IPasswordResetTokenRepository
51
+ rateLimiters: IRateLimiterRegistry
52
+ activityLogger: IActivityLogger
53
+ activityLogRepository: IActivityLogRepository
54
+ notificationRepository: INotificationRepository
55
+ notificationService: INotificationService
56
+ widgetRepository: IWidgetRepository
57
+ searchRepository: ISearchRepository
58
+ analyticsRepository: IAnalyticsRepository
59
+ contentScanRepository: IContentScanRepository
60
+ clock: IClock
61
+ idGenerator: IIdGenerator
62
+ automationRepository: IAutomationRepository
63
+ automationRunner: IAutomationRunner
64
+ scheduler: IScheduler
44
65
  }
45
66
 
46
67
  export type AppEnv = { Bindings: Env; Variables: Variables }
package/src/upload.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  * e facilità di sviluppo locale.
6
6
  */
7
7
  import { Hono } from 'hono'
8
- import { logActivity } from './shared/activity-logger'
9
8
  import { AppEnv } from './types'
10
9
 
11
10
  /** Prefissi MIME consentiti (immagini e PDF) */
@@ -133,12 +132,20 @@ uploadRoutes.post('/upload', async (c) => {
133
132
 
134
133
  const publicUrl = bucket.getUrl(objectKey)
135
134
 
136
- logActivity(c, {
137
- action: 'upload',
138
- entityType: 'media',
139
- entityId: objectKey,
140
- details: { name: file.name, size: file.size, type: file.type }
141
- })
135
+ const jwtPayload = c.get('jwtPayload')
136
+ if (jwtPayload) {
137
+ c.get('activityLogger').log({
138
+ action: 'upload',
139
+ entityType: 'media',
140
+ entityId: objectKey,
141
+ details: { name: file.name, size: file.size, type: file.type },
142
+ actor: {
143
+ id: jwtPayload.sub,
144
+ email: jwtPayload.email ?? 'unknown',
145
+ name: jwtPayload.name ?? null,
146
+ },
147
+ })
148
+ }
142
149
 
143
150
  return c.json({ url: publicUrl }, 200)
144
151
  } catch (err) {
@@ -151,7 +158,7 @@ uploadRoutes.post('/upload', async (c) => {
151
158
  uploadRoutes.delete('/upload/:key', async (c) => {
152
159
  const key = c.req.param('key')
153
160
  if (!key) return c.json({ error: 'Missing key' }, 400)
154
-
161
+
155
162
  await deleteR2Objects(c, [decodeURIComponent(key)])
156
163
  return c.json({ success: true }, 200)
157
164
  })
@@ -171,9 +178,10 @@ export async function serveMediaHandler(c: any): Promise<Response> {
171
178
  const headers = new Headers()
172
179
  headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
173
180
  headers.set('Cache-Control', 'public, max-age=31536000, immutable')
174
-
181
+
175
182
  return new Response(object.body, { status: 200, headers })
176
183
  } catch (err) {
184
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
177
185
  return new Response('Internal error', { status: 500 })
178
186
  }
179
187
  }
package/src/widget.ts CHANGED
@@ -1,91 +1,15 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
3
  import { deserializeFromDb } from '@beechcms/core'
4
- import type { Seed } from '@beechcms/core'
4
+ import type { AggregateFormula, TimeWindow } from '@beechcms/core'
5
5
  import type { Env, Variables } from './types'
6
6
 
7
7
  const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
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
- }
9
+ const DEFAULT_LEADERBOARD_LIMIT = 10
10
+ const MAXIMUM_LEADERBOARD_LIMIT = 100
11
+ const DEFAULT_LIST_LIMIT = 25
12
+ const MAXIMUM_LIST_LIMIT = 100
89
13
 
90
14
  function parseFormula(raw: string | undefined): AggregateFormula | null {
91
15
  if (!raw) return null
@@ -103,178 +27,130 @@ function parseWindow(raw: string | undefined): TimeWindow {
103
27
  return 'all'
104
28
  }
105
29
 
106
- function error(status: number, title: string, detail: string) {
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) {
107
37
  return { type: 'about:blank', title, status, detail }
108
38
  }
109
39
 
110
- // ─── Routes ─────────────────────────────────────────────────────────────────
40
+ function isUnsafeColumnError(error: unknown): boolean {
41
+ return error instanceof Error && error.message === 'UNSAFE_COLUMN'
42
+ }
111
43
 
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)
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)
116
48
 
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)
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)
119
51
 
120
- const window = parseWindow(c.req.query('window'))
121
- const aggExpr = buildAggregateExpr(seed, formula)
52
+ const window = parseWindow(context.req.query('window'))
122
53
 
123
54
  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)
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)
131
61
  }
132
62
  })
133
63
 
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)
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)
138
68
 
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)
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)
141
71
 
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}`
72
+ const window = parseWindow(context.req.query('window'))
146
73
 
147
74
  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
75
+ const { currentValue, previousValue } = await context
76
+ .get('widgetRepository')
77
+ .growth(seed, formula, window)
155
78
 
156
79
  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) {
80
+ if (previousValue !== 0) {
81
+ percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
82
+ } else if (currentValue > 0) {
162
83
  percentageChange = 100
163
84
  }
164
85
 
86
+ let trend: 'up' | 'down' | 'flat' = 'flat'
165
87
  if (percentageChange > 0) trend = 'up'
166
88
  else if (percentageChange < 0) trend = 'down'
167
89
 
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)
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)
172
95
  }
173
96
  })
174
97
 
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)
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)
179
102
 
180
- const scoreColumn = c.req.query('scoreColumn')
181
- if (!scoreColumn) return c.json(error(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
103
+ const scoreColumn = context.req.query('scoreColumn')
104
+ if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
182
105
 
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}`
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'
189
108
 
190
109
  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)
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)
209
118
  }
210
119
  })
211
120
 
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}`
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)
220
125
 
221
- const limitRaw = parseInt(query.limit ?? '25', 10)
222
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 25
126
+ const query = context.req.query()
127
+ const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
223
128
  const offsetRaw = parseInt(query.offset ?? '0', 10)
224
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'
225
133
 
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
-
134
+ let filters: Array<{ column: string; op: string; value: unknown }> | undefined
237
135
  if (query.filters) {
238
136
  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
- }
137
+ filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
254
138
  } catch {
255
- return c.json(error(400, 'Bad Request', 'Invalid filters JSON'), 400)
139
+ return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
256
140
  }
257
141
  }
258
142
 
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
143
  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 => {
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 => {
278
154
  const data: Record<string, unknown> = {}
279
155
  for (const branch of seed.branches) {
280
156
  data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
@@ -289,60 +165,43 @@ widgetApp.get('/list/:seed', async (c) => {
289
165
  }
290
166
  })
291
167
 
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)
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)
296
173
  }
297
174
  })
298
175
 
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)
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)
303
180
 
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}`
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'))
309
185
 
310
186
  if (!valueColumn && formulaOp !== 'count') {
311
- return c.json(error(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
187
+ return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
312
188
  }
313
189
 
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
- }
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)
327
195
 
328
196
  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)
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)
346
205
  }
347
206
  })
348
207