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