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

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.
@@ -1,198 +1,272 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
- import { validateAndSanitizeSeedPayload, resolvePolicies, serializeForDb, deserializeFromDb } from '@beechcms/core'
4
- import type { Seed } from '@beechcms/core'
3
+ import {
4
+ validateAndSanitizeSeedPayload,
5
+ resolvePolicies,
6
+ EntryNotFoundError
7
+ } from '@beechcms/core'
5
8
  import { publicProblem } from '../../public/problem-details'
6
9
  import { logActivity } from '../../shared/activity-logger'
7
10
  import { cleanStr } from '../../shared/query-utils'
8
11
  import { applyVisibility } from '../../shared/apply-policies'
12
+ import { AppEnv } from '../../types'
13
+ import { CONTENT_ERRORS } from '../content/constants'
9
14
 
10
- type Bindings = { DB: D1Database }
11
- type Variables = {
12
- jwtPayload: { sub: string; email?: string }
13
- getSeed: (slug: string) => Seed | null
14
- seedRegistry: Record<string, Seed>
15
- }
16
-
17
- const draftApp = new Hono<{ Bindings: Bindings; Variables: Variables }>()
15
+ const draftApp = new Hono<AppEnv>()
18
16
 
19
17
  function normalizeBody(raw: unknown): Record<string, unknown> {
20
18
  return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
21
19
  }
22
20
 
