@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
@@ -1,420 +1,420 @@
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 { SystemClock } from '@beechcms/core'
8
- import type { Env, Variables } from '../../types'
9
- import { publicProblem } from '../../public/problem-details'
10
- import { cleanStr } from '../../shared/query-utils'
11
-
12
- const DATABASE_ERROR = 'Database error'
13
- const INTERNAL_SERVER_ERROR = 'Internal Server Error'
14
-
15
- const SECONDS_PER_MINUTE = 60
16
- const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
17
- const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
18
- const HOURS_24_IN_SECONDS = 24 * SECONDS_PER_HOUR
19
- const DAYS_7_IN_SECONDS = 7 * SECONDS_PER_DAY
20
- const DAYS_30_IN_SECONDS = 30 * SECONDS_PER_DAY
21
- const MAX_MEDIA_SCAN = 1000
22
- const DEFAULT_LIMIT = 12
23
- const MAX_LIMIT = 100
24
- const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
25
- const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // 1M requests/month
26
-
27
- function getMimeType(extension: string): string {
28
- if (extension === 'pdf') {
29
- return 'application/pdf'
30
- }
31
- const type = extension === 'jpg' ? 'jpeg' : (extension || 'jpeg')
32
- return `image/${type}`
33
- }
34
-
35
- const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
36
-
37
- /**
38
- * GET /stats/media-library - Lists all files present in R2:
39
- * combines media_objects (tracked uploads) + /api/media/ URLs found in file columns of each seed
40
- */
41
- statsApp.get('/stats/media-library', async (context) => {
42
- try {
43
- const mediaRepository = context.get('mediaRepository')
44
- const limit = Math.min(Number.parseInt(context.req.query('limit') ?? String(DEFAULT_LIMIT), 10), MAX_LIMIT)
45
- const offset = Number.parseInt(context.req.query('offset') ?? '0', 10)
46
- const mediaBaseUrl = (context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '')) ?? new URL(context.req.url).origin
47
-
48
- // 1. Files tracked in the media library
49
- // Take the first MAX_MEDIA_SCAN for cross-scanning
50
- const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
51
-
52
- const trackedKeys = new Set<string>()
53
- const allItems: Array<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number; url: string }> = []
54
-
55
- for (const mediaRow of mediaRows) {
56
- trackedKeys.add(mediaRow.key)
57
- allItems.push({ ...mediaRow, url: `${mediaBaseUrl}/api/media/${encodeURIComponent(mediaRow.key)}` })
58
- }
59
-
60
- // 2. /api/media/ URLs in the file columns of each seed (v0.4.0 — real columns)
61
- const seeds = context.get('seedRegistry').all()
62
- const contentScanRepository = context.get('contentScanRepository')
63
- const referencedKeysFromContent = await contentScanRepository.getReferencedMediaKeys(seeds)
64
-
65
- for (const key of referencedKeysFromContent) {
66
- if (trackedKeys.has(key)) continue
67
- trackedKeys.add(key)
68
- const filename = key.replace(/^\d+-/, '')
69
- const extension = filename.split('.').pop()?.toLowerCase() ?? ''
70
- const mimeType = getMimeType(extension)
71
- const createdAt = Number.parseInt(key.split('-')[0], 10) || 0
72
- allItems.push({
73
- key,
74
- filename,
75
- mime_type: mimeType,
76
- size_bytes: 0,
77
- created_at: createdAt,
78
- url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
79
- })
80
- }
81
-
82
- allItems.sort((a, b) => b.created_at - a.created_at)
83
- const total = allItems.length
84
- const paginatedItems = allItems.slice(offset, offset + limit)
85
-
86
- return context.json({ items: paginatedItems, total })
87
- } catch (error) {
88
- console.error('Media library error:', error)
89
- return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
90
- }
91
- })
92
-
93
- /**
94
- * GET /stats/unused-media - Finds media in media_objects not referenced in any file column
95
- */
96
- statsApp.get('/stats/unused-media', async (context) => {
97
- try {
98
- const mediaRepository = context.get('mediaRepository')
99
- const seeds = context.get('seedRegistry').all()
100
-
101
- // All tracked media keys
102
- const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
103
-
104
- if (mediaRows.length === 0) {
105
- return context.json({ items: [] })
106
- }
107
-
108
- // Collect all referenced keys in the file columns of each seed
109
- const contentScanRepository = context.get('contentScanRepository')
110
- const referencedKeys = await contentScanRepository.getReferencedMediaKeys(seeds)
111
-
112
- const unusedMedia = mediaRows.filter(mediaItem => !referencedKeys.has(mediaItem.key))
113
- return context.json({ items: unusedMedia })
114
- } catch (error) {
115
- console.error('Unused media error:', error)
116
- return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
117
- }
118
- })
119
-
120
- /**
121
- * GET /stats/setup-checklist - Project setup status for "Project Health" widget
122
- */
123
- statsApp.get('/stats/setup-checklist', async (context) => {
124
- try {
125
- const setupChecklistRepository = context.get('setupChecklistRepository')
126
- const seeds = context.get('seedRegistry').all()
127
-
128
- const existingTables = await setupChecklistRepository.getExistingTableNames()
129
-
130
- const systemTableNames = [
131
- 'users', 'refresh_tokens', 'media_objects',
132
- 'analytics', 'system_stats', 'activity_logs',
133
- ]
134
- const systemTablesOk = systemTableNames.every(name => existingTables.has(name))
135
-
136
- const seedsCount = seeds.length
137
- const contentTablesOk = seedsCount > 0 && seeds.every(seed => existingTables.has(`content_${seed.slug}`))
138
-
139
- let adminExists = false
140
- try {
141
- adminExists = (await setupChecklistRepository.countAdmins()) > 0
142
- } catch {
143
- // users table may not exist yet
144
- }
145
-
146
- let hasContent = false
147
- const firstSeedSlug = seeds[0]?.slug ?? null
148
- if (firstSeedSlug && existingTables.has(`content_${firstSeedSlug}`)) {
149
- try {
150
- hasContent = (await setupChecklistRepository.countEntriesInSeed(firstSeedSlug)) > 0
151
- } catch {
152
- // ignore
153
- }
154
- }
155
-
156
- return context.json({
157
- systemTablesOk,
158
- seedsCount,
159
- contentTablesOk,
160
- adminExists,
161
- hasContent,
162
- firstSeedSlug,
163
- })
164
- } catch (error) {
165
- console.error('Setup checklist error:', error)
166
- return context.json({ error: 'Failed to compute setup checklist' }, 500)
167
- }
168
- })
169
-
170
- /**
171
- * GET /stats/total - Global content statistics for dashboard
172
- */
173
- statsApp.get('/stats/total', async (context) => {
174
- try {
175
- const now = SystemClock.nowSeconds()
176
- const twentyFourHoursAgo = now - HOURS_24_IN_SECONDS
177
- const sevenDaysAgo = now - DAYS_7_IN_SECONDS
178
- const thirtyDaysAgo = now - DAYS_30_IN_SECONDS
179
-
180
- // Total: SUM of per-seed counts (current live entries)
181
- const seeds = context.get('seedRegistry').all()
182
- const widgetRepository = context.get('widgetRepository')
183
- const countResults = await Promise.all(
184
- seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
185
- )
186
- const totalEntriesCount = countResults.reduce((accumulator, count) => accumulator + count, 0)
187
-
188
- // today/week/month: create events in activity_logs (entity_type = 'content')
189
- const activityLogRepository = context.get('activityLogRepository')
190
- const [todayCount, weekCount, monthCount] = await Promise.all([
191
- activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: twentyFourHoursAgo }),
192
- activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: sevenDaysAgo }),
193
- activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: thirtyDaysAgo }),
194
- ])
195
-
196
- return context.json({
197
- total: totalEntriesCount,
198
- today: todayCount,
199
- week: weekCount,
200
- month: monthCount,
201
- })
202
- } catch (error) {
203
- console.error('Content stats error:', error)
204
- return publicProblem(context, {
205
- type: 'content-database-error',
206
- title: INTERNAL_SERVER_ERROR,
207
- status: 500,
208
- detail: DATABASE_ERROR,
209
- })
210
- }
211
- })
212
-
213
- /**
214
- * GET /stats/recent-activity - Latest activities registered in the system
215
- * No ETag on this endpoint: the activity feed must always be fresh after
216
- * every mutation. Cache-Control: no-store prevents the browser from keeping a
217
- * response that could be returned as a 304 stale.
218
- */
219
- statsApp.get('/stats/recent-activity', async (context) => {
220
- try {
221
- const slug = cleanStr(context.req.query('slug'))
222
-
223
- const entries = await context.get('activityLogRepository').list({
224
- entitySlug: slug ?? undefined,
225
- limit: 15,
226
- })
227
-
228
- // Preserve the legacy snake_case wire format consumed by the dashboard
229
- // recent-activity widget. The repository returns camelCase records,
230
- // so we reshape only on the way out.
231
- const activities = entries.map((entry) => ({
232
- id: entry.id,
233
- user_id: entry.userId,
234
- user_email: entry.userEmail,
235
- user_name: entry.userName,
236
- action: entry.action,
237
- entity_type: entry.entityType,
238
- entity_id: entry.entityId,
239
- entity_slug: entry.entitySlug,
240
- details: entry.details,
241
- created_at: entry.createdAt,
242
- }))
243
-
244
- context.header('Cache-Control', 'no-store')
245
-
246
- return context.json(activities)
247
- } catch (error) {
248
- console.error('Recent activity error:', error)
249
- return publicProblem(context, {
250
- type: 'content-database-error',
251
- title: INTERNAL_SERVER_ERROR,
252
- status: 500,
253
- detail: DATABASE_ERROR,
254
- })
255
- }
256
- })
257
-
258
- /**
259
- * GET /stats/health - System health status and Cloudflare quotas
260
- */
261
- statsApp.get('/stats/health', async (context) => {
262
- try {
263
- const systemStatsRepository = context.get('systemStatsRepository')
264
-
265
- // 1. Retrieve storage usage from repository (system_stats)
266
- let storageUsedBytes = 0
267
- try {
268
- storageUsedBytes = await systemStatsRepository.getStorageUsage()
269
- } catch (error) {
270
- console.warn('Health: Could not fetch storage stats from repo:', error)
271
- }
272
-
273
- // 2. Aggregate D1 requests (proxy for database health) - last 30 days
274
- const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
275
- const totalRequests = await context
276
- .get('analyticsRepository')
277
- .sumByMetric('requests', '', thirtyDaysAgo)
278
-
279
- const storagePercentage = Math.min(Math.round((storageUsedBytes / R2_STORAGE_LIMIT) * 1000) / 10, 100)
280
- const d1Percentage = Math.min(Math.round((totalRequests / D1_MONTHLY_REQUESTS_LIMIT) * 1000) / 10, 100)
281
-
282
- return context.json({
283
- storage: {
284
- used: storageUsedBytes,
285
- limit: R2_STORAGE_LIMIT,
286
- percentage: storagePercentage
287
- },
288
- database: {
289
- requests30d: totalRequests,
290
- limit: D1_MONTHLY_REQUESTS_LIMIT,
291
- percentage: d1Percentage
292
- },
293
- status: (storagePercentage < 90 && d1Percentage < 90) ? 'healthy' : 'warning',
294
- lastUpdate: SystemClock.nowSeconds()
295
- })
296
- } catch (error) {
297
- console.error('System health stats error:', error)
298
- return context.json({ error: 'Failed to calculate system health' }, 500)
299
- }
300
- })
301
-
302
- /**
303
- * GET /stats/cloudflare - Cloudflare-like metrics (Visitors, Requests, Bandwidth)
304
- */
305
- statsApp.get('/stats/cloudflare', async (context) => {
306
- try {
307
- const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
308
- const analyticsRepository = context.get('analyticsRepository')
309
-
310
- const [recordedRequests, recordedVisitors] = await Promise.all([
311
- analyticsRepository.sumByMetric('requests', '', thirtyDaysAgo),
312
- analyticsRepository.sumByMetric('visitors', '', thirtyDaysAgo),
313
- ])
314
-
315
- // Simulate some Cloudflare metrics not directly tracked for a premium feel
316
- const requestsCount = recordedRequests > 0 ? recordedRequests : Math.floor(Math.random() * 5000) + 1000
317
- const visitorsCount = recordedVisitors > 0 ? recordedVisitors : Math.floor(requestsCount / 12) + 1
318
- const bandwidthMB = Math.round((requestsCount * 0.15) * 10) / 10
319
-
320
- const systemStatsRepository = context.get('systemStatsRepository')
321
- let storageUsedBytes = 0
322
- try {
323
- storageUsedBytes = await systemStatsRepository.getStorageUsage()
324
- } catch (error) {
325
- console.warn('Could not fetch storage stats from repo:', error)
326
- }
327
-
328
- const storageUsedMB = Math.round((storageUsedBytes / (1024 * 1024)) * 10) / 10
329
- const storageLimitMB = 10 * 1024 // 10 GB Free Tier
330
-
331
- return context.json({
332
- visitors: {
333
- value: visitorsCount,
334
- trend: 12, // simulated growth %
335
- isPositive: true
336
- },
337
- requests: {
338
- value: requestsCount,
339
- trend: 8,
340
- isPositive: true
341
- },
342
- bandwidth: {
343
- value: bandwidthMB,
344
- unit: 'MB',
345
- trend: 5,
346
- isPositive: false
347
- },
348
- cacheRate: {
349
- value: 94.2,
350
- unit: '%',
351
- trend: 0.5,
352
- isPositive: true
353
- },
354
- storage: {
355
- used: storageUsedMB,
356
- limit: storageLimitMB,
357
- unit: 'MB',
358
- percentage: Math.round((storageUsedMB / storageLimitMB) * 1000) / 10
359
- }
360
- })
361
- } catch (error) {
362
- console.error('Cloudflare stats error:', error)
363
- return publicProblem(context, {
364
- type: 'content-database-error',
365
- title: INTERNAL_SERVER_ERROR,
366
- status: 500,
367
- detail: DATABASE_ERROR,
368
- })
369
- }
370
- })
371
-
372
- /**
373
- * GET /stats/breakdown - Content distribution for "Content Pulse" widget
374
- */
375
- statsApp.get('/stats/breakdown', async (context) => {
376
- try {
377
- const seeds = context.get('seedRegistry').all()
378
-
379
- const widgetRepository = context.get('widgetRepository')
380
- const counts = await Promise.all(
381
- seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
382
- )
383
-
384
- const breakdown = seeds.map((seed, index) => ({
385
- slug: seed.slug,
386
- label: seed.labelPlural || seed.label,
387
- count: counts[index] ?? 0,
388
- }))
389
-
390
- return context.json(breakdown)
391
- } catch (error) {
392
- console.error('Breakdown stats error:', error)
393
- return publicProblem(context, {
394
- type: 'content-database-error',
395
- title: INTERNAL_SERVER_ERROR,
396
- status: 500,
397
- detail: DATABASE_ERROR,
398
- })
399
- }
400
- })
401
-
402
- /**
403
- * POST /stats/storage/sync - Recalculates space occupied on R2 (expensive operation, use with caution)
404
- */
405
- statsApp.post('/stats/storage/sync', async (context) => {
406
- try {
407
- const bucket = context.get('bucket')
408
- const systemStatsRepository = context.get('systemStatsRepository')
409
-
410
- const realSize = await bucket.getTotalSize()
411
- await systemStatsRepository.setStorage(realSize)
412
-
413
- return context.json({ success: true, size: realSize })
414
- } catch (error) {
415
- console.error('Storage sync error:', error)
416
- return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
417
- }
418
- })
419
-
420
- export { statsApp }
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 { SystemClock } from '@beechcms/core'
8
+ import type { Env, Variables } from '../../types'
9
+ import { publicProblem } from '../../public/problem-details'
10
+ import { cleanStr } from '../../shared/query-utils'
11
+
12
+ const DATABASE_ERROR = 'Database error'
13
+ const INTERNAL_SERVER_ERROR = 'Internal Server Error'
14
+
15
+ const SECONDS_PER_MINUTE = 60
16
+ const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
17
+ const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
18
+ const HOURS_24_IN_SECONDS = 24 * SECONDS_PER_HOUR
19
+ const DAYS_7_IN_SECONDS = 7 * SECONDS_PER_DAY
20
+ const DAYS_30_IN_SECONDS = 30 * SECONDS_PER_DAY
21
+ const MAX_MEDIA_SCAN = 1000
22
+ const DEFAULT_LIMIT = 12
23
+ const MAX_LIMIT = 100
24
+ const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
25
+ const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // 1M requests/month
26
+
27
+ function getMimeType(extension: string): string {
28
+ if (extension === 'pdf') {
29
+ return 'application/pdf'
30
+ }
31
+ const type = extension === 'jpg' ? 'jpeg' : (extension || 'jpeg')
32
+ return `image/${type}`
33
+ }
34
+
35
+ const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
36
+
37
+ /**
38
+ * GET /stats/media-library - Lists all files present in R2:
39
+ * combines media_objects (tracked uploads) + /api/media/ URLs found in file columns of each seed
40
+ */
41
+ statsApp.get('/stats/media-library', async (context) => {
42
+ try {
43
+ const mediaRepository = context.get('mediaRepository')
44
+ const limit = Math.min(Number.parseInt(context.req.query('limit') ?? String(DEFAULT_LIMIT), 10), MAX_LIMIT)
45
+ const offset = Number.parseInt(context.req.query('offset') ?? '0', 10)
46
+ const mediaBaseUrl = (context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '')) ?? new URL(context.req.url).origin
47
+
48
+ // 1. Files tracked in the media library
49
+ // Take the first MAX_MEDIA_SCAN for cross-scanning
50
+ const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
51
+
52
+ const trackedKeys = new Set<string>()
53
+ const allItems: Array<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number; url: string }> = []
54
+
55
+ for (const mediaRow of mediaRows) {
56
+ trackedKeys.add(mediaRow.key)
57
+ allItems.push({ ...mediaRow, url: `${mediaBaseUrl}/api/media/${encodeURIComponent(mediaRow.key)}` })
58
+ }
59
+
60
+ // 2. /api/media/ URLs in the file columns of each seed (v0.4.0 — real columns)
61
+ const seeds = context.get('seedRegistry').all()
62
+ const contentScanRepository = context.get('contentScanRepository')
63
+ const referencedKeysFromContent = await contentScanRepository.getReferencedMediaKeys(seeds)
64
+
65
+ for (const key of referencedKeysFromContent) {
66
+ if (trackedKeys.has(key)) continue
67
+ trackedKeys.add(key)
68
+ const filename = key.replace(/^\d+-/, '')
69
+ const extension = filename.split('.').pop()?.toLowerCase() ?? ''
70
+ const mimeType = getMimeType(extension)
71
+ const createdAt = Number.parseInt(key.split('-')[0], 10) || 0
72
+ allItems.push({
73
+ key,
74
+ filename,
75
+ mime_type: mimeType,
76
+ size_bytes: 0,
77
+ created_at: createdAt,
78
+ url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
79
+ })
80
+ }
81
+
82
+ allItems.sort((a, b) => b.created_at - a.created_at)
83
+ const total = allItems.length
84
+ const paginatedItems = allItems.slice(offset, offset + limit)
85
+
86
+ return context.json({ items: paginatedItems, total })
87
+ } catch (error) {
88
+ console.error('Media library error:', error)
89
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
90
+ }
91
+ })
92
+
93
+ /**
94
+ * GET /stats/unused-media - Finds media in media_objects not referenced in any file column
95
+ */
96
+ statsApp.get('/stats/unused-media', async (context) => {
97
+ try {
98
+ const mediaRepository = context.get('mediaRepository')
99
+ const seeds = context.get('seedRegistry').all()
100
+
101
+ // All tracked media keys
102
+ const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
103
+
104
+ if (mediaRows.length === 0) {
105
+ return context.json({ items: [] })
106
+ }
107
+
108
+ // Collect all referenced keys in the file columns of each seed
109
+ const contentScanRepository = context.get('contentScanRepository')
110
+ const referencedKeys = await contentScanRepository.getReferencedMediaKeys(seeds)
111
+
112
+ const unusedMedia = mediaRows.filter(mediaItem => !referencedKeys.has(mediaItem.key))
113
+ return context.json({ items: unusedMedia })
114
+ } catch (error) {
115
+ console.error('Unused media error:', error)
116
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
117
+ }
118
+ })
119
+
120
+ /**
121
+ * GET /stats/setup-checklist - Project setup status for "Project Health" widget
122
+ */
123
+ statsApp.get('/stats/setup-checklist', async (context) => {
124
+ try {
125
+ const setupChecklistRepository = context.get('setupChecklistRepository')
126
+ const seeds = context.get('seedRegistry').all()
127
+
128
+ const existingTables = await setupChecklistRepository.getExistingTableNames()
129
+
130
+ const systemTableNames = [
131
+ 'users', 'refresh_tokens', 'media_objects',
132
+ 'analytics', 'system_stats', 'activity_logs',
133
+ ]
134
+ const systemTablesOk = systemTableNames.every(name => existingTables.has(name))
135
+
136
+ const seedsCount = seeds.length
137
+ const contentTablesOk = seedsCount > 0 && seeds.every(seed => existingTables.has(`content_${seed.slug}`))
138
+
139
+ let adminExists = false
140
+ try {
141
+ adminExists = (await setupChecklistRepository.countAdmins()) > 0
142
+ } catch {
143
+ // users table may not exist yet
144
+ }
145
+
146
+ let hasContent = false
147
+ const firstSeedSlug = seeds[0]?.slug ?? null
148
+ if (firstSeedSlug && existingTables.has(`content_${firstSeedSlug}`)) {
149
+ try {
150
+ hasContent = (await setupChecklistRepository.countEntriesInSeed(firstSeedSlug)) > 0
151
+ } catch {
152
+ // ignore
153
+ }
154
+ }
155
+
156
+ return context.json({
157
+ systemTablesOk,
158
+ seedsCount,
159
+ contentTablesOk,
160
+ adminExists,
161
+ hasContent,
162
+ firstSeedSlug,
163
+ })
164
+ } catch (error) {
165
+ console.error('Setup checklist error:', error)
166
+ return context.json({ error: 'Failed to compute setup checklist' }, 500)
167
+ }
168
+ })
169
+
170
+ /**
171
+ * GET /stats/total - Global content statistics for dashboard
172
+ */
173
+ statsApp.get('/stats/total', async (context) => {
174
+ try {
175
+ const now = SystemClock.nowSeconds()
176
+ const twentyFourHoursAgo = now - HOURS_24_IN_SECONDS
177
+ const sevenDaysAgo = now - DAYS_7_IN_SECONDS
178
+ const thirtyDaysAgo = now - DAYS_30_IN_SECONDS
179
+
180
+ // Total: SUM of per-seed counts (current live entries)
181
+ const seeds = context.get('seedRegistry').all()
182
+ const widgetRepository = context.get('widgetRepository')
183
+ const countResults = await Promise.all(
184
+ seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
185
+ )
186
+ const totalEntriesCount = countResults.reduce((accumulator, count) => accumulator + count, 0)
187
+
188
+ // today/week/month: create events in activity_logs (entity_type = 'content')
189
+ const activityLogRepository = context.get('activityLogRepository')
190
+ const [todayCount, weekCount, monthCount] = await Promise.all([
191
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: twentyFourHoursAgo }),
192
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: sevenDaysAgo }),
193
+ activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: thirtyDaysAgo }),
194
+ ])
195
+
196
+ return context.json({
197
+ total: totalEntriesCount,
198
+ today: todayCount,
199
+ week: weekCount,
200
+ month: monthCount,
201
+ })
202
+ } catch (error) {
203
+ console.error('Content stats error:', error)
204
+ return publicProblem(context, {
205
+ type: 'content-database-error',
206
+ title: INTERNAL_SERVER_ERROR,
207
+ status: 500,
208
+ detail: DATABASE_ERROR,
209
+ })
210
+ }
211
+ })
212
+
213
+ /**
214
+ * GET /stats/recent-activity - Latest activities registered in the system
215
+ * No ETag on this endpoint: the activity feed must always be fresh after
216
+ * every mutation. Cache-Control: no-store prevents the browser from keeping a
217
+ * response that could be returned as a 304 stale.
218
+ */
219
+ statsApp.get('/stats/recent-activity', async (context) => {
220
+ try {
221
+ const slug = cleanStr(context.req.query('slug'))
222
+
223
+ const entries = await context.get('activityLogRepository').list({
224
+ entitySlug: slug ?? undefined,
225
+ limit: 15,
226
+ })
227
+
228
+ // Preserve the legacy snake_case wire format consumed by the dashboard
229
+ // recent-activity widget. The repository returns camelCase records,
230
+ // so we reshape only on the way out.
231
+ const activities = entries.map((entry) => ({
232
+ id: entry.id,
233
+ user_id: entry.userId,
234
+ user_email: entry.userEmail,
235
+ user_name: entry.userName,
236
+ action: entry.action,
237
+ entity_type: entry.entityType,
238
+ entity_id: entry.entityId,
239
+ entity_slug: entry.entitySlug,
240
+ details: entry.details,
241
+ created_at: entry.createdAt,
242
+ }))
243
+
244
+ context.header('Cache-Control', 'no-store')
245
+
246
+ return context.json(activities)
247
+ } catch (error) {
248
+ console.error('Recent activity error:', error)
249
+ return publicProblem(context, {
250
+ type: 'content-database-error',
251
+ title: INTERNAL_SERVER_ERROR,
252
+ status: 500,
253
+ detail: DATABASE_ERROR,
254
+ })
255
+ }
256
+ })
257
+
258
+ /**
259
+ * GET /stats/health - System health status and Cloudflare quotas
260
+ */
261
+ statsApp.get('/stats/health', async (context) => {
262
+ try {
263
+ const systemStatsRepository = context.get('systemStatsRepository')
264
+
265
+ // 1. Retrieve storage usage from repository (system_stats)
266
+ let storageUsedBytes = 0
267
+ try {
268
+ storageUsedBytes = await systemStatsRepository.getStorageUsage()
269
+ } catch (error) {
270
+ console.warn('Health: Could not fetch storage stats from repo:', error)
271
+ }
272
+
273
+ // 2. Aggregate D1 requests (proxy for database health) - last 30 days
274
+ const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
275
+ const totalRequests = await context
276
+ .get('analyticsRepository')
277
+ .sumByMetric('requests', '', thirtyDaysAgo)
278
+
279
+ const storagePercentage = Math.min(Math.round((storageUsedBytes / R2_STORAGE_LIMIT) * 1000) / 10, 100)
280
+ const d1Percentage = Math.min(Math.round((totalRequests / D1_MONTHLY_REQUESTS_LIMIT) * 1000) / 10, 100)
281
+
282
+ return context.json({
283
+ storage: {
284
+ used: storageUsedBytes,
285
+ limit: R2_STORAGE_LIMIT,
286
+ percentage: storagePercentage
287
+ },
288
+ database: {
289
+ requests30d: totalRequests,
290
+ limit: D1_MONTHLY_REQUESTS_LIMIT,
291
+ percentage: d1Percentage
292
+ },
293
+ status: (storagePercentage < 90 && d1Percentage < 90) ? 'healthy' : 'warning',
294
+ lastUpdate: SystemClock.nowSeconds()
295
+ })
296
+ } catch (error) {
297
+ console.error('System health stats error:', error)
298
+ return context.json({ error: 'Failed to calculate system health' }, 500)
299
+ }
300
+ })
301
+
302
+ /**
303
+ * GET /stats/cloudflare - Cloudflare-like metrics (Visitors, Requests, Bandwidth)
304
+ */
305
+ statsApp.get('/stats/cloudflare', async (context) => {
306
+ try {
307
+ const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
308
+ const analyticsRepository = context.get('analyticsRepository')
309
+
310
+ const [recordedRequests, recordedVisitors] = await Promise.all([
311
+ analyticsRepository.sumByMetric('requests', '', thirtyDaysAgo),
312
+ analyticsRepository.sumByMetric('visitors', '', thirtyDaysAgo),
313
+ ])
314
+
315
+ // Simulate some Cloudflare metrics not directly tracked for a premium feel
316
+ const requestsCount = recordedRequests > 0 ? recordedRequests : Math.floor(Math.random() * 5000) + 1000
317
+ const visitorsCount = recordedVisitors > 0 ? recordedVisitors : Math.floor(requestsCount / 12) + 1
318
+ const bandwidthMB = Math.round((requestsCount * 0.15) * 10) / 10
319
+
320
+ const systemStatsRepository = context.get('systemStatsRepository')
321
+ let storageUsedBytes = 0
322
+ try {
323
+ storageUsedBytes = await systemStatsRepository.getStorageUsage()
324
+ } catch (error) {
325
+ console.warn('Could not fetch storage stats from repo:', error)
326
+ }
327
+
328
+ const storageUsedMB = Math.round((storageUsedBytes / (1024 * 1024)) * 10) / 10
329
+ const storageLimitMB = 10 * 1024 // 10 GB Free Tier
330
+
331
+ return context.json({
332
+ visitors: {
333
+ value: visitorsCount,
334
+ trend: 12, // simulated growth %
335
+ isPositive: true
336
+ },
337
+ requests: {
338
+ value: requestsCount,
339
+ trend: 8,
340
+ isPositive: true
341
+ },
342
+ bandwidth: {
343
+ value: bandwidthMB,
344
+ unit: 'MB',
345
+ trend: 5,
346
+ isPositive: false
347
+ },
348
+ cacheRate: {
349
+ value: 94.2,
350
+ unit: '%',
351
+ trend: 0.5,
352
+ isPositive: true
353
+ },
354
+ storage: {
355
+ used: storageUsedMB,
356
+ limit: storageLimitMB,
357
+ unit: 'MB',
358
+ percentage: Math.round((storageUsedMB / storageLimitMB) * 1000) / 10
359
+ }
360
+ })
361
+ } catch (error) {
362
+ console.error('Cloudflare stats error:', error)
363
+ return publicProblem(context, {
364
+ type: 'content-database-error',
365
+ title: INTERNAL_SERVER_ERROR,
366
+ status: 500,
367
+ detail: DATABASE_ERROR,
368
+ })
369
+ }
370
+ })
371
+
372
+ /**
373
+ * GET /stats/breakdown - Content distribution for "Content Pulse" widget
374
+ */
375
+ statsApp.get('/stats/breakdown', async (context) => {
376
+ try {
377
+ const seeds = context.get('seedRegistry').all()
378
+
379
+ const widgetRepository = context.get('widgetRepository')
380
+ const counts = await Promise.all(
381
+ seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
382
+ )
383
+
384
+ const breakdown = seeds.map((seed, index) => ({
385
+ slug: seed.slug,
386
+ label: seed.labelPlural || seed.label,
387
+ count: counts[index] ?? 0,
388
+ }))
389
+
390
+ return context.json(breakdown)
391
+ } catch (error) {
392
+ console.error('Breakdown stats error:', error)
393
+ return publicProblem(context, {
394
+ type: 'content-database-error',
395
+ title: INTERNAL_SERVER_ERROR,
396
+ status: 500,
397
+ detail: DATABASE_ERROR,
398
+ })
399
+ }
400
+ })
401
+
402
+ /**
403
+ * POST /stats/storage/sync - Recalculates space occupied on R2 (expensive operation, use with caution)
404
+ */
405
+ statsApp.post('/stats/storage/sync', async (context) => {
406
+ try {
407
+ const bucket = context.get('bucket')
408
+ const systemStatsRepository = context.get('systemStatsRepository')
409
+
410
+ const realSize = await bucket.getTotalSize()
411
+ await systemStatsRepository.setStorage(realSize)
412
+
413
+ return context.json({ success: true, size: realSize })
414
+ } catch (error) {
415
+ console.error('Storage sync error:', error)
416
+ return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
417
+ }
418
+ })
419
+
420
+ export { statsApp }