@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.
- package/assets/dashboard/assets/index-CTSuGxlX.js +554 -0
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/package.json +2 -2
- package/src/factory.ts +96 -85
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +163 -0
- package/src/features/content/handlers/delete.ts +85 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +216 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +203 -129
- package/src/features/settings/settings.handler.ts +18 -0
- package/src/index.ts +16 -3
- package/src/middleware/repository.middleware.ts +18 -0
- package/src/public/public-add.ts +72 -89
- package/src/public/public-edit.ts +51 -76
- package/src/public/public-read.ts +113 -114
- package/src/public/query-builder.ts +47 -136
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/idempotency.repository.d1.ts +45 -0
- package/src/types.ts +6 -1
- package/src/upload.ts +3 -7
- package/assets/dashboard/assets/index-CFTJe1vb.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- 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
|