@beechcms/api 0.4.0-preview.10 → 0.4.0-preview.12

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 (31) hide show
  1. package/assets/dashboard/assets/index-CTSuGxlX.js +554 -0
  2. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/factory.ts +96 -85
  6. package/src/features/content/constants.ts +10 -0
  7. package/src/features/content/handlers/create.ts +163 -0
  8. package/src/features/content/handlers/delete.ts +85 -0
  9. package/src/features/content/handlers/facets.ts +45 -0
  10. package/src/features/content/handlers/get.ts +116 -0
  11. package/src/features/content/handlers/list.ts +88 -0
  12. package/src/features/content/handlers/update.ts +216 -0
  13. package/src/features/content/index.ts +20 -0
  14. package/src/features/draft/draft.handler.ts +203 -129
  15. package/src/features/settings/settings.handler.ts +18 -0
  16. package/src/index.ts +16 -3
  17. package/src/middleware/repository.middleware.ts +18 -0
  18. package/src/public/public-add.ts +72 -89
  19. package/src/public/public-edit.ts +51 -76
  20. package/src/public/public-read.ts +113 -114
  21. package/src/public/query-builder.ts +47 -136
  22. package/src/shared/base.repository.d1.ts +28 -0
  23. package/src/shared/content.repository.d1.ts +382 -0
  24. package/src/shared/idempotency.repository.d1.ts +45 -0
  25. package/src/types.ts +6 -1
  26. package/src/upload.ts +3 -7
  27. package/assets/dashboard/assets/index-CFTJe1vb.js +0 -554
  28. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  29. package/src/content.ts +0 -502
  30. package/src/features/draft/draft.test.ts +0 -315
  31. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/factory.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Hono } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
4
  import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
5
- import type { Seed } from '@beechcms/core'
5
+ import type { Seed, ContentRepository, IdempotencyRepository } from '@beechcms/core'
6
6
  import type { Env, Variables } from './types'
7
7
 
8
8
  // Imports delle rotte e middleware
@@ -22,7 +22,7 @@ import {
22
22
  revokeRefreshToken,
23
23
  } from './auth/refresh'
24
24
  import { authMiddleware } from './middleware'
25
- import { contentRoutes } from './content'
25
+ import contentFeature from './features/content'
26
26
  import { widgetApp } from './widget'
27
27
  import { rotateFieldApp } from './features/rotate-field'
28
28
  import { passwordResetApp } from './features/password-reset'
@@ -35,9 +35,12 @@ import { statsApp } from './features/stats'
35
35
  import { uploadRoutes, serveMediaHandler } from './upload'
36
36
  import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
37
37
  import { searchRouter } from "./search"
38
+ import { repositoryMiddleware } from './middleware/repository.middleware'
38
39
 
39
40
  export interface BeechConfig {
40
41
  seeds: Seed[] | Record<string, Seed>
42
+ repository?: ContentRepository
43
+ idempotencyRepository?: IdempotencyRepository
41
44
  }
42
45
 
43
46
  // --- Costanti e helper ---
@@ -71,11 +74,11 @@ function getRefreshTokenDeleteCookieOptions(secure: boolean) {
71
74
  }
72
75
  }
73
76
 
