@beechcms/api 0.4.0-preview.9 → 0.4.0

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 (89) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -249
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/content.ts DELETED
@@ -1,502 +0,0 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import {
4
- buildSelectQuery,
5
- isValidContentStatus,
6
- validateAndSanitizeSeedPayload,
7
- slugify,
8
- resolvePolicies,
9
- } from '@beechcms/core'
10
- import type { Seed } from '@beechcms/core'
11
- import { deleteR2Objects } from './upload'
12
- import { extractMediaKeysFromData } from './media-utils'
13
- import { publicProblem } from './public/problem-details'
14
- import { logActivity } from './shared/activity-logger'
15
- import { cleanStr, parsePositiveInt, parseQueryFilters, safeParseJson, toEngineFilters } from './shared/query-utils'
16
- import type { ContentEntry } from './shared/query-utils'
17
- import { applyPrivacy, applyVisibility, PrivacyPolicyError } from './shared/apply-policies'
18
- import { rowToApiData, rowToEntry, buildInsertBindings, buildUpdateBindings, hasDraft, logContentEvent } from './shared/content-utils'
19
-
20
- export type { ContentEntry } from './shared/query-utils'
21
-
22
- export const CONTENT_ERRORS = {
23
- INVALID_SLUG: 'Invalid slug',
24
- INVALID_SLUG_OR_ID: 'Invalid slug or id',
25
- INVALID_JSON_BODY: 'Invalid JSON body',
26
- NOT_FOUND: 'Not found',
27
- SEED_NOT_FOUND: 'Seed not found',
28
- DATABASE_ERROR: 'Database error',
29
- SLUG_CONFLICT: 'Slug already exists for this schema',
30
- SENSITIVE_FIELD_EDIT: 'Cannot edit sensitive fields',
31
- } as const
32
-
33
- interface ContentFacetsResponse {
34
- statuses: string[]
35
- tagsByColumnId: Record<string, string[]>
36
- }
37
-
38
- function parseTagNames(value: unknown): string[] {
39
- let parsed: unknown = value
40
- if (typeof value === 'string') {
41
- try { parsed = JSON.parse(value) } catch { parsed = value }
42
- }
43
- if (Array.isArray(parsed)) return parsed.map(cleanStr).filter(Boolean) as string[]
44
- if (parsed && typeof parsed === 'object') return Object.keys(parsed).map(cleanStr).filter(Boolean) as string[]
45
- const single = cleanStr(parsed)
46
- return single ? [single] : []
47
- }
48
-
49
- function normalizeBody(raw: unknown): Record<string, unknown> {
50
- return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
51
- }
52
-
53
- function contentValidationProblem(
54
- c: Parameters<typeof publicProblem>[0],
55
- details: Array<{ field: string; expected: string; received: string; message: string }>
56
- ) {
57
- return publicProblem(c, { type: 'content-validation-failed', title: 'Bad Request', status: 400, detail: 'Validation failed', errors: details })
58
- }
59
-
60
- type Bindings = {
61
- DB: D1Database
62
- R2_ACCESS_KEY_ID?: string
63
- R2_SECRET_ACCESS_KEY?: string
64
- R2_ENDPOINT?: string
65
- R2_BUCKET_NAME?: string
66
- MEDIA_BASE_URL?: string
67
- ENV?: string
68
- }
69
-
70
- type Variables = {
71
- jwtPayload: { sub: string; email?: string }
72
- getSeed: (slug: string) => Seed | null
73
- seedRegistry: Record<string, Seed>
74
- }
75
-
76
- const contentApp = new Hono<{ Bindings: Bindings; Variables: Variables }>()
77
-
78
- // POST /:slug — Creazione
79
- contentApp.post('/:slug', async (c) => {
80
- const slug = c.req.param('slug')
81
- if (!slug) return publicProblem(c, { type: 'content-invalid-slug', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG })
82
-
83
- const seed = c.get('getSeed')(slug)
84
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
85
-
86
- let body: Record<string, unknown>
87
- try {
88
- body = normalizeBody(await c.req.json<unknown>())
89
- } catch {
90
- return publicProblem(c, { type: 'content-invalid-json', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_JSON_BODY })
91
- }
92
-
93
- const entrySlug = body.slug ? slugify(String(body.slug)) : null
94
- const status = cleanStr(body.status) ?? 'draft'
95
- if (!isValidContentStatus(status)) {
96
- return publicProblem(c, { type: 'content-invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
97
- }
98
-
99
- const bodyForData = { ...body }
100
- delete bodyForData.slug
101
- delete bodyForData.status
102
-
103
- const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
104
- operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true,
105
- })
106
- if (validation.dangerousFields.length > 0) {
107
- return publicProblem(c, { type: 'content-dangerous-content', title: 'Unprocessable Entity', status: 422, detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'` })
108
- }
109
- if (validation.details.length > 0) return contentValidationProblem(c, validation.details)
110
-
111
- let privacyData: Record<string, unknown>
112
- try {
113
- privacyData = await applyPrivacy(validation.data, seed)
114
- } catch (err) {
115
- if (err instanceof PrivacyPolicyError) {
116
- return publicProblem(c, { type: 'content-policy-not-implemented', title: 'Not Implemented', status: 501, detail: err.message })
117
- }
118
- throw err
119
- }
120
-
121
- const { cols, placeholders, bindings } = buildInsertBindings(seed, privacyData)
122
- const id = crypto.randomUUID()
123
-
124
- let finalSlug = entrySlug
125
- if (!finalSlug) {
126
- const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
127
- finalSlug = slugify(String(fallbackSource))
128
- }
129
-
130
- const now = Math.floor(Date.now() / 1000)
131
- const table = `content_${slug}`
132
-
133
- const systemCols = ['id', 'slug', 'status', 'created_at', 'updated_at']
134
- const systemVals = [id, finalSlug, status, now, now]
135
- const allCols = [...systemCols, ...cols].join(', ')
136
- const allPlaceholders = [...systemVals.map(() => '?'), ...placeholders].join(', ')
137
-
138
- try {
139
- const { DB } = c.env
140
-
141
- const existing = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ?`).bind(finalSlug).first()
142
- if (existing) {
143
- return publicProblem(c, { type: 'content-slug-conflict', title: 'Conflict', status: 409, detail: CONTENT_ERRORS.SLUG_CONFLICT })
144
- }
145
-
146
- await DB.prepare(`INSERT INTO ${table} (${allCols}) VALUES (${allPlaceholders})`)
147
- .bind(...systemVals, ...bindings)
148
- .run()
149
-
150
- const userId = c.get('jwtPayload')?.sub
151
- logContentEvent(DB, { action: 'create', schemaSlug: slug, entryId: id, userId, details: { title: privacyData.title || privacyData.name || entrySlug } }).catch(() => {})
152
- logActivity(c, { action: 'create', entityType: 'content', entityId: id, entitySlug: slug, details: { title: privacyData.title || privacyData.name || entrySlug } })
153
-
154
- return c.json({ id }, 201)
155
- } catch (err) {
156
- console.error('Content create error:', err)
157
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
158
- }
159
- })
160
-
161
- // GET /:slug/facets — Valori distinti per filtri dashboard
162
- contentApp.get('/:slug/facets', async (c) => {
163
- const slug = c.req.param('slug')
164
- if (!slug) return publicProblem(c, { type: 'content-invalid-slug', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG })
165
-
166
- const seed = c.get('getSeed')(slug)
167
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
168
-
169
- const tagBranches = seed.branches.filter((b) => b.type === 'json' && b.alias.toLowerCase().includes('tag'))
170
- const tagAliases = tagBranches.map((b) => b.alias)
171
- const statusSet = new Set<string>()
172
- const tagsSetByAlias = new Map<string, Set<string>>(tagAliases.map((a) => [a, new Set()]))
173
-
174
- try {
175
- const { DB } = c.env
176
- const selectCols = ['status', ...tagAliases].join(', ')
177
- const result = await DB.prepare(`SELECT ${selectCols} FROM content_${slug}`)
178
- .all<Record<string, unknown>>()
179
-
180
- for (const row of result.results ?? []) {
181
- const s = cleanStr(row.status) ?? ''
182
- if (s) statusSet.add(s)
183
- for (const alias of tagAliases) {
184
- for (const tag of parseTagNames(row[alias])) {
185
- tagsSetByAlias.get(alias)?.add(tag)
186
- }
187
- }
188
- }
189
-
190
- const tagsByColumnId: Record<string, string[]> = {}
191
- for (const [alias, set] of tagsSetByAlias.entries()) {
192
- tagsByColumnId[alias] = Array.from(set).sort((a, b) => a.localeCompare(b, 'it'))
193
- }
194
- return c.json({
195
- statuses: Array.from(statusSet).sort((a, b) => a.localeCompare(b, 'it')),
196
- tagsByColumnId,
197
- } satisfies ContentFacetsResponse)
198
- } catch (err) {
199
- console.error('Content facets error:', err)
200
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
201
- }
202
- })
203
-
204
- // GET /:schema_slug/by-slug/:entry_slug — Dettaglio per slug URL
205
- contentApp.get('/:schema_slug/by-slug/:entry_slug', async (c) => {
206
- const schemaSlug = c.req.param('schema_slug')
207
- const entrySlug = c.req.param('entry_slug')
208
- if (!schemaSlug || !entrySlug) {
209
- return publicProblem(c, { type: 'content-invalid-slug-or-id', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID })
210
- }
211
-
212
- const seed = c.get('getSeed')(schemaSlug)
213
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
214
-
215
- try {
216
- const { DB } = c.env
217
- const row = await DB.prepare(`SELECT * FROM content_${schemaSlug} WHERE slug = ?`)
218
- .bind(entrySlug)
219
- .first<Record<string, unknown>>()
220
-
221
- if (!row) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.NOT_FOUND })
222
-
223
- const pending = await hasDraft(DB, seed, row.id as string)
224
- const entry = rowToEntry(seed, row, pending)
225
- return c.json({ ...entry, data: applyVisibility(entry.data, seed) })
226
- } catch (err) {
227
- console.error('Content by-slug error:', err)
228
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
229
- }
230
- })
231
-
232
- // PUT /:slug/:id — Aggiornamento
233
- contentApp.put('/:slug/:id', async (c) => {
234
- const slug = c.req.param('slug')
235
- const id = c.req.param('id')
236
- if (!slug || !id) {
237
- return publicProblem(c, { type: 'content-invalid-slug-or-id', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID })
238
- }
239
-
240
- const seed = c.get('getSeed')(slug)
241
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
242
-
243
- let body: Record<string, unknown>
244
- try {
245
- body = normalizeBody(await c.req.json<unknown>())
246
- } catch {
247
- return publicProblem(c, { type: 'content-invalid-json', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_JSON_BODY })
248
- }
249
-
250
- const bodyForData = { ...body }
251
- delete bodyForData.slug
252
- delete bodyForData.status
253
- const now = Math.floor(Date.now() / 1000)
254
- const table = `content_${slug}`
255
-
256
- try {
257
- const { DB } = c.env
258
- const current = await DB.prepare(`SELECT slug, status FROM ${table} WHERE id = ?`)
259
- .bind(id)
260
- .first<{ slug: string | null; status: string }>()
261
-
262
- if (!current) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.NOT_FOUND })
263
-
264
- const newSlug = body.slug !== undefined ? slugify(String(body.slug)) : (current.slug ?? '')
265
- const newStatus = body.status !== undefined ? (cleanStr(body.status) ?? current.status) : current.status
266
-
267
- if (!isValidContentStatus(newStatus)) {
268
- return publicProblem(c, { type: 'content-invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
269
- }
270
- if (!newSlug) {
271
- return publicProblem(c, { type: 'content-missing-slug', title: 'Bad Request', status: 400, detail: 'Missing required field: slug' })
272
- }
273
-
274
- let mergedData: Record<string, unknown> = {}
275
-
276
- if (Object.keys(bodyForData).length > 0) {
277
- const sensitiveAliases = Object.keys(bodyForData).filter((alias) => {
278
- const branch = seed.branches.find((b) => b.alias === alias)
279
- return branch != null && resolvePolicies(branch).privacy !== 'plain'
280
- })
281
- if (sensitiveAliases.length > 0) {
282
- return publicProblem(c, { type: 'content-sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}` })
283
- }
284
-
285
- const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
286
- operation: 'update', allowNull: true, requireAtLeastOneValidField: true, enforceRequiredFields: true,
287
- })
288
- if (validation.dangerousFields.length > 0) {
289
- return publicProblem(c, { type: 'content-dangerous-content', title: 'Unprocessable Entity', status: 422, detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'` })
290
- }
291
- if (validation.details.length > 0) return contentValidationProblem(c, validation.details)
292
-
293
- let privacyPatch: Record<string, unknown>
294
- try {
295
- privacyPatch = await applyPrivacy(validation.data, seed)
296
- } catch (err) {
297
- if (err instanceof PrivacyPolicyError) {
298
- return publicProblem(c, { type: 'content-policy-not-implemented', title: 'Not Implemented', status: 501, detail: (err as PrivacyPolicyError).message })
299
- }
300
- throw err
301
- }
302
- // Remove null values from patch (patch semantics: null = leave unchanged)
303
- for (const [k, v] of Object.entries(privacyPatch)) {
304
- if (v !== null) mergedData[k] = v
305
- }
306
- }
307
-
308
- if (newSlug !== current.slug) {
309
- const existing = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? AND id != ?`).bind(newSlug, id).first()
310
- if (existing) {
311
- return publicProblem(c, { type: 'content-slug-conflict', title: 'Conflict', status: 409, detail: CONTENT_ERRORS.SLUG_CONFLICT })
312
- }
313
- }
314
-
315
- const { setClause, bindings } = buildUpdateBindings(seed, mergedData)
316
- const systemSet = `slug = ?, status = ?, updated_at = ?`
317
- const fullSet = setClause ? `${systemSet}, ${setClause}` : systemSet
318
-
319
- await DB.prepare(`UPDATE ${table} SET ${fullSet} WHERE id = ?`)
320
- .bind(newSlug, newStatus, now, ...bindings, id)
321
- .run()
322
-
323
- const userId = c.get('jwtPayload')?.sub
324
- logContentEvent(DB, { action: 'update', schemaSlug: slug, entryId: id, userId, details: { title: mergedData.title || mergedData.name || newSlug } }).catch(() => {})
325
- logActivity(c, { action: 'update', entityType: 'content', entityId: id, entitySlug: slug, details: { title: mergedData.title || mergedData.name || newSlug } })
326
-
327
- return c.json({ success: true })
328
- } catch (err) {
329
- console.error('Content update error:', err)
330
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
331
- }
332
- })
333
-
334
- // DELETE /:slug/:id — Eliminazione + cleanup R2
335
- contentApp.delete('/:slug/:id', async (c) => {
336
- const schemaSlug = c.req.param('slug')
337
- const entryId = c.req.param('id')
338
- if (!schemaSlug || !entryId) {
339
- return publicProblem(c, { type: 'content-invalid-slug-or-id', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID })
340
- }
341
-
342
- const seed = c.get('getSeed')(schemaSlug)
343
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
344
-
345
- const table = `content_${schemaSlug}`
346
-
347
- try {
348
- const { DB } = c.env
349
-
350
- const row = await DB.prepare(`SELECT * FROM ${table} WHERE id = ?`).bind(entryId).first<Record<string, unknown>>()
351
- if (!row) {
352
- return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.NOT_FOUND })
353
- }
354
-
355
- const result = await DB.prepare(`DELETE FROM ${table} WHERE id = ?`).bind(entryId).run()
356
-
357
- if (!result.success) throw new Error('Database deletion failed unexpectedly')
358
- if (!result.meta?.changes) {
359
- return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.NOT_FOUND })
360
- }
361
-
362
- const aliasData = rowToApiData(seed, row)
363
- const userId = c.get('jwtPayload')?.sub
364
- logContentEvent(DB, { action: 'delete', schemaSlug, entryId, userId, details: { title: aliasData.title || aliasData.name || entryId } }).catch(() => {})
365
- logActivity(c, { action: 'delete', entityType: 'content', entityId: entryId, entitySlug: schemaSlug, details: { title: aliasData.title || aliasData.name || entryId } })
366
-
367
- // Cleanup R2 — usa alias data (colonne reali già deserializzate)
368
- const r2ObjectKeys = extractMediaKeysFromData(seed, aliasData)
369
- if (r2ObjectKeys.length > 0) {
370
- await deleteR2Objects(c.env, r2ObjectKeys).catch((err) => {
371
- if (c.env.ENV !== 'production') console.warn('R2 cleanup on delete failed (orphaned files):', err)
372
- })
373
- }
374
-
375
- return c.json({ success: true })
376
- } catch (err) {
377
- console.error('Content delete error:', err)
378
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
379
- }
380
- })
381
-
382
- // GET /:slug — Lista con filtri, sort, pagination
383
- contentApp.get('/:slug', async (c) => {
384
- const slug = c.req.param('slug')
385
- if (!slug) return publicProblem(c, { type: 'content-invalid-slug', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG })
386
-
387
- const seed = c.get('getSeed')(slug)
388
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
389
-
390
- try {
391
- const { DB } = c.env
392
- const query = c.req.query()
393
-
394
- const search = cleanStr(query.search) ?? ''
395
- const sortBy = cleanStr(query.sortBy) ?? ''
396
- const sortDirRaw = cleanStr(query.sortDir)?.toLowerCase() ?? 'asc'
397
- const rawFilters = parseQueryFilters(query.filters)
398
- const engineFilters = toEngineFilters(rawFilters)
399
- const hasPendingDraftFilter = query.has_pending_draft === '1' || query.has_pending_draft === 'true'
400
-
401
- const page = parsePositiveInt(query.page, 1)
402
- const limit = Math.min(parsePositiveInt(query.limit, 25), 100)
403
- const offset = (page - 1) * limit
404
- const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || query.page !== undefined || query.limit !== undefined || hasPendingDraftFilter
405
-
406
- const orderBy = sortBy
407
- ? { column: sortBy, dir: (sortDirRaw === 'desc' ? 'DESC' : 'ASC') as 'ASC' | 'DESC' }
408
- : undefined
409
-
410
- // has_pending_draft: EXISTS subquery per seeds con allowDrafts
411
- const draftSubquery = seed.allowDrafts
412
- ? `, CASE WHEN EXISTS (SELECT 1 FROM content_${slug}_drafts d WHERE d.entry_id = content_${slug}.id) THEN 1 ELSE 0 END as has_pending_draft`
413
- : ', 0 as has_pending_draft'
414
-
415
- const { sql: baseSql, bindings: baseBindings } = buildSelectQuery(seed, {
416
- filters: engineFilters,
417
- orderBy,
418
- search: search || undefined,
419
- pagination: hasQueryParams ? { limit, offset } : undefined,
420
- })
421
-
422
- // Inject has_pending_draft subquery into SELECT
423
- const listSql = baseSql.replace(
424
- `SELECT content_${slug}.* FROM content_${slug}`,
425
- `SELECT content_${slug}.*${draftSubquery} FROM content_${slug}`
426
- )
427
-
428
- // has_pending_draft filter (JOIN on drafts table)
429
- let finalSql = listSql
430
- const finalBindings = [...baseBindings]
431
- if (hasPendingDraftFilter && seed.allowDrafts) {
432
- // NOTE: use lastIndexOf to skip the draftSubquery's `FROM content_${slug}_drafts`
433
- // (which starts with the same prefix) and land on the real main-table FROM.
434
- const fromIdx = listSql.lastIndexOf(` FROM content_${slug}`)
435
- const afterFrom = fromIdx >= 0 ? listSql.slice(fromIdx) : listSql
436
- const hasTopLevelWhere = / WHERE /i.test(afterFrom.replace(/\(SELECT[^)]+\)/gi, ''))
437
- const whereOrAnd = hasTopLevelWhere ? ' AND' : ' WHERE'
438
- const orderByIdx = listSql.lastIndexOf(' ORDER BY ')
439
- if (orderByIdx >= 0) {
440
- finalSql =
441
- listSql.slice(0, orderByIdx) +
442
- `${whereOrAnd} EXISTS (SELECT 1 FROM content_${slug}_drafts d WHERE d.entry_id = content_${slug}.id)` +
443
- listSql.slice(orderByIdx)
444
- } else {
445
- finalSql += `${whereOrAnd} EXISTS (SELECT 1 FROM content_${slug}_drafts d WHERE d.entry_id = content_${slug}.id)`
446
- }
447
- }
448
-
449
- let total = 0
450
- if (hasQueryParams) {
451
- const { sql: countSql, bindings: countBindings } = buildSelectQuery(seed, {
452
- filters: engineFilters,
453
- search: search || undefined,
454
- })
455
- const countWrapped = `SELECT COUNT(*) as total FROM (${countSql})`
456
- const countRow = await DB.prepare(countWrapped).bind(...countBindings).first<{ total: number }>()
457
- total = countRow?.total ?? 0
458
- }
459
-
460
- const result = await DB.prepare(finalSql).bind(...finalBindings).all<Record<string, unknown>>()
461
-
462
- const entries: ContentEntry[] = (result.results ?? []).map((row) => {
463
- const pending = (row.has_pending_draft as number) === 1
464
- const entry = rowToEntry(seed, row, pending)
465
- return { ...entry, data: applyVisibility(entry.data, seed) }
466
- })
467
-
468
- if (!hasQueryParams) return c.json(entries)
469
- return c.json({ items: entries, total, page, limit })
470
- } catch (err) {
471
- console.error('Content list error:', err)
472
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
473
- }
474
- })
475
-
476
- // GET /:slug/:id — Dettaglio singola entry
477
- contentApp.get('/:slug/:id', async (c) => {
478
- const slug = c.req.param('slug')
479
- const id = c.req.param('id')
480
- if (!slug || !id) {
481
- return publicProblem(c, { type: 'content-invalid-slug-or-id', title: 'Bad Request', status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID })
482
- }
483
-
484
- const seed = c.get('getSeed')(slug)
485
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND })
486
-
487
- try {
488
- const { DB } = c.env
489
- const row = await DB.prepare(`SELECT * FROM content_${slug} WHERE id = ?`).bind(id).first<Record<string, unknown>>()
490
-
491
- if (!row) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: CONTENT_ERRORS.NOT_FOUND })
492
-
493
- const pending = await hasDraft(DB, seed, id)
494
- const entry = rowToEntry(seed, row, pending)
495
- return c.json({ ...entry, data: applyVisibility(entry.data, seed) })
496
- } catch (err) {
497
- console.error('Content detail error:', err)
498
- return publicProblem(c, { type: 'content-database-error', title: 'Internal Server Error', status: 500, detail: CONTENT_ERRORS.DATABASE_ERROR })
499
- }
500
- })
501
-
502
- export const contentRoutes = contentApp