@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,395 +1,430 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import { cleanStr } from '../../shared/query-utils'
4
- import { getBucketSize } from '../../shared/storage-utils'
5
- import { createR2Client } from '../../upload'
6
- import { publicProblem } from '../../public/problem-details'
7
- import type { Env, Variables } from '../../types'
8
-
9
- const DATABASE_ERROR = 'Database error'
10
-
11
- const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
-
13
- // GET /stats/media-library - Lista tutti i file presenti in R2:
14
- // combina media_objects (upload tracciati) + URL /api/media/ nelle colonne file di ogni seed
15
- statsApp.get('/stats/media-library', async (c) => {
16
- try {
17
- const { DB } = c.env
18
- const limit = Math.min(parseInt(c.req.query('limit') ?? '12'), 100)
19
- const offset = parseInt(c.req.query('offset') ?? '0')
20
- const mediaBase = (c.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '')) ?? new URL(c.req.url).origin
21
-
22
- // 1. File tracciati nella media library
23
- const mediaRows = await DB.prepare(
24
- 'SELECT key, filename, mime_type, size_bytes, created_at FROM media_objects ORDER BY created_at DESC'
25
- ).all<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number }>()
26
-
27
- const trackedKeys = new Set<string>()
28
- const allItems: Array<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number; url: string }> = []
29
-
30
- for (const m of mediaRows.results ?? []) {
31
- trackedKeys.add(m.key)
32
- allItems.push({ ...m, url: `${mediaBase}/api/media/${encodeURIComponent(m.key)}` })
33
- }
34
-
35
- // 2. URL /api/media/ nelle colonne file di ogni seed (v0.4.0 — colonne reali)
36
- const MEDIA_KEY_RE = /\/api\/media\/([^"'\s\\,}\]]+)/g
37
- const seeds = Object.values(c.get('seedRegistry'))
38
-
39
- for (const seed of seeds) {
40
- const fileBranches = seed.branches.filter(b => b.type === 'file')
41
- if (fileBranches.length === 0) continue
42
-
43
- const cols = fileBranches.map(b => b.alias).join(', ')
44
- const whereClause = fileBranches.map(b => `${b.alias} LIKE '%/api/media/%'`).join(' OR ')
45
-
46
- const rows = await DB.prepare(
47
- `SELECT ${cols} FROM content_${seed.slug} WHERE ${whereClause}`
48
- ).all<Record<string, string | null>>()
49
-
50
- for (const row of rows.results ?? []) {
51
- const combined = Object.values(row).filter(Boolean).join(' ')
52
- for (const match of combined.matchAll(MEDIA_KEY_RE)) {
53
- const key = decodeURIComponent(match[1])
54
- if (trackedKeys.has(key)) continue
55
- trackedKeys.add(key)
56
- const filename = key.replace(/^\d+-/, '')
57
- const ext = filename.split('.').pop()?.toLowerCase() ?? ''
58
- const mimeType = ext === 'pdf' ? 'application/pdf' : `image/${ext === 'jpg' ? 'jpeg' : ext || 'jpeg'}`
59
- const createdAt = parseInt(key.split('-')[0]) || 0
60
- allItems.push({ key, filename, mime_type: mimeType, size_bytes: 0, created_at: createdAt, url: `${mediaBase}/api/media/${encodeURIComponent(key)}` })
61
- }
62
- }
63
- }
64
-
65
- allItems.sort((a, b) => b.created_at - a.created_at)
66
- const total = allItems.length
67
- const paginated = allItems.slice(offset, offset + limit)
68
-
69
- return c.json({ items: paginated, total })
70
- } catch (err) {
71
- console.error('Media library error:', err)
72
- return c.json({ error: 'Internal Server Error' }, 500)
73
- }
74
- })
75
-
76
- // GET /stats/unused-media - Trova media in media_objects non referenziati in nessuna colonna file
77
- statsApp.get('/stats/unused-media', async (c) => {
78
- try {
79
- const { DB } = c.env
80
- const seeds = Object.values(c.get('seedRegistry'))
81
-
82
- // Tutte le chiavi media tracciate
83
- const mediaRows = await DB.prepare(
84
- 'SELECT key, filename, mime_type, size_bytes, created_at FROM media_objects ORDER BY created_at DESC'
85
- ).all<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number }>()
86
-
87
- if (!mediaRows.results?.length) {
88
- return c.json({ items: [] })
89
- }
90
-
91
- // Raccoglie tutte le chiavi referenziate nelle colonne file di ogni seed
92
- const referencedKeys = new Set<string>()
93
- for (const seed of seeds) {
94
- const fileBranches = seed.branches.filter(b => b.type === 'file')
95
- if (fileBranches.length === 0) continue
96
- const cols = fileBranches.map(b => b.alias).join(', ')
97
- const rows = await DB.prepare(
98
- `SELECT ${cols} FROM content_${seed.slug}`
99
- ).all<Record<string, string | null>>()
100
-
101
- for (const row of rows.results ?? []) {
102
- const combined = Object.values(row).filter(Boolean).join(' ')
103
- for (const match of combined.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
104
- referencedKeys.add(decodeURIComponent(match[1]))
105
- }
106
- }
107
- }
108
-
109
- const unused = (mediaRows.results ?? []).filter(m => !referencedKeys.has(m.key))
110
- return c.json({ items: unused })
111
- } catch (err) {
112
- console.error('Unused media error:', err)
113
- return c.json({ error: 'Internal Server Error' }, 500)
114
- }
115
- })
116
-
117
- // GET /stats/total - Statistiche globali contenuti per dashboard
118
- statsApp.get('/stats/total', async (c) => {
119
- try {
120
- const { DB } = c.env
121
- const now = Math.floor(Date.now() / 1000)
122
- const twentyFourHoursAgo = now - (24 * 60 * 60)
123
- const sevenDaysAgo = now - (7 * 24 * 60 * 60)
124
- const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
125
-
126
- // Total: SUM of per-seed counts (current live entries)
127
- const seeds = Object.values(c.get('seedRegistry'))
128
- const countResults = await Promise.all(
129
- seeds.map(s => DB.prepare(`SELECT COUNT(*) as n FROM content_${s.slug}`).first<{ n: number }>())
130
- )
131
- const total = countResults.reduce((acc, r) => acc + (r?.n ?? 0), 0)
132
-
133
- // today/week/month: create events in content_event_log
134
- const eventRow = await DB.prepare(
135
- `SELECT
136
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as today,
137
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as week,
138
- COUNT(CASE WHEN created_at >= ? THEN 1 END) as month
139
- FROM content_event_log WHERE action = 'create'`
140
- )
141
- .bind(twentyFourHoursAgo, sevenDaysAgo, thirtyDaysAgo)
142
- .first<{ today: number; week: number; month: number }>()
143
-
144
- return c.json({
145
- total,
146
- today: eventRow?.today ?? 0,
147
- week: eventRow?.week ?? 0,
148
- month: eventRow?.month ?? 0,
149
- })
150
- } catch (err) {
151
- console.error('Content stats error:', err)
152
- return publicProblem(c, {
153
- type: 'content-database-error',
154
- title: 'Internal Server Error',
155
- status: 500,
156
- detail: DATABASE_ERROR,
157
- })
158
- }
159
- })
160
-
161
- // GET /stats/recent-activity - Ultime attività registrate nel sistema
162
- // Nessun ETag su questo endpoint: il feed di attività deve sempre essere fresco dopo
163
- // ogni mutazione. Cache-Control: no-store impedisce al browser di conservare una
164
- // risposta che potrebbe essere restituita come 304 stale.
165
- statsApp.get('/stats/recent-activity', async (c) => {
166
- try {
167
- const { DB } = c.env
168
- const slug = cleanStr(c.req.query('slug'))
169
-
170
- let query = `SELECT id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details, created_at
171
- FROM activity_logs`
172
- const params: any[] = []
173
-
174
- if (slug) {
175
- query += ' WHERE entity_slug = ?'
176
- params.push(slug)
177
- }
178
-
179
- query += ' ORDER BY created_at DESC LIMIT 15'
180
-
181
- const result = await DB.prepare(query).bind(...params).all()
182
-
183
- const activities = (result.results ?? []).map((row: any) => ({
184
- ...row,
185
- details: row.details ? JSON.parse(row.details) : null
186
- }))
187
-
188
- c.header('Cache-Control', 'no-store')
189
-
190
- return c.json(activities)
191
- } catch (err) {
192
- console.error('Recent activity error:', err)
193
- return publicProblem(c, {
194
- type: 'content-database-error',
195
- title: 'Internal Server Error',
196
- status: 500,
197
- detail: DATABASE_ERROR,
198
- })
199
- }
200
- })
201
-
202
- // GET /stats/health - Stato salute sistema e quote Cloudflare
203
- statsApp.get('/stats/health', async (c) => {
204
- try {
205
- const { DB } = c.env
206
-
207
- // 1. Recupera storage da system_stats (aggiornato periodicamente o via sync)
208
- let storageUsedBytes = 0
209
- try {
210
- const statsRow = await DB.prepare(
211
- "SELECT value FROM system_stats WHERE id = 'total_storage_bytes'"
212
- ).first<{ value: string }>()
213
- if (statsRow) {
214
- storageUsedBytes = parseInt(statsRow.value, 10)
215
- }
216
- } catch (err) {
217
- console.warn('Health: Could not fetch storage stats from D1:', err)
218
- }
219
-
220
- // 2. Aggregazione richieste D1 (proxy per database health) - ultimi 30 giorni
221
- const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000)
222
- const d1Stats = await DB.prepare(
223
- `SELECT SUM(value) as total_requests FROM analytics WHERE metric = 'requests' AND seed = '' AND day_ts >= ?`
224
- ).bind(thirtyDaysAgo).first<{ total_requests: number }>()
225
-
226
- const totalRequests = d1Stats?.total_requests ?? 0
227
-
228
- // 3. Definizione limiti (Free Tier Cloudflare come riferimento)
229
- const R2_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
230
- const D1_MONTHLY_LIMIT = 1000000 // Simuliamo un limite di 1M di richieste/mese
231
-
232
- const storagePercentage = Math.min(Math.round((storageUsedBytes / R2_LIMIT) * 1000) / 10, 100)
233
- const d1Percentage = Math.min(Math.round((totalRequests / D1_MONTHLY_LIMIT) * 1000) / 10, 100)
234
-
235
- return c.json({
236
- storage: {
237
- used: storageUsedBytes,
238
- limit: R2_LIMIT,
239
- percentage: storagePercentage
240
- },
241
- database: {
242
- requests30d: totalRequests,
243
- limit: D1_MONTHLY_LIMIT,
244
- percentage: d1Percentage
245
- },
246
- status: (storagePercentage < 90 && d1Percentage < 90) ? 'healthy' : 'warning',
247
- lastUpdate: Math.floor(Date.now() / 1000)
248
- })
249
- } catch (err) {
250
- console.error('System health stats error:', err)
251
- return c.json({ error: 'Failed to calculate system health' }, 500)
252
- }
253
- })
254
-
255
- // GET /stats/cloudflare - Metriche tipo Cloudflare (Richieste, Visitatori, Bandwidth)
256
- statsApp.get('/stats/cloudflare', async (c) => {
257
- try {
258
- const { DB } = c.env
259
- const nowTs = Math.floor(Date.now() / 1000)
260
- const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000)
261
-
262
- // Recupera sum delle metriche negli ultimi 30 giorni
263
- const metrics = await DB.prepare(
264
- `SELECT
265
- metric,
266
- SUM(value) as total_value
267
- FROM analytics
268
- WHERE day_ts >= ? AND seed = ''
269
- GROUP BY metric`
270
- )
271
- .bind(thirtyDaysAgo)
272
- .all<{ metric: string; total_value: number }>()
273
-
274
- const statsMap = Object.fromEntries(
275
- metrics.results?.map(m => [m.metric, m.total_value]) ?? []
276
- )
277
-
278
- // Simuliamo alcune metriche Cloudflare non tracciate direttamente per premium feel
279
- const requests = statsMap['requests'] ?? Math.floor(Math.random() * 5000) + 1000
280
- const visitors = statsMap['visitors'] ?? Math.floor(requests / 12) + 1
281
- const bandwidth = Math.round((requests * 0.15) * 10) / 10
282
-
283
- // Metriche R2 (Dal contatore ottimizzato in D1)
284
- let storageUsedBytes = 0
285
- try {
286
- const statsRow = await DB.prepare(
287
- "SELECT value FROM system_stats WHERE id = 'total_storage_bytes'"
288
- ).first<{ value: string }>()
289
- if (statsRow) {
290
- storageUsedBytes = parseInt(statsRow.value, 10)
291
- }
292
- } catch (err) {
293
- console.warn('Could not fetch storage stats from D1:', err)
294
- }
295
-
296
- const storageUsedMB = Math.round((storageUsedBytes / (1024 * 1024)) * 10) / 10
297
- const storageLimitMB = 10 * 1024 // 10 GB Free Tier
298
-
299
- return c.json({
300
- visitors: {
301
- value: visitors,
302
- trend: 12, // % crescita simulata
303
- isPositive: true
304
- },
305
- requests: {
306
- value: requests,
307
- trend: 8,
308
- isPositive: true
309
- },
310
- bandwidth: {
311
- value: bandwidth,
312
- unit: 'MB',
313
- trend: 5,
314
- isPositive: false
315
- },
316
- cacheRate: {
317
- value: 94.2,
318
- unit: '%',
319
- trend: 0.5,
320
- isPositive: true
321
- },
322
- storage: {
323
- used: storageUsedMB,
324
- limit: storageLimitMB,
325
- unit: 'MB',
326
- percentage: Math.round((storageUsedMB / storageLimitMB) * 1000) / 10
327
- }
328
- })
329
- } catch (err) {
330
- console.error('Cloudflare stats error:', err)
331
- return publicProblem(c, {
332
- type: 'content-database-error',
333
- title: 'Internal Server Error',
334
- status: 500,
335
- detail: DATABASE_ERROR,
336
- })
337
- }
338
- })
339
-
340
- // GET /stats/breakdown - Distribuzione contenuti per il widget Content Pulse
341
- statsApp.get('/stats/breakdown', async (c) => {
342
- try {
343
- const { DB } = c.env
344
- const seeds = Object.values(c.get('seedRegistry'))
345
-
346
- const counts = await Promise.all(
347
- seeds.map(s => DB.prepare(`SELECT COUNT(*) as n FROM content_${s.slug}`).first<{ n: number }>())
348
- )
349
-
350
- const breakdown = seeds.map((seed, i) => ({
351
- slug: seed.slug,
352
- label: seed.labelPlural || seed.label,
353
- count: counts[i]?.n ?? 0,
354
- }))
355
-
356
- return c.json(breakdown)
357
- } catch (err) {
358
- console.error('Breakdown stats error:', err)
359
- return publicProblem(c, {
360
- type: 'content-database-error',
361
- title: 'Internal Server Error',
362
- status: 500,
363
- detail: DATABASE_ERROR,
364
- })
365
- }
366
- })
367
-
368
- // POST /stats/storage/sync - Ricalcola lo spazio occupato su R2 (operazione costosa, usare con cautela)
369
- statsApp.post('/stats/storage/sync', async (c) => {
370
- try {
371
- const { DB } = c.env
372
- const client = createR2Client(c.env as any)
373
- if (!c.env.R2_BUCKET_NAME) {
374
- throw new Error('R2_BUCKET_NAME not configured')
375
- }
376
-
377
- const realSize = await getBucketSize(client, c.env.R2_BUCKET_NAME)
378
-
379
- await DB.prepare(
380
- "UPDATE system_stats SET value = ? WHERE id = 'total_storage_bytes'"
381
- ).bind(String(realSize)).run()
382
-
383
- return c.json({ success: true, size: realSize })
384
- } catch (err) {
385
- console.error('Storage sync error:', err)
386
- return publicProblem(c, {
387
- type: 'content-database-error',
388
- title: 'Internal Server Error',
389
- status: 500,
390
- detail: DATABASE_ERROR,
391
- })
392
- }
393
- })
394
-
395
- export { statsApp }
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 }