@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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- 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 }
|