74
- function handleAuthError(c: any, err: unknown, operationName: string): Response {
75
- if (c.env.ENV !== 'production') {
76
- console.error(`${operationName} error:`, err)
77
+ function handleAuthError(context: any, error: unknown, operationName: string): Response {
78
+ if (context.env.ENV !== 'production') {
79
+ console.error(`${operationName} error:`, error)
77
80
  }
78
- return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
81
+ return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
79
82
  }
80
83
 
81
84
  function extractPublicSeed(path: string): string {
@@ -89,20 +92,28 @@ function extractPublicSeed(path: string): string {
89
92
  */
90
93
  export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
91
94
  const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
92
- const registry: Record<string, Seed> = Object.fromEntries(seedsArray.map(s => [s.slug, s]))
95
+ // Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
96
+ const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
97
+ const registry: Record<string, Seed> = Object.fromEntries(validSeeds.map(s => [s.slug, s]))
93
98
  const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
94
99
 
95
100
  const app = new Hono<{ Bindings: Env; Variables: Variables }>()
96
101
 
97
102
  // 1. Core Middleware (Seeds, CORS, Security)
98
- app.use('*', async (c, next) => {
99
- c.set('getSeed', getSeedFn)
100
- c.set('seedRegistry', registry)
103
+ app.use('*', async (context, next) => {
104
+ context.set('getSeed', getSeedFn)
105
+ context.set('seedRegistry', registry)
101
106
  await next()
102
107
  })
103
108
 
104
- app.use('*', async (c, next) => {
105
- const origins = (c.env.CORS_ORIGINS ?? 'http://localhost:5173')
109
+ // 1.1 Repository Injection
110
+ app.use('*', repositoryMiddleware({
111
+ repository: config.repository,
112
+ idempotencyRepository: config.idempotencyRepository,
113
+ }))
114
+
115
+ app.use('*', async (context, next) => {
116
+ const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
106
117
  .split(',')
107
118
  .map((o) => o.trim())
108
119
  .filter(Boolean)
@@ -114,7 +125,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
114
125
  // Allow same-origin if the host matches
115
126
  try {
116
127
  const originUrl = new URL(origin)
117
- const requestUrl = new URL(c.req.url)
128
+ const requestUrl = new URL(context.req.url)
118
129
  if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
119
130
  return origin
120
131
  }
@@ -125,29 +136,29 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
125
136
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
126
137
  allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
127
138
  credentials: true,
128
- })(c, next)
139
+ })(context, next)
129
140
  })
130
141
 
131
- app.use('*', async (c, next) => {
142
+ app.use('*', async (context, next) => {
132
143
  await next()
133
- if (c.req.path.startsWith('/admin')) return
134
- c.header('X-Frame-Options', 'DENY')
135
- c.header('X-Content-Type-Options', 'nosniff')
136
- c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
137
- c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
138
- c.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
144
+ if (context.req.path.startsWith('/admin')) return
145
+ context.header('X-Frame-Options', 'DENY')
146
+ context.header('X-Content-Type-Options', 'nosniff')
147
+ context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
148
+ context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
149
+ context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
139
150
  })
140
151
 
141
152
  // 2. Analytics Middleware
142
- app.use('/api/*', async (c, next) => {
153
+ app.use('/api/*', async (context, next) => {
143
154
  await next()
144
- if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
145
- const db = c.env.DB
155
+ if (context.req.method !== 'OPTIONS' && context.res.status >= 200 && context.res.status < 300) {
156
+ const db = context.env.DB
146
157
  let executionCtx: any
147
- try { executionCtx = c.executionCtx } catch {}
158
+ try { executionCtx = context.executionCtx } catch {}
148
159
 
149
160
  if (db && executionCtx) {
150
- const seed = extractPublicSeed(c.req.path)
161
+ const seed = extractPublicSeed(context.req.path)
151
162
  executionCtx.waitUntil((async () => {
152
163
  try {
153
164
  const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
@@ -156,8 +167,8 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
156
167
  VALUES (?, 'requests', ?, 1)
157
168
  ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
158
169
  ).bind(today, seed).run()
159
- } catch (err) {
160
- console.error('Analytics middleware error:', err)
170
+ } catch (error) {
171
+ console.error('Analytics middleware error:', error)
161
172
  }
162
173
  })())
163
174
  }
@@ -165,88 +176,88 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
165
176
  })
166
177
 
167
178
  // 3. Auth Routes
168
- app.post('/auth/login', async (c) => {
179
+ app.post('/auth/login', async (context) => {
169
180
  try {
170
181
  let body: any
171
- try { body = await c.req.json() } catch { return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
182
+ try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
172
183
  const credentials = parseLoginBody(body)
173
- if (!credentials) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
184
+ if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
174
185
  const { email, password } = credentials
175
- if (!validateLoginInput(email, password)) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
186
+ if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
176
187
 
177
- const loginLimiter = c.env.LOGIN_RATE_LIMITER
188
+ const loginLimiter = context.env.LOGIN_RATE_LIMITER
178
189
  if (loginLimiter) {
179
- const clientIp = getClientIp(c.req.raw.headers)
190
+ const clientIp = getClientIp(context.req.raw.headers)
180
191
  const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
181
- if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
192
+ if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
182
193
  }
183
194
 
184
- const { DB, JWT_SECRET } = c.env
195
+ const { DB, JWT_SECRET } = context.env
185
196
  const user = await findUserByEmail(DB, email)
186
197
  const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
187
198
  const isValid = await verifyPassword(password, hashToCompare)
188
199
 
189
- if (!user || !isValid) return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
200
+ if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
190
201
 
191
202
  const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
192
203
  const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
193
- issuer: c.env.JWT_ISSUER,
194
- audience: c.env.JWT_AUDIENCE,
204
+ issuer: context.env.JWT_ISSUER,
205
+ audience: context.env.JWT_AUDIENCE,
195
206
  }, userProfile?.name ?? undefined)
196
207
  const refreshToken = generateRefreshToken()
197
208
 
198
209
  await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
199
- setCookie(c, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
200
- return c.json({ token: accessToken, expiresIn: '15m' }, 200)
201
- } catch (err) {
202
- return handleAuthError(c, err, 'Login')
210
+ setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
211
+ return context.json({ token: accessToken, expiresIn: '15m' }, 200)
212
+ } catch (error) {
213
+ return handleAuthError(context, error, 'Login')
203
214
  }
204
215
  })
205
216
 
206
- app.post('/auth/refresh', async (c) => {
217
+ app.post('/auth/refresh', async (context) => {
207
218
  try {
208
- const refreshLimiter = c.env.REFRESH_RATE_LIMITER
219
+ const refreshLimiter = context.env.REFRESH_RATE_LIMITER
209
220
  if (refreshLimiter) {
210
- const clientIp = getClientIp(c.req.raw.headers)
221
+ const clientIp = getClientIp(context.req.raw.headers)
211
222
  const { success } = await refreshLimiter.limit({ key: clientIp })
212
- if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
223
+ if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
213
224
  }
214
225
 
215
- const refreshToken = getCookie(c, 'refresh_token')
216
- if (!refreshToken) return c.json({ error: 'Refresh token missing' }, 401)
226
+ const refreshToken = getCookie(context, 'refresh_token')
227
+ if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
217
228
 
218
- const { DB, JWT_SECRET } = c.env
229
+ const { DB, JWT_SECRET } = context.env
219
230
  const validation = await validateRefreshToken(DB, refreshToken)
220
- if (!validation.valid || !validation.userId) return c.json({ error: 'Invalid refresh token' }, 401)
231
+ if (!validation.valid || !validation.userId) return context.json({ error: 'Invalid refresh token' }, 401)
221
232
 
222
233
  const user = await DB.prepare('SELECT id, email, name FROM users WHERE id = ? LIMIT 1').bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
223
- if (!user) return c.json({ error: 'User not found' }, 401)
234
+ if (!user) return context.json({ error: 'User not found' }, 401)
224
235
 
225
236
  const revoked = await revokeRefreshToken(DB, refreshToken)
226
- if (!revoked) return c.json({ error: 'Invalid refresh token' }, 401)
237
+ if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
227
238
 
228
239
  const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
229
- issuer: c.env.JWT_ISSUER,
230
- audience: c.env.JWT_AUDIENCE,
240
+ issuer: context.env.JWT_ISSUER,
241
+ audience: context.env.JWT_AUDIENCE,
231
242
  }, user.name ?? undefined)
232
243
  const newRefreshToken = generateRefreshToken()
233
244
 
234
245
  await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
235
- setCookie(c, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
236
- return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
237
- } catch (err) {
238
- return handleAuthError(c, err, 'Refresh')
246
+ setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
247
+ return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
248
+ } catch (error) {
249
+ return handleAuthError(context, error, 'Refresh')
239
250
  }
240
251
  })
241
252
 
242
- app.post('/auth/logout', async (c) => {
253
+ app.post('/auth/logout', async (context) => {
243
254
  try {
244
- const refreshToken = getCookie(c, 'refresh_token')
245
- if (refreshToken) await revokeRefreshToken(c.env.DB, refreshToken)
246
- deleteCookie(c, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)))
247
- return c.json({ message: 'Logged out' }, 200)
248
- } catch (err) {
249
- return handleAuthError(c, err, 'Logout')
255
+ const refreshToken = getCookie(context, 'refresh_token')
256
+ if (refreshToken) await revokeRefreshToken(context.env.DB, refreshToken)
257
+ deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
258
+ return context.json({ message: 'Logged out' }, 200)
259
+ } catch (error) {
260
+ return handleAuthError(context, error, 'Logout')
250
261
  }
251
262
  })
252
263
 
@@ -256,11 +267,11 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
256
267
 
257
268
  // 5. Protected CMS API
258
269
  const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
259
- apiProtected.use('*', async (c, next) => {
260
- await authMiddleware(c.env.JWT_SECRET, {
261
- issuer: c.env.JWT_ISSUER,
262
- audience: c.env.JWT_AUDIENCE,
263
- })(c, next)
270
+ apiProtected.use('*', async (context, next) => {
271
+ await authMiddleware(context.env.JWT_SECRET, {
272
+ issuer: context.env.JWT_ISSUER,
273
+ audience: context.env.JWT_AUDIENCE,
274
+ })(context, next)
264
275
  })
265
276
 
266
277
  apiProtected.route('/settings', settingsApp)
@@ -269,35 +280,35 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
269
280
  apiProtected.route('/content', statsApp)
270
281
  apiProtected.route('/content', rotateFieldApp)
271
282
  apiProtected.route('/content', draftApp)
272
- apiProtected.route('/content', contentRoutes)
283
+ apiProtected.route('/content', contentFeature)
273
284
  apiProtected.route('/widget', widgetApp)
285
+ apiProtected.route('/', uploadRoutes)
274
286
 
275
- app.route('/api', apiProtected)
276
- app.route('/api/search', searchRouter)
277
- app.route('/api', uploadRoutes)
278
- app.get('/api/media/:key', (c) => serveMediaHandler(c))
279
-
280
- // 6. Public API
287
+ // 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
281
288
  const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
282
289
  apiPublic.use('*', publicRateLimitMiddleware())
283
290
  apiPublic.use('*', apiKeyMiddleware())
284
291
  apiPublic.route('/', publicRoutes)
285
292
  app.route('/api/v1/public', apiPublic)
286
293
 
294
+ app.get('/api/media/:key', (context) => serveMediaHandler(context))
295
+ app.route('/api', apiProtected)
296
+ app.route('/api/search', searchRouter)
297
+
287
298
  // 7. Dashboard SPA — serve static assets from Workers Assets binding
288
- app.get('/admin', (c) => c.redirect('/admin/', 301))
289
- app.get('/admin/*', async (c) => {
290
- if (!c.env.ASSETS) {
291
- return c.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
299
+ app.get('/admin', (context) => context.redirect('/admin/', 301))
300
+ app.get('/admin/*', async (context) => {
301
+ if (!context.env.ASSETS) {
302
+ return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
292
303
  }
293
- const url = new URL(c.req.url)
304
+ const url = new URL(context.req.url)
294
305
  const originalPath = url.pathname
295
306
  url.pathname = originalPath.replace(/^\/admin/, '') || '/'
296
307
 
297
- let assetResponse = await c.env.ASSETS.fetch(new Request(url.toString(), c.req.raw))
308
+ let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
298
309
  if (assetResponse.status === 404) {
299
310
  // SPA fallback: any unmatched /admin/* route serves index.html
300
- assetResponse = await c.env.ASSETS.fetch(new Request(new URL('/index.html', c.req.url)))
311
+ assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
301
312
  }
302
313
  // ASSETS returns an immutable Response — wrap it to inject security headers
303
314
  const headers = new Headers(assetResponse.headers)
@@ -0,0 +1,10 @@
1
+ export const CONTENT_ERRORS = {
2
+ INVALID_SLUG: 'Invalid slug',
3
+ INVALID_SLUG_OR_ID: 'Invalid slug or id',
4
+ INVALID_JSON_BODY: 'Invalid JSON body',
5
+ NOT_FOUND: 'Not found',
6
+ SEED_NOT_FOUND: 'Seed not found',
7
+ DATABASE_ERROR: 'Database error',
8
+ SLUG_CONFLICT: 'Slug already exists for this schema',
9
+ SENSITIVE_FIELD_EDIT: 'Cannot edit sensitive fields',
10
+ } as const
@@ -0,0 +1,163 @@
1
+ import { Context } from 'hono'
2
+ import {
3
+ slugify,
4
+ isValidContentStatus,
5
+ validateAndSanitizeSeedPayload,
6
+ SlugConflictError
7
+ } from '@beechcms/core'
8
+ import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
9
+ import { publicProblem } from '../../../public/problem-details'
10
+ import { CONTENT_ERRORS } from '../constants'
11
+ import { logActivity } from '../../../shared/activity-logger'
12
+ import { logContentEvent } from '../../../shared/content-utils'
13
+ import { cleanStr } from '../../../shared/query-utils'
14
+ import { AppEnv } from '../../../types'
15
+
16
+ function normalizeBody(raw: unknown): Record<string, unknown> {
17
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
18
+ }
19
+
20
+ function contentValidationProblem(
21
+ context: Context,
22
+ details: Array<{ field: string; expected: string; received: string; message: string }>
23
+ ) {
24
+ return publicProblem(context, {
25
+ type: 'content-validation-failed',
26
+ title: 'Bad Request',
27
+ status: 400,
28
+ detail: 'Validation failed',
29
+ errors: details
30
+ })
31
+ }
32
+
33
+ export async function createHandler(context: Context<AppEnv>) {
34
+ const slug = context.req.param('slug')
35
+ if (!slug) {
36
+ return publicProblem(context, {
37
+ type: 'content-invalid-slug',
38
+ title: 'Bad Request',
39
+ status: 400,
40
+ detail: CONTENT_ERRORS.INVALID_SLUG
41
+ })
42
+ }
43
+
44
+ const seed = context.get('getSeed')(slug)
45
+ if (!seed) {
46
+ return publicProblem(context, {
47
+ type: 'content-seed-not-found',
48
+ title: 'Not Found',
49
+ status: 404,
50
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
51
+ })
52
+ }
53
+
54
+ let body: Record<string, unknown>
55
+ try {
56
+ body = normalizeBody(await context.req.json<unknown>())
57
+ } catch {
58
+ return publicProblem(context, {
59
+ type: 'content-invalid-json',
60
+ title: 'Bad Request',
61
+ status: 400,
62
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
63
+ })
64
+ }
65
+
66
+ const entrySlug = body.slug ? slugify(String(body.slug)) : null
67
+ const status = cleanStr(body.status) ?? 'draft'
68
+ if (!isValidContentStatus(status)) {
69
+ return publicProblem(context, {
70
+ type: 'content-invalid-status',
71
+ title: 'Bad Request',
72
+ status: 400,
73
+ detail: 'Invalid status. Allowed values are: draft, review, published'
74
+ })
75
+ }
76
+
77
+ const bodyForData = { ...body }
78
+ delete bodyForData.slug
79
+ delete bodyForData.status
80
+
81
+ const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
82
+ operation: 'create',
83
+ allowNull: false,
84
+ requireAtLeastOneValidField: true,
85
+ enforceRequiredFields: true,
86
+ })
87
+
88
+ if (validation.dangerousFields.length > 0) {
89
+ return publicProblem(context, {
90
+ type: 'content-dangerous-content',
91
+ title: 'Unprocessable Entity',
92
+ status: 422,
93
+ detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
94
+ })
95
+ }
96
+
97
+ if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
98
+
99
+ let privacyData: Record<string, unknown>
100
+ try {
101
+ privacyData = await applyPrivacy(validation.data, seed)
102
+ } catch (error) {
103
+ if (error instanceof PrivacyPolicyError) {
104
+ return publicProblem(context, {
105
+ type: 'content-policy-not-implemented',
106
+ title: 'Not Implemented',
107
+ status: 501,
108
+ detail: error.message
109
+ })
110
+ }
111
+ throw error
112
+ }
113
+
114
+ const id = crypto.randomUUID()
115
+ let finalSlug = entrySlug
116
+ if (!finalSlug) {
117
+ const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
118
+ finalSlug = slugify(String(fallbackSource))
119
+ }
120
+
121
+ try {
122
+ const repository = context.get('repository')
123
+ await repository.create(seed, id, finalSlug, status, privacyData)
124
+
125
+ const userId = context.get('jwtPayload')?.sub
126
+ const title = privacyData.title || privacyData.name || finalSlug
127
+
128
+ // We use the DB from env for legacy logging utils until they are migrated
129
+ logContentEvent(context.env.DB, {
130
+ action: 'create',
131
+ schemaSlug: slug,
132
+ entryId: id,
133
+ userId,
134
+ details: { title }
135
+ }).catch(() => {})
136
+
137
+ logActivity(context, {
138
+ action: 'create',
139
+ entityType: 'content',
140
+ entityId: id,
141
+ entitySlug: slug,
142
+ details: { title }
143
+ })
144
+
145
+ return context.json({ id }, 201)
146
+ } catch (error) {
147
+ if (error instanceof SlugConflictError) {
148
+ return publicProblem(context, {
149
+ type: 'content-slug-conflict',
150
+ title: 'Conflict',
151
+ status: 409,
152
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
153
+ })
154
+ }
155
+ console.error('Content create error:', error)
156
+ return publicProblem(context, {
157
+ type: 'content-database-error',
158
+ title: 'Internal Server Error',
159
+ status: 500,
160
+ detail: CONTENT_ERRORS.DATABASE_ERROR
161
+ })
162
+ }
163
+ }
@@ -0,0 +1,85 @@
1
+ import { Context } from 'hono'
2
+ import { EntryNotFoundError } from '@beechcms/core'
3
+ import { deleteR2Objects } from '../../../upload'
4
+ import { extractMediaKeysFromData } from '../../../media-utils'
5
+ import { publicProblem } from '../../../public/problem-details'
6
+ import { CONTENT_ERRORS } from '../constants'
7
+ import { logActivity } from '../../../shared/activity-logger'
8
+ import { logContentEvent } from '../../../shared/content-utils'
9
+ import { AppEnv } from '../../../types'
10
+
11
+ export async function deleteHandler(context: Context<AppEnv>) {
12
+ const schemaSlug = context.req.param('slug')
13
+ const entryId = context.req.param('id')
14
+ if (!schemaSlug || !entryId) {
15
+ return publicProblem(context, {
16
+ type: 'content-invalid-slug-or-id',
17
+ title: 'Bad Request',
18
+ status: 400,
19
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
20
+ })
21
+ }
22
+
23
+ const seed = context.get('getSeed')(schemaSlug)
24
+ if (!seed) {
25
+ return publicProblem(context, {
26
+ type: 'content-seed-not-found',
27
+ title: 'Not Found',
28
+ status: 404,
29
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
30
+ })
31
+ }
32
+
33
+ try {
34
+ const repository = context.get('repository')
35
+ // Repository.delete returns the row data for cleanup
36
+ const { row } = await repository.delete(seed, entryId)
37
+
38
+ const userId = context.get('jwtPayload')?.sub
39
+ const title = row.title || row.name || entryId
40
+
41
+ logContentEvent(context.env.DB, {
42
+ action: 'delete',
43
+ schemaSlug,
44
+ entryId,
45
+ userId,
46
+ details: { title }
47
+ }).catch(() => {})
48
+
49
+ logActivity(context, {
50
+ action: 'delete',
51
+ entityType: 'content',
52
+ entityId: entryId,
53
+ entitySlug: schemaSlug,
54
+ details: { title }
55
+ })
56
+
57
+ // Task 4.3: Cleanup R2 logic
58
+ const r2ObjectKeys = extractMediaKeysFromData(seed, row)
59
+ if (r2ObjectKeys.length > 0) {
60
+ await deleteR2Objects(context.env, r2ObjectKeys).catch((error) => {
61
+ if (context.env.ENV !== 'production') {
62
+ console.warn('R2 cleanup on delete failed (orphaned files):', error)
63
+ }
64
+ })
65
+ }
66
+
67
+ return context.json({ success: true })
68
+ } catch (error) {
69
+ if (error instanceof EntryNotFoundError) {
70
+ return publicProblem(context, {
71
+ type: 'content-not-found',
72
+ title: 'Not Found',
73
+ status: 404,
74
+ detail: CONTENT_ERRORS.NOT_FOUND
75
+ })
76
+ }
77
+ console.error('Content delete error:', error)
78
+ return publicProblem(context, {
79
+ type: 'content-database-error',
80
+ title: 'Internal Server Error',
81
+ status: 500,
82
+ detail: CONTENT_ERRORS.DATABASE_ERROR
83
+ })
84
+ }
85
+ }
@@ -0,0 +1,45 @@
1
+ import { Context } from 'hono'
2
+ import { publicProblem } from '../../../public/problem-details'
3
+ import { CONTENT_ERRORS } from '../constants'
4
+ import { AppEnv } from '../../../types'
5
+
6
+ export async function facetsHandler(context: Context<AppEnv>) {
7
+ const slug = context.req.param('slug')
8
+ if (!slug) {
9
+ return publicProblem(context, {
10
+ type: 'content-invalid-slug',
11
+ title: 'Bad Request',
12
+ status: 400,
13
+ detail: CONTENT_ERRORS.INVALID_SLUG
14
+ })
15
+ }
16
+
17
+ const seed = context.get('getSeed')(slug)
18
+ if (!seed) {
19
+ return publicProblem(context, {
20
+ type: 'content-seed-not-found',
21
+ title: 'Not Found',
22
+ status: 404,
23
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
24
+ })
25
+ }
26
+
27
+ try {
28
+ const repository = context.get('repository')
29
+ const { statuses, tagsByColumn } = await repository.getFacets(seed)
30
+
31
+ // Legacy format expected by the dashboard
32
+ return context.json({
33
+ statuses: Object.keys(statuses).sort((a, b) => a.localeCompare(b, 'it')),
34
+ tagsByColumnId: tagsByColumn,
35
+ })
36
+ } catch (error) {
37
+ console.error('Content facets error:', error)
38
+ return publicProblem(context, {
39
+ type: 'content-database-error',
40
+ title: 'Internal Server Error',
41
+ status: 500,
42
+ detail: CONTENT_ERRORS.DATABASE_ERROR
43
+ })
44
+ }
45
+ }