@beechcms/api 0.4.0 → 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.
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/index.html +1 -1
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +70 -78
- package/src/features/content/handlers/create.ts +14 -10
- package/src/features/content/handlers/delete.ts +13 -9
- package/src/features/content/handlers/update.ts +13 -9
- package/src/features/draft/draft.handler.ts +23 -12
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +62 -175
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +41 -5
- package/src/middleware.ts +15 -35
- package/src/public/public-add.ts +5 -15
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +3 -3
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +20 -3
- package/src/upload.ts +14 -7
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
package/src/factory.ts
CHANGED
|
@@ -3,24 +3,19 @@ import { Hono } from 'hono'
|
|
|
3
3
|
import { cors } from 'hono/cors'
|
|
4
4
|
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
5
5
|
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
|
|
6
|
+
import { sha256hex, SystemClock, SystemIdGenerator, SeedRegistry } from '@beechcms/core'
|
|
6
7
|
import type { Env, Variables } from './types'
|
|
8
|
+
import { getClientIp } from './shared/request-utils'
|
|
7
9
|
|
|
8
10
|
// Imports delle rotte e middleware
|
|
9
11
|
import { AUTH_ERRORS } from './auth/constants'
|
|
10
12
|
import {
|
|
11
13
|
parseLoginBody,
|
|
12
14
|
validateLoginInput,
|
|
13
|
-
findUserByEmail,
|
|
14
15
|
verifyPassword,
|
|
15
16
|
DUMMY_PASSWORD_HASH,
|
|
16
17
|
} from './auth/login'
|
|
17
|
-
import {
|
|
18
|
-
generateRefreshToken,
|
|
19
|
-
saveRefreshToken,
|
|
20
|
-
generateAccessToken,
|
|
21
|
-
validateRefreshToken,
|
|
22
|
-
revokeRefreshToken,
|
|
23
|
-
} from './auth/refresh'
|
|
18
|
+
import { generateRefreshToken } from './auth/refresh'
|
|
24
19
|
import { authMiddleware } from './middleware'
|
|
25
20
|
import contentFeature from './features/content'
|
|
26
21
|
import { widgetApp } from './widget'
|
|
@@ -37,6 +32,9 @@ import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './pub
|
|
|
37
32
|
import { searchRouter } from "./search"
|
|
38
33
|
import { repositoryMiddleware } from './middleware/repository.middleware'
|
|
39
34
|
import { storageMiddleware } from './middleware/storage.middleware'
|
|
35
|
+
import { authProvidersMiddleware } from './middleware/auth-providers.middleware'
|
|
36
|
+
import { rateLimiterMiddleware } from './middleware/rate-limit.middleware'
|
|
37
|
+
import { observabilityMiddleware } from './middleware/observability.middleware'
|
|
40
38
|
|
|
41
39
|
export interface BeechConfig {
|
|
42
40
|
seeds: Seed[] | Record<string, Seed>
|
|
@@ -55,10 +53,6 @@ function isRequestSecure(url: string): boolean {
|
|
|
55
53
|
return new URL(url).protocol === 'https:'
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
function getClientIp(headers: Headers): string {
|
|
59
|
-
return headers.get('cf-connecting-ip') ?? 'unknown'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
56
|
function getRefreshTokenCookieOptions(secure: boolean) {
|
|
63
57
|
return {
|
|
64
58
|
httpOnly: true,
|
|
@@ -98,15 +92,14 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
98
92
|
const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
|
|
99
93
|
// Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
|
|
100
94
|
const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
|
|
101
|
-
const
|
|
102
|
-
const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
|
|
95
|
+
const seedRegistry = new SeedRegistry(validSeeds)
|
|
103
96
|
|
|
104
97
|
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
105
98
|
|
|
106
99
|
// 1. Core Middleware (Seeds, CORS, Security)
|
|
107
100
|
app.use('*', async (context, next) => {
|
|
108
|
-
context.set('getSeed',
|
|
109
|
-
context.set('seedRegistry',
|
|
101
|
+
context.set('getSeed', (slug: string) => seedRegistry.get(slug))
|
|
102
|
+
context.set('seedRegistry', seedRegistry)
|
|
110
103
|
await next()
|
|
111
104
|
})
|
|
112
105
|
|
|
@@ -122,6 +115,10 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
122
115
|
bucket: config.bucket,
|
|
123
116
|
}))
|
|
124
117
|
|
|
118
|
+
app.use('*', authProvidersMiddleware())
|
|
119
|
+
app.use('*', rateLimiterMiddleware())
|
|
120
|
+
app.use('*', observabilityMiddleware())
|
|
121
|
+
|
|
125
122
|
app.use('*', async (context, next) => {
|
|
126
123
|
const isDev = context.env.ENV !== 'production'
|
|
127
124
|
|
|
@@ -174,27 +171,23 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
174
171
|
// 2. Analytics Middleware
|
|
175
172
|
app.use('/api/*', async (context, next) => {
|
|
176
173
|
await next()
|
|
177
|
-
if (context.req.method
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
})())
|
|
196
|
-
}
|
|
197
|
-
}
|
|
174
|
+
if (context.req.method === 'OPTIONS') return
|
|
175
|
+
if (context.res.status < 200 || context.res.status >= 300) return
|
|
176
|
+
|
|
177
|
+
let executionCtx: any
|
|
178
|
+
try { executionCtx = context.executionCtx } catch {}
|
|
179
|
+
if (!executionCtx) return
|
|
180
|
+
|
|
181
|
+
const analyticsRepository = context.get('analyticsRepository')
|
|
182
|
+
if (!analyticsRepository) return
|
|
183
|
+
|
|
184
|
+
const seedSlug = extractPublicSeed(context.req.path)
|
|
185
|
+
|
|
186
|
+
executionCtx.waitUntil(
|
|
187
|
+
analyticsRepository.recordRequest(seedSlug).catch((error: unknown) => {
|
|
188
|
+
console.error('Analytics middleware error:', error)
|
|
189
|
+
})
|
|
190
|
+
)
|
|
198
191
|
})
|
|
199
192
|
|
|
200
193
|
// 3. Auth Routes
|
|
@@ -207,28 +200,27 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
207
200
|
const { email, password } = credentials
|
|
208
201
|
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
209
202
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
|
|
214
|
-
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
215
|
-
}
|
|
203
|
+
const clientIp = getClientIp(context.req)
|
|
204
|
+
const loginRateLimit = await context.get('rateLimiters').getLimiter('login').checkLimit(clientIp)
|
|
205
|
+
if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
216
206
|
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const isValid = await verifyPassword(password, hashToCompare)
|
|
207
|
+
const user = await context.get('userRepository').findByEmail(email)
|
|
208
|
+
const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH
|
|
209
|
+
const isValid = await verifyPassword(password, hashToCompare, context.get('hashProvider'))
|
|
221
210
|
|
|
222
211
|
if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
|
|
223
212
|
|
|
224
|
-
const
|
|
225
|
-
const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
226
|
-
issuer: context.env.JWT_ISSUER,
|
|
227
|
-
audience: context.env.JWT_AUDIENCE,
|
|
228
|
-
}, userProfile?.name ?? undefined)
|
|
213
|
+
const accessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
|
|
229
214
|
const refreshToken = generateRefreshToken()
|
|
230
|
-
|
|
231
|
-
|
|
215
|
+
const refreshTokenHash = await sha256hex(refreshToken)
|
|
216
|
+
const nowSeconds = SystemClock.nowSeconds()
|
|
217
|
+
|
|
218
|
+
await context.get('sessionRepository').saveRefreshToken({
|
|
219
|
+
id: SystemIdGenerator.uuid(),
|
|
220
|
+
userId: user.id,
|
|
221
|
+
tokenHash: refreshTokenHash,
|
|
222
|
+
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
|
|
223
|
+
})
|
|
232
224
|
setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
233
225
|
return context.json({ token: accessToken, expiresIn: '15m' }, 200)
|
|
234
226
|
} catch (error) {
|
|
@@ -238,33 +230,34 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
238
230
|
|
|
239
231
|
app.post('/auth/refresh', async (context) => {
|
|
240
232
|
try {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const { success } = await refreshLimiter.limit({ key: clientIp })
|
|
245
|
-
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
246
|
-
}
|
|
233
|
+
const refreshClientIp = getClientIp(context.req)
|
|
234
|
+
const refreshRateLimit = await context.get('rateLimiters').getLimiter('tokenRefresh').checkLimit(refreshClientIp)
|
|
235
|
+
if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
247
236
|
|
|
248
237
|
const refreshToken = getCookie(context, 'refresh_token')
|
|
249
238
|
if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
|
|
250
239
|
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
240
|
+
const nowSeconds = SystemClock.nowSeconds()
|
|
241
|
+
const tokenHash = await sha256hex(refreshToken)
|
|
242
|
+
const activeSession = await context.get('sessionRepository').findActiveByHash(tokenHash, nowSeconds)
|
|
243
|
+
if (!activeSession) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
254
244
|
|
|
255
|
-
const user = await
|
|
245
|
+
const user = await context.get('userRepository').findById(activeSession.userId)
|
|
256
246
|
if (!user) return context.json({ error: 'User not found' }, 401)
|
|
257
247
|
|
|
258
|
-
const revoked = await
|
|
248
|
+
const revoked = await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
|
|
259
249
|
if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
260
250
|
|
|
261
|
-
const newAccessToken = await
|
|
262
|
-
issuer: context.env.JWT_ISSUER,
|
|
263
|
-
audience: context.env.JWT_AUDIENCE,
|
|
264
|
-
}, user.name ?? undefined)
|
|
251
|
+
const newAccessToken = await context.get('tokenService').issue({ sub: user.id, email: user.email, name: user.name ?? undefined })
|
|
265
252
|
const newRefreshToken = generateRefreshToken()
|
|
266
|
-
|
|
267
|
-
|
|
253
|
+
const newRefreshTokenHash = await sha256hex(newRefreshToken)
|
|
254
|
+
|
|
255
|
+
await context.get('sessionRepository').saveRefreshToken({
|
|
256
|
+
id: SystemIdGenerator.uuid(),
|
|
257
|
+
userId: user.id,
|
|
258
|
+
tokenHash: newRefreshTokenHash,
|
|
259
|
+
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
|
|
260
|
+
})
|
|
268
261
|
setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
269
262
|
return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
|
|
270
263
|
} catch (error) {
|
|
@@ -275,7 +268,11 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
275
268
|
app.post('/auth/logout', async (context) => {
|
|
276
269
|
try {
|
|
277
270
|
const refreshToken = getCookie(context, 'refresh_token')
|
|
278
|
-
if (refreshToken)
|
|
271
|
+
if (refreshToken) {
|
|
272
|
+
const nowSeconds = SystemClock.nowSeconds()
|
|
273
|
+
const tokenHash = await sha256hex(refreshToken)
|
|
274
|
+
await context.get('sessionRepository').revokeByHash(tokenHash, nowSeconds)
|
|
275
|
+
}
|
|
279
276
|
deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
|
|
280
277
|
return context.json({ message: 'Logged out' }, 200)
|
|
281
278
|
} catch (error) {
|
|
@@ -289,12 +286,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
289
286
|
|
|
290
287
|
// 5. Protected CMS API
|
|
291
288
|
const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
292
|
-
apiProtected.use('*',
|
|
293
|
-
await authMiddleware(context.env.JWT_SECRET, {
|
|
294
|
-
issuer: context.env.JWT_ISSUER,
|
|
295
|
-
audience: context.env.JWT_AUDIENCE,
|
|
296
|
-
})(context, next)
|
|
297
|
-
})
|
|
289
|
+
apiProtected.use('*', authMiddleware())
|
|
298
290
|
|
|
299
291
|
apiProtected.route('/settings', settingsApp)
|
|
300
292
|
apiProtected.route('/schema', schemaApp)
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
9
9
|
import { publicProblem } from '../../../public/problem-details'
|
|
10
10
|
import { CONTENT_ERRORS } from '../constants'
|
|
11
|
-
import { logActivity } from '../../../shared/activity-logger'
|
|
12
11
|
import { cleanStr } from '../../../shared/query-utils'
|
|
13
12
|
import { AppEnv } from '../../../types'
|
|
14
13
|
|
|
@@ -110,7 +109,7 @@ export async function createHandler(context: Context<AppEnv>) {
|
|
|
110
109
|
throw error
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
const id =
|
|
112
|
+
const id = context.get('idGenerator').uuid()
|
|
114
113
|
let finalSlug = entrySlug
|
|
115
114
|
if (!finalSlug) {
|
|
116
115
|
const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
|
|
@@ -121,15 +120,20 @@ export async function createHandler(context: Context<AppEnv>) {
|
|
|
121
120
|
const repository = context.get('repository')
|
|
122
121
|
await repository.create(seed, id, finalSlug, status, privacyData)
|
|
123
122
|
|
|
124
|
-
const
|
|
123
|
+
const jwtPayload = context.get('jwtPayload')
|
|
125
124
|
const title = privacyData.title || privacyData.name || finalSlug
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
action: 'create',
|
|
129
|
-
entityType: 'content',
|
|
130
|
-
entityId: id,
|
|
131
|
-
entitySlug: slug,
|
|
132
|
-
details: { title }
|
|
125
|
+
|
|
126
|
+
context.get('activityLogger').log({
|
|
127
|
+
action: 'create',
|
|
128
|
+
entityType: 'content',
|
|
129
|
+
entityId: id,
|
|
130
|
+
entitySlug: slug,
|
|
131
|
+
details: { title },
|
|
132
|
+
actor: {
|
|
133
|
+
id: jwtPayload.sub,
|
|
134
|
+
email: jwtPayload.email ?? 'unknown',
|
|
135
|
+
name: jwtPayload.name ?? null,
|
|
136
|
+
},
|
|
133
137
|
})
|
|
134
138
|
|
|
135
139
|
return context.json({ id }, 201)
|
|
@@ -4,7 +4,6 @@ import { deleteR2Objects } from '../../../upload'
|
|
|
4
4
|
import { extractMediaKeysFromData } from '../../../media-utils'
|
|
5
5
|
import { publicProblem } from '../../../public/problem-details'
|
|
6
6
|
import { CONTENT_ERRORS } from '../constants'
|
|
7
|
-
import { logActivity } from '../../../shared/activity-logger'
|
|
8
7
|
import { AppEnv } from '../../../types'
|
|
9
8
|
|
|
10
9
|
export async function deleteHandler(context: Context<AppEnv>) {
|
|
@@ -34,15 +33,20 @@ export async function deleteHandler(context: Context<AppEnv>) {
|
|
|
34
33
|
// Repository.delete returns the row data for cleanup
|
|
35
34
|
const { row } = await repository.delete(seed, entryId)
|
|
36
35
|
|
|
37
|
-
const
|
|
36
|
+
const jwtPayload = context.get('jwtPayload')
|
|
38
37
|
const title = row.title || row.name || entryId
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
action: 'delete',
|
|
42
|
-
entityType: 'content',
|
|
43
|
-
entityId: entryId,
|
|
44
|
-
entitySlug: schemaSlug,
|
|
45
|
-
details: { title }
|
|
38
|
+
|
|
39
|
+
context.get('activityLogger').log({
|
|
40
|
+
action: 'delete',
|
|
41
|
+
entityType: 'content',
|
|
42
|
+
entityId: entryId,
|
|
43
|
+
entitySlug: schemaSlug,
|
|
44
|
+
details: { title },
|
|
45
|
+
actor: {
|
|
46
|
+
id: jwtPayload.sub,
|
|
47
|
+
email: jwtPayload.email ?? 'unknown',
|
|
48
|
+
name: jwtPayload.name ?? null,
|
|
49
|
+
},
|
|
46
50
|
})
|
|
47
51
|
|
|
48
52
|
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
11
11
|
import { publicProblem } from '../../../public/problem-details'
|
|
12
12
|
import { CONTENT_ERRORS } from '../constants'
|
|
13
|
-
import { logActivity } from '../../../shared/activity-logger'
|
|
14
13
|
import { cleanStr } from '../../../shared/query-utils'
|
|
15
14
|
import { AppEnv } from '../../../types'
|
|
16
15
|
|
|
@@ -167,15 +166,20 @@ export async function updateHandler(context: Context<AppEnv>) {
|
|
|
167
166
|
|
|
168
167
|
await repository.update(seed, id, mergedData, newStatus)
|
|
169
168
|
|
|
170
|
-
const
|
|
169
|
+
const jwtPayload = context.get('jwtPayload')
|
|
171
170
|
const title = mergedData.title || mergedData.name || newSlug
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
action: 'update',
|
|
175
|
-
entityType: 'content',
|
|
176
|
-
entityId: id,
|
|
177
|
-
entitySlug: slug,
|
|
178
|
-
details: { title }
|
|
171
|
+
|
|
172
|
+
context.get('activityLogger').log({
|
|
173
|
+
action: 'update',
|
|
174
|
+
entityType: 'content',
|
|
175
|
+
entityId: id,
|
|
176
|
+
entitySlug: slug,
|
|
177
|
+
details: { title },
|
|
178
|
+
actor: {
|
|
179
|
+
id: jwtPayload.sub,
|
|
180
|
+
email: jwtPayload.email ?? 'unknown',
|
|
181
|
+
name: jwtPayload.name ?? null,
|
|
182
|
+
},
|
|
179
183
|
})
|
|
180
184
|
|
|
181
185
|
return context.json({ success: true })
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
EntryNotFoundError
|
|
7
7
|
} from '@beechcms/core'
|
|
8
8
|
import { publicProblem } from '../../public/problem-details'
|
|
9
|
-
import { logActivity } from '../../shared/activity-logger'
|
|
10
9
|
import { cleanStr } from '../../shared/query-utils'
|
|
11
10
|
import { applyVisibility } from '../../shared/apply-policies'
|
|
12
11
|
import { AppEnv } from '../../types'
|
|
@@ -114,14 +113,20 @@ draftApp.put('/:slug/:id/draft', async (context) => {
|
|
|
114
113
|
|
|
115
114
|
await repository.saveDraft(seed, id, validation.data)
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
const draftSaveActor = context.get('jwtPayload')
|
|
117
|
+
context.get('activityLogger').log({
|
|
118
|
+
action: 'update',
|
|
119
|
+
entityType: 'content',
|
|
120
|
+
entityId: id,
|
|
121
121
|
entitySlug: slug,
|
|
122
|
-
details: {
|
|
123
|
-
title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
|
|
124
|
-
note: 'draft saved'
|
|
122
|
+
details: {
|
|
123
|
+
title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
|
|
124
|
+
note: 'draft saved',
|
|
125
|
+
},
|
|
126
|
+
actor: {
|
|
127
|
+
id: draftSaveActor.sub,
|
|
128
|
+
email: draftSaveActor.email ?? 'unknown',
|
|
129
|
+
name: draftSaveActor.name ?? null,
|
|
125
130
|
},
|
|
126
131
|
})
|
|
127
132
|
|
|
@@ -220,12 +225,18 @@ draftApp.post('/:slug/:id/draft/publish', async (context) => {
|
|
|
220
225
|
const displayValue = draft[seed.displayNameAlias]
|
|
221
226
|
const displayStr = typeof displayValue === 'string' ? displayValue : id
|
|
222
227
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
const draftPublishActor = context.get('jwtPayload')
|
|
229
|
+
context.get('activityLogger').log({
|
|
230
|
+
action: 'update',
|
|
231
|
+
entityType: 'content',
|
|
232
|
+
entityId: id,
|
|
227
233
|
entitySlug: slug,
|
|
228
234
|
details: { title: displayStr, note: 'draft published' },
|
|
235
|
+
actor: {
|
|
236
|
+
id: draftPublishActor.sub,
|
|
237
|
+
email: draftPublishActor.email ?? 'unknown',
|
|
238
|
+
name: draftPublishActor.name ?? null,
|
|
239
|
+
},
|
|
229
240
|
})
|
|
230
241
|
|
|
231
242
|
return context.json({ success: true })
|
|
@@ -3,47 +3,40 @@ import { Hono } from 'hono'
|
|
|
3
3
|
import type { Env, Variables } from '../../types'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Notifications Feature Handler
|
|
6
|
+
* Notifications Feature Handler.
|
|
7
7
|
*
|
|
8
|
-
* Manages
|
|
9
|
-
*
|
|
8
|
+
* Manages retrieval, mark read/unread, and deletion of system notifications.
|
|
9
|
+
* All persistence goes through the {@link INotificationRepository} injected
|
|
10
|
+
* by `repositoryMiddleware`. The handler owns the HTTP concerns only:
|
|
11
|
+
* ETag negotiation, status codes, error mapping.
|
|
10
12
|
*/
|
|
11
13
|
const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
12
14
|
|
|
15
|
+
const NOTIFICATION_LIST_LIMIT = 50
|
|
16
|
+
|
|
13
17
|
/**
|
|
14
18
|
* GET /notifications
|
|
15
|
-
*
|
|
16
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* Returns the most recent notifications. The ETag is built from aggregate
|
|
21
|
+
* stats so unchanged inboxes return 304 without serialising the full list.
|
|
17
22
|
*/
|
|
18
23
|
notificationsApp.get('/notifications', async (context) => {
|
|
19
24
|
try {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const stats = await DB.prepare(
|
|
24
|
-
'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
|
|
25
|
-
).first<{ count: number; latest: number | null; read_sum: number | null }>()
|
|
26
|
-
|
|
27
|
-
const totalCount = stats?.count ?? 0
|
|
28
|
-
const lastUpdateTimestamp = stats?.latest ?? 0
|
|
29
|
-
const totalReadCount = stats?.read_sum ?? 0
|
|
30
|
-
|
|
31
|
-
// ETag is derived from count, latest timestamp, and read status to ensure freshness
|
|
32
|
-
const etag = `W/"${totalCount}-${lastUpdateTimestamp}-${totalReadCount}"`
|
|
25
|
+
const notificationRepository = context.get('notificationRepository')
|
|
26
|
+
const notificationStats = await notificationRepository.stats()
|
|
27
|
+
const etagValue = `W/"${notificationStats.totalCount}-${notificationStats.latestCreatedAt}-${notificationStats.readCount}"`
|
|
33
28
|
|
|
34
29
|
const ifNoneMatch = context.req.header('If-None-Match')
|
|
35
|
-
if (ifNoneMatch ===
|
|
30
|
+
if (ifNoneMatch === etagValue) {
|
|
36
31
|
return new Response(null, { status: 304 })
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
const
|
|
40
|
-
'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
|
|
41
|
-
).all()
|
|
34
|
+
const notifications = await notificationRepository.list(NOTIFICATION_LIST_LIMIT)
|
|
42
35
|
|
|
43
|
-
context.header('ETag',
|
|
36
|
+
context.header('ETag', etagValue)
|
|
44
37
|
context.header('Cache-Control', 'no-cache, must-revalidate')
|
|
45
38
|
|
|
46
|
-
return context.json(
|
|
39
|
+
return context.json(notifications)
|
|
47
40
|
} catch (error) {
|
|
48
41
|
console.error('[Notifications] Fetch error:', error)
|
|
49
42
|
return context.json({ error: 'Failed to fetch notifications' }, 500)
|
|
@@ -51,18 +44,12 @@ notificationsApp.get('/notifications', async (context) => {
|
|
|
51
44
|
})
|
|
52
45
|
|
|
53
46
|
/**
|
|
54
|
-
* PATCH /notifications/:id/read
|
|
55
|
-
* Marks a specific notification as read.
|
|
47
|
+
* PATCH /notifications/:id/read — mark a single notification as read.
|
|
56
48
|
*/
|
|
57
49
|
notificationsApp.patch('/notifications/:id/read', async (context) => {
|
|
58
50
|
try {
|
|
59
51
|
const notificationId = context.req.param('id')
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
|
|
63
|
-
.bind(notificationId)
|
|
64
|
-
.run()
|
|
65
|
-
|
|
52
|
+
await context.get('notificationRepository').markRead(notificationId)
|
|
66
53
|
return context.json({ success: true })
|
|
67
54
|
} catch (error) {
|
|
68
55
|
console.error('[Notifications] Mark read error:', error)
|
|
@@ -71,18 +58,12 @@ notificationsApp.patch('/notifications/:id/read', async (context) => {
|
|
|
71
58
|
})
|
|
72
59
|
|
|
73
60
|
/**
|
|
74
|
-
* PATCH /notifications/:id/unread
|
|
75
|
-
* Marks a specific notification as unread.
|
|
61
|
+
* PATCH /notifications/:id/unread — mark a single notification as unread.
|
|
76
62
|
*/
|
|
77
63
|
notificationsApp.patch('/notifications/:id/unread', async (context) => {
|
|
78
64
|
try {
|
|
79
65
|
const notificationId = context.req.param('id')
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
|
|
83
|
-
.bind(notificationId)
|
|
84
|
-
.run()
|
|
85
|
-
|
|
66
|
+
await context.get('notificationRepository').markUnread(notificationId)
|
|
86
67
|
return context.json({ success: true })
|
|
87
68
|
} catch (error) {
|
|
88
69
|
console.error('[Notifications] Mark unread error:', error)
|
|
@@ -91,18 +72,12 @@ notificationsApp.patch('/notifications/:id/unread', async (context) => {
|
|
|
91
72
|
})
|
|
92
73
|
|
|
93
74
|
/**
|
|
94
|
-
* DELETE /notifications/:id
|
|
95
|
-
* Permanently deletes a notification.
|
|
75
|
+
* DELETE /notifications/:id — permanently remove a notification.
|
|
96
76
|
*/
|
|
97
77
|
notificationsApp.delete('/notifications/:id', async (context) => {
|
|
98
78
|
try {
|
|
99
79
|
const notificationId = context.req.param('id')
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await DB.prepare('DELETE FROM notifications WHERE id = ?')
|
|
103
|
-
.bind(notificationId)
|
|
104
|
-
.run()
|
|
105
|
-
|
|
80
|
+
await context.get('notificationRepository').delete(notificationId)
|
|
106
81
|
return context.json({ success: true })
|
|
107
82
|
} catch (error) {
|
|
108
83
|
console.error('[Notifications] Delete error:', error)
|
|
@@ -111,15 +86,11 @@ notificationsApp.delete('/notifications/:id', async (context) => {
|
|
|
111
86
|
})
|
|
112
87
|
|
|
113
88
|
/**
|
|
114
|
-
* POST /notifications/mark-all-read
|
|
115
|
-
* Marks all notifications in the database as read.
|
|
89
|
+
* POST /notifications/mark-all-read — mark every notification as read.
|
|
116
90
|
*/
|
|
117
91
|
notificationsApp.post('/notifications/mark-all-read', async (context) => {
|
|
118
92
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await DB.prepare('UPDATE notifications SET is_read = 1').run()
|
|
122
|
-
|
|
93
|
+
await context.get('notificationRepository').markAllRead()
|
|
123
94
|
return context.json({ success: true })
|
|
124
95
|
} catch (error) {
|
|
125
96
|
console.error('[Notifications] Mark all read error:', error)
|