23
- function draftNotAllowed(c: Parameters<typeof publicProblem>[0]) {
24
- return publicProblem(c, {
25
- type: 'draft-not-allowed', title: 'Method Not Allowed', status: 405,
21
+ function draftNotAllowed(context: any) {
22
+ return publicProblem(context, {
23
+ type: 'draft-not-allowed',
24
+ title: 'Method Not Allowed',
25
+ status: 405,
26
26
  detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
27
27
  })
28
28
  }
29
29
 
30
30
  // PUT /:slug/:id/draft — crea o sovrascrive la bozza in content_{slug}_drafts
31
- draftApp.put('/:slug/:id/draft', async (c) => {
32
- const slug = c.req.param('slug')
33
- const id = c.req.param('id')
34
-
35
- const seed = c.get('getSeed')(slug)
36
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
37
- if (!seed.allowDrafts) return draftNotAllowed(c)
31
+ draftApp.put('/:slug/:id/draft', async (context) => {
32
+ const slug = context.req.param('slug')
33
+ const id = context.req.param('id')
34
+
35
+ const seed = context.get('getSeed')(slug)
36
+ if (!seed) {
37
+ return publicProblem(context, {
38
+ type: 'content-seed-not-found',
39
+ title: 'Not Found',
40
+ status: 404,
41
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
42
+ })
43
+ }
44
+
45
+ if (!seed.allowDrafts) return draftNotAllowed(context)
38
46
 
39
47
  let body: Record<string, unknown>
40
48
  try {
41
- body = normalizeBody(await c.req.json<unknown>())
49
+ body = normalizeBody(await context.req.json<unknown>())
42
50
  } catch {
43
- return publicProblem(c, { type: 'content-invalid-json', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
51
+ return publicProblem(context, {
52
+ type: 'content-invalid-json',
53
+ title: 'Bad Request',
54
+ status: 400,
55
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
56
+ })
44
57
  }
45
58
 
46
- const { DB } = c.env
47
- const existing = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first<{ id: string }>()
48
- if (!existing) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
59
+ const repository = context.get('repository')
60
+ try {
61
+ // Verify entry existence
62
+ await repository.findById(seed, id)
63
+ } catch (error) {
64
+ if (error instanceof EntryNotFoundError) {
65
+ return publicProblem(context, {
66
+ type: 'content-not-found',
67
+ title: 'Not Found',
68
+ status: 404,
69
+ detail: CONTENT_ERRORS.NOT_FOUND
70
+ })
71
+ }
72
+ throw error
73
+ }
49
74
 
50
75
  const sensitiveAliases = Object.keys(body).filter((alias) => {
51
76
  const branch = seed.branches.find((b) => b.alias === alias)
52
77
  return branch != null && resolvePolicies(branch).privacy !== 'plain'
53
78
  })
79
+
54
80
  if (sensitiveAliases.length > 0) {
55
- return publicProblem(c, { type: 'content-sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot draft sensitive fields: ${sensitiveAliases.join(', ')}` })
81
+ return publicProblem(context, {
82
+ type: 'content-sensitive-field-edit',
83
+ title: 'Unprocessable Entity',
84
+ status: 422,
85
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
86
+ })
56
87
  }
57
88
 
58
89
  const validation = validateAndSanitizeSeedPayload(seed, body, {
59
- operation: 'update', allowNull: true, requireAtLeastOneValidField: true, enforceRequiredFields: false,
90
+ operation: 'update',
91
+ allowNull: true,
92
+ requireAtLeastOneValidField: true,
93
+ enforceRequiredFields: false,
60
94
  })
95
+
61
96
  if (validation.dangerousFields.length > 0) {
62
- return publicProblem(c, { type: 'content-dangerous-content', title: 'Unprocessable Entity', status: 422, detail: `Dangerous markup in field '${validation.dangerousFields[0]}'` })
97
+ return publicProblem(context, {
98
+ type: 'content-dangerous-content',
99
+ title: 'Unprocessable Entity',
100
+ status: 422,
101
+ detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
102
+ })
63
103
  }
104
+
64
105
  if (validation.details.length > 0) {
65
- return publicProblem(c, { type: 'content-validation-failed', title: 'Bad Request', status: 400, detail: 'Validation failed', errors: validation.details })
66
- }
67
-
68
- // UPSERT in content_{slug}_drafts — solo colonne branch, nullable
69
- const draftTable = `content_${slug}_drafts`
70
- const cols: string[] = []
71
- const placeholders: string[] = []
72
- const bindings: (string | number | null)[] = []
73
- for (const branch of seed.branches) {
74
- if (Object.hasOwn(validation.data, branch.alias)) {
75
- cols.push(branch.alias)
76
- placeholders.push('?')
77
- bindings.push(serializeForDb(branch, validation.data[branch.alias]))
78
- }
106
+ return publicProblem(context, {
107
+ type: 'content-validation-failed',
108
+ title: 'Bad Request',
109
+ status: 400,
110
+ detail: 'Validation failed',
111
+ errors: validation.details
112
+ })
79
113
  }
80
114
 
81
- const now = Math.floor(Date.now() / 1000)
82
- const updateSet = cols.map((c) => `${c} = excluded.${c}`).join(', ')
83
- await DB.prepare(
84
- `INSERT INTO ${draftTable} (entry_id, ${cols.join(', ')}, updated_at)
85
- VALUES (?, ${placeholders.join(', ')}, ?)
86
- ON CONFLICT(entry_id) DO UPDATE SET ${updateSet}, updated_at = excluded.updated_at`
87
- ).bind(id, ...bindings, now).run()
88
-
89
- logActivity(c, {
90
- action: 'update', entityType: 'content', entityId: id, entitySlug: slug,
91
- details: { title: cleanStr(validation.data[seed.displayNameAlias]) ?? id, note: 'draft saved' },
115
+ await repository.saveDraft(seed, id, validation.data)
116
+
117
+ logActivity(context, {
118
+ action: 'update',
119
+ entityType: 'content',
120
+ entityId: id,
121
+ entitySlug: slug,
122
+ details: {
123
+ title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
124
+ note: 'draft saved'
125
+ },
92
126
  })
93
127
 
94
- return c.json({ success: true })
128
+ return context.json({ success: true })
95
129
  })
96
130
 
97
131
  // GET /:slug/:id/draft — legge la bozza pendente
98
- draftApp.get('/:slug/:id/draft', async (c) => {
99
- const slug = c.req.param('slug')
100
- const id = c.req.param('id')
101
-
102
- const seed = c.get('getSeed')(slug)
103
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
104
- if (!seed.allowDrafts) return draftNotAllowed(c)
105
-
106
- const { DB } = c.env
107
- const row = await DB.prepare(`SELECT * FROM content_${slug}_drafts WHERE entry_id = ?`)
108
- .bind(id)
109
- .first<Record<string, unknown>>()
110
-
111
- if (!row) {
112
- // Verifica se entry esiste
113
- const entry = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first()
114
- if (!entry) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
115
- return publicProblem(c, { type: 'draft-not-found', title: 'Not Found', status: 404, detail: 'No pending draft for this entry' })
132
+ draftApp.get('/:slug/:id/draft', async (context) => {
133
+ const slug = context.req.param('slug')
134
+ const id = context.req.param('id')
135
+
136
+ const seed = context.get('getSeed')(slug)
137
+ if (!seed) {
138
+ return publicProblem(context, {
139
+ type: 'content-seed-not-found',
140
+ title: 'Not Found',
141
+ status: 404,
142
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
143
+ })
116
144
  }
117
-
118
- // Deserializza colonne branch reali
119
- const data: Record<string, unknown> = {}
120
- for (const branch of seed.branches) {
121
- if (Object.hasOwn(row, branch.alias)) {
122
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
145
+
146
+ if (!seed.allowDrafts) return draftNotAllowed(context)
147
+
148
+ const repository = context.get('repository')
149
+ const draft = await repository.getDraft(seed, id)
150
+
151
+ if (!draft) {
152
+ try {
153
+ await repository.findById(seed, id)
154
+ return publicProblem(context, {
155
+ type: 'draft-not-found',
156
+ title: 'Not Found',
157
+ status: 404,
158
+ detail: 'No pending draft for this entry'
159
+ })
160
+ } catch (error) {
161
+ if (error instanceof EntryNotFoundError) {
162
+ return publicProblem(context, {
163
+ type: 'content-not-found',
164
+ title: 'Not Found',
165
+ status: 404,
166
+ detail: CONTENT_ERRORS.NOT_FOUND
167
+ })
168
+ }
169
+ throw error
123
170
  }
124
171
  }
125
172
 
126
- return c.json({ data: applyVisibility(data, seed) })
173
+ return context.json({ data: applyVisibility(draft, seed) })
127
174
  })
128
175
 
129
176
  // POST /:slug/:id/draft/publish — promuove bozza → live atomicamente
130
- draftApp.post('/:slug/:id/draft/publish', async (c) => {
131
- const slug = c.req.param('slug')
132
- const id = c.req.param('id')
133
-
134
- const seed = c.get('getSeed')(slug)
135
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
136
- if (!seed.allowDrafts) return draftNotAllowed(c)
137
-
138
- const { DB } = c.env
139
- const draftRow = await DB.prepare(`SELECT * FROM content_${slug}_drafts WHERE entry_id = ?`)
140
- .bind(id)
141
- .first<Record<string, unknown>>()
142
-
143
- if (!draftRow) {
144
- const entry = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first()
145
- if (!entry) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
146
- return publicProblem(c, { type: 'draft-not-found', title: 'Not Found', status: 404, detail: 'No pending draft to publish' })
177
+ draftApp.post('/:slug/:id/draft/publish', async (context) => {
178
+ const slug = context.req.param('slug')
179
+ const id = context.req.param('id')
180
+
181
+ const seed = context.get('getSeed')(slug)
182
+ if (!seed) {
183
+ return publicProblem(context, {
184
+ type: 'content-seed-not-found',
185
+ title: 'Not Found',
186
+ status: 404,
187
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
188
+ })
147
189
  }
148
-
149
- // Costruisce SET clause dal draft per UPDATE atomico
150
- const now = Math.floor(Date.now() / 1000)
151
- const setParts: string[] = ['status = ?', 'updated_at = ?']
152
- const setBindings: (string | number | null)[] = ['published', now]
153
-
154
- for (const branch of seed.branches) {
155
- if (Object.hasOwn(draftRow, branch.alias) && draftRow[branch.alias] !== null) {
156
- setParts.push(`${branch.alias} = ?`)
157
- setBindings.push(draftRow[branch.alias] as string | number | null)
190
+
191
+ if (!seed.allowDrafts) return draftNotAllowed(context)
192
+
193
+ const repository = context.get('repository')
194
+ const draft = await repository.getDraft(seed, id)
195
+
196
+ if (!draft) {
197
+ try {
198
+ await repository.findById(seed, id)
199
+ return publicProblem(context, {
200
+ type: 'draft-not-found',
201
+ title: 'Not Found',
202
+ status: 404,
203
+ detail: 'No pending draft to publish'
204
+ })
205
+ } catch (error) {
206
+ if (error instanceof EntryNotFoundError) {
207
+ return publicProblem(context, {
208
+ type: 'content-not-found',
209
+ title: 'Not Found',
210
+ status: 404,
211
+ detail: CONTENT_ERRORS.NOT_FOUND
212
+ })
213
+ }
214
+ throw error
158
215
  }
159
216
  }
160
217
 
161
- await DB.batch([
162
- DB.prepare(`UPDATE content_${slug} SET ${setParts.join(', ')} WHERE id = ?`)
163
- .bind(...setBindings, id),
164
- DB.prepare(`DELETE FROM content_${slug}_drafts WHERE entry_id = ?`)
165
- .bind(id),
166
- ])
218
+ await repository.publishDraft(seed, id)
167
219
 
168
- // Deserializza per activity log
169
- const displayValue = draftRow[seed.displayNameAlias]
220
+ const displayValue = draft[seed.displayNameAlias]
170
221
  const displayStr = typeof displayValue === 'string' ? displayValue : id
171
222
 
172
- logActivity(c, {
173
- action: 'update', entityType: 'content', entityId: id, entitySlug: slug,
223
+ logActivity(context, {
224
+ action: 'update',
225
+ entityType: 'content',
226
+ entityId: id,
227
+ entitySlug: slug,
174
228
  details: { title: displayStr, note: 'draft published' },
175
229
  })
176
230
 
177
- return c.json({ success: true })
231
+ return context.json({ success: true })
178
232
  })
179
233
 
180
234
  // DELETE /:slug/:id/draft — scarta la bozza pendente
181
- draftApp.delete('/:slug/:id/draft', async (c) => {
182
- const slug = c.req.param('slug')
183
- const id = c.req.param('id')
184
-
185
- const seed = c.get('getSeed')(slug)
186
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
187
- if (!seed.allowDrafts) return draftNotAllowed(c)
235
+ draftApp.delete('/:slug/:id/draft', async (context) => {
236
+ const slug = context.req.param('slug')
237
+ const id = context.req.param('id')
238
+
239
+ const seed = context.get('getSeed')(slug)
240
+ if (!seed) {
241
+ return publicProblem(context, {
242
+ type: 'content-seed-not-found',
243
+ title: 'Not Found',
244
+ status: 404,
245
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
246
+ })
247
+ }
248
+
249
+ if (!seed.allowDrafts) return draftNotAllowed(context)
188
250
 
189
- const { DB } = c.env
190
- const existing = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first<{ id: string }>()
191
- if (!existing) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
251
+ const repository = context.get('repository')
252
+ try {
253
+ await repository.findById(seed, id)
254
+ } catch (error) {
255
+ if (error instanceof EntryNotFoundError) {
256
+ return publicProblem(context, {
257
+ type: 'content-not-found',
258
+ title: 'Not Found',
259
+ status: 404,
260
+ detail: CONTENT_ERRORS.NOT_FOUND
261
+ })
262
+ }
263
+ throw error
264
+ }
192
265
 
193
- await DB.prepare(`DELETE FROM content_${slug}_drafts WHERE entry_id = ?`).bind(id).run()
266
+ await repository.deleteDraft(seed, id)
194
267
 
195
- return c.json({ success: true })
268
+ return context.json({ success: true })
196
269
  })
197
270
 
198
271
  export { draftApp }
272
+
@@ -43,6 +43,24 @@ type OrphanRow = {
43
43
  }
44
44
 
45
45
 
46
+ // GET /api/settings
47
+ settingsApp.get('/', async (c) => {
48
+ // General site configuration.
49
+ // In the future, these could be loaded from a 'system_settings' table in D1.
50
+ return c.json({
51
+ siteTitle: 'Beech CMS',
52
+ siteLogo: '/beechLogoDark.svg',
53
+ defaultLanguage: 'it',
54
+ dateFormat: c.env.DATE_FORMAT || 'DD-MM-YYYY',
55
+ features: {
56
+ drafts: true,
57
+ media: true,
58
+ search: true,
59
+ activityLog: true
60
+ }
61
+ })
62
+ })
63
+
46
64
  // GET /api/settings/me
47
65
  settingsApp.get('/me', async (c) => {
48
66
  const { sub } = c.get('jwtPayload')
package/src/index.ts CHANGED
@@ -2,10 +2,23 @@ import { createBeechApp } from './factory'
2
2
 
3
3
  /**
4
4
  * Entry point per lo sviluppo locale del monorepo.
5
- * In produzione (progetto utente), viene usato worker.ts che importa createBeechApp.
5
+ * Carica dinamicamente seed.ts o seeds.ts dalla root di apps/api se presenti.
6
6
  */
7
- const app = createBeechApp({ seeds: [] })
7
+ let seeds: any = []
8
8
 
9
- app.get('/', (c) => c.text('Beech API is running (Production Ready Mode)'))
9
+ try {
10
+ // @ts-ignore
11
+ const mod = await import('../seed.ts')
12
+ const registry = mod.default || mod.SEED_REGISTRY || mod
13
+ seeds = (typeof registry === 'object' && !Array.isArray(registry))
14
+ ? Object.values(registry)
15
+ : registry
16
+ } catch (e) {
17
+ // Fallback se seed.ts non esiste
18
+ }
19
+
20
+ const app = createBeechApp({ seeds })
21
+
22
+ app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
10
23
 
11
24
  export default app
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { D1ContentRepository } from '../shared/content.repository.d1'
3
+ import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
4
+ import type { ContentRepository, IdempotencyRepository } from '@beechcms/core'
5
+ import type { Env, Variables } from '../types'
6
+
7
+ interface RepositoryOverrides {
8
+ repository?: ContentRepository
9
+ idempotencyRepository?: IdempotencyRepository
10
+ }
11
+
12
+ export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
13
+ return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
14
+ context.set('repository', overrides?.repository ?? new D1ContentRepository(context.env.DB))
15
+ context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(context.env.DB))
16
+ await next()
17
+ })
18
+ }