@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.
- package/assets/dashboard/assets/{index-CFTJe1vb.js → index-9Ch2xWJr.js} +122 -122
- 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-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/public/public-add.ts
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import { isValidContentStatus } from '@beechcms/core'
|
|
2
|
-
import type { Seed } from '@beechcms/core'
|
|
1
|
+
import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
|
|
3
2
|
import type { Context } from 'hono'
|
|
4
3
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { buildInsertBindings } from '../shared/content-utils'
|
|
6
4
|
import { checkPublicOperation } from './access-policy'
|
|
7
5
|
import { publicProblem } from './problem-details'
|
|
8
6
|
import { generateEntrySlug, slugify } from './slug-utils'
|
|
9
7
|
import { sanitizePublicPayload } from './sanitize'
|
|
10
8
|
import { createNotification } from '../shared/notification-service'
|
|
9
|
+
import { AppEnv } from '../types'
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
PUBLIC_READ_API_KEY?: string
|
|
15
|
-
PUBLIC_WRITE_API_KEY?: string
|
|
16
|
-
PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
|
|
17
|
-
ENV?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
type Variables = {
|
|
21
|
-
jwtPayload: { sub: string; email?: string }
|
|
22
|
-
getSeed: (slug: string) => Seed | null
|
|
23
|
-
seedRegistry: Record<string, Seed>
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function errorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
|
|
27
|
-
if (c.env.ENV !== 'production' && err instanceof Error) return err.message
|
|
11
|
+
function errorMessage(context: Context<AppEnv>, error: unknown): string {
|
|
12
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
28
13
|
return 'An unexpected error occurred.'
|
|
29
14
|
}
|
|
30
15
|
|
|
@@ -57,127 +42,125 @@ async function sha256Hex(input: string): Promise<string> {
|
|
|
57
42
|
return toHex(digest)
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
response_status: number
|
|
64
|
-
response_body: string
|
|
65
|
-
expires_at: number
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function lookupIdempotency(db: D1Database, key: string): Promise<StoredIdempotencyRecord | null> {
|
|
69
|
-
return db.prepare(
|
|
70
|
-
`SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
|
|
71
|
-
FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
|
|
72
|
-
).bind(key).first<StoredIdempotencyRecord>()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function storeIdempotency(db: D1Database, input: {
|
|
76
|
-
key: string; fingerprint: string; responseStatus: number
|
|
77
|
-
responseBody: string; expiresAt: number; createdAt: number
|
|
78
|
-
}): Promise<void> {
|
|
79
|
-
await db.prepare(
|
|
80
|
-
`INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
|
|
81
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
82
|
-
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
83
|
-
request_fingerprint = excluded.request_fingerprint,
|
|
84
|
-
response_status = excluded.response_status,
|
|
85
|
-
response_body = excluded.response_body,
|
|
86
|
-
created_at = excluded.created_at,
|
|
87
|
-
expires_at = excluded.expires_at`
|
|
88
|
-
).bind(input.key, input.fingerprint, input.responseStatus, input.responseBody, input.createdAt, input.expiresAt).run()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export async function publicAddHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
|
|
92
|
-
const seedSlug = c.req.param('seed') ?? ''
|
|
93
|
-
const seed = c.get('getSeed')(seedSlug)
|
|
45
|
+
export async function publicAddHandler(context: Context<AppEnv>) {
|
|
46
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
47
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
94
48
|
if (!seed) {
|
|
95
|
-
return publicProblem(
|
|
49
|
+
return publicProblem(context, {
|
|
50
|
+
type: 'seed-not-found',
|
|
51
|
+
title: 'Seed Not Found',
|
|
52
|
+
status: 404,
|
|
53
|
+
detail: `The content type '${seedSlug}' does not exist.`
|
|
54
|
+
})
|
|
96
55
|
}
|
|
56
|
+
|
|
97
57
|
const access = checkPublicOperation(seed, 'add')
|
|
98
58
|
if (!access.ok) {
|
|
99
|
-
return publicProblem(
|
|
59
|
+
return publicProblem(context, {
|
|
60
|
+
type: 'operation-not-allowed',
|
|
61
|
+
title: access.error.error,
|
|
62
|
+
status: 403,
|
|
63
|
+
detail: access.error.message
|
|
64
|
+
})
|
|
100
65
|
}
|
|
101
66
|
|
|
102
67
|
let body: Record<string, unknown>
|
|
103
68
|
try {
|
|
104
|
-
const parsed = await
|
|
69
|
+
const parsed = await context.req.json<unknown>()
|
|
105
70
|
body = asRecord(parsed) ?? {}
|
|
106
71
|
} catch {
|
|
107
|
-
return publicProblem(
|
|
72
|
+
return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
|
|
108
73
|
}
|
|
109
74
|
|
|
110
75
|
const rawData = asRecord(body.data)
|
|
111
76
|
if (!rawData || Object.keys(rawData).length === 0) {
|
|
112
|
-
return publicProblem(
|
|
77
|
+
return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
|
|
113
78
|
}
|
|
114
79
|
|
|
115
80
|
const statusValue = body.status ?? 'draft'
|
|
116
81
|
if (!isValidContentStatus(statusValue)) {
|
|
117
|
-
return publicProblem(
|
|
82
|
+
return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
|
|
118
83
|
}
|
|
119
84
|
|
|
120
85
|
const sanitized = sanitizePublicPayload(seed, rawData, {
|
|
121
|
-
operation: 'create',
|
|
86
|
+
operation: 'create',
|
|
87
|
+
allowNull: false,
|
|
88
|
+
requireAtLeastOneValidField: true,
|
|
89
|
+
enforceRequiredFields: true,
|
|
122
90
|
})
|
|
91
|
+
|
|
123
92
|
if (!sanitized.ok) {
|
|
124
93
|
if (sanitized.status === 422) {
|
|
125
|
-
return publicProblem(
|
|
94
|
+
return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
|
|
126
95
|
}
|
|
127
|
-
return publicProblem(
|
|
96
|
+
return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
|
|
128
97
|
}
|
|
129
98
|
|
|
130
|
-
const idempotencyKey = parseIdempotencyKey(
|
|
99
|
+
const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
|
|
131
100
|
const entrySlug = pickSlugFromBody(body, sanitized.data)
|
|
132
101
|
const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
|
|
133
|
-
const
|
|
102
|
+
const repository = context.get('repository')
|
|
103
|
+
const idempotencyRepository = context.get('idempotencyRepository')
|
|
134
104
|
|
|
135
105
|
try {
|
|
136
|
-
const { DB } = c.env
|
|
137
106
|
const now = Math.floor(Date.now() / 1000)
|
|
138
107
|
const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
|
|
139
108
|
const fingerprint = await sha256Hex(fingerprintPayload)
|
|
140
|
-
const idempotencyTtlSeconds = Math.max(60, Number.parseInt(
|
|
109
|
+
const idempotencyTtlSeconds = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
|
|
141
110
|
|
|
142
111
|
if (idempotencyKey) {
|
|
143
|
-
const existing = await
|
|
144
|
-
if (existing && existing.
|
|
145
|
-
if (existing.
|
|
146
|
-
return publicProblem(
|
|
112
|
+
const existing = await idempotencyRepository.lookup(idempotencyKey)
|
|
113
|
+
if (existing && existing.expiresAt >= now) {
|
|
114
|
+
if (existing.fingerprint !== fingerprint) {
|
|
115
|
+
return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
|
|
147
116
|
}
|
|
148
117
|
let parsedBody: unknown = null
|
|
149
|
-
try { parsedBody = JSON.parse(existing.
|
|
150
|
-
return
|
|
118
|
+
try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
|
|
119
|
+
return context.json(parsedBody, existing.responseStatus as 201)
|
|
151
120
|
}
|
|
152
121
|
}
|
|
153
122
|
|
|
154
|
-
const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? LIMIT 1`).bind(finalSlug).first<{ id: string }>()
|
|
155
|
-
if (slugExisting) {
|
|
156
|
-
return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
|
|
157
|
-
}
|
|
158
|
-
|
|
159
123
|
const id = crypto.randomUUID()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof SlugConflictError) {
|
|
129
|
+
return publicProblem(context, {
|
|
130
|
+
type: 'slug-conflict',
|
|
131
|
+
title: 'Conflict',
|
|
132
|
+
status: 409,
|
|
133
|
+
detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.`
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
167
138
|
|
|
168
139
|
const responseBody = { success: true, id, slug: finalSlug }
|
|
169
140
|
if (idempotencyKey) {
|
|
170
|
-
await
|
|
141
|
+
await idempotencyRepository.store({
|
|
142
|
+
key: idempotencyKey,
|
|
143
|
+
fingerprint,
|
|
144
|
+
responseStatus: 201,
|
|
145
|
+
responseBody: JSON.stringify(responseBody),
|
|
146
|
+
expiresAt: now + idempotencyTtlSeconds
|
|
147
|
+
})
|
|
171
148
|
}
|
|
172
149
|
|
|
173
|
-
await createNotification(
|
|
150
|
+
await createNotification(context, {
|
|
174
151
|
title: `${seed.label}: Nuovo inserimento`,
|
|
175
152
|
message: `Una nuova entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") è stata aggiunta via API pubblica.`,
|
|
176
153
|
type: 'success',
|
|
177
154
|
})
|
|
178
155
|
|
|
179
|
-
return
|
|
180
|
-
} catch (
|
|
181
|
-
|
|
156
|
+
return context.json(responseBody, 201)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('Public add error:', error)
|
|
159
|
+
return publicProblem(context, {
|
|
160
|
+
type: 'internal-server-error',
|
|
161
|
+
title: 'Internal Server Error',
|
|
162
|
+
status: 500,
|
|
163
|
+
detail: errorMessage(context, error)
|
|
164
|
+
})
|
|
182
165
|
}
|
|
183
166
|
}
|
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
import { isValidContentStatus, resolvePolicies } from '@beechcms/core'
|
|
1
|
+
import { isValidContentStatus, resolvePolicies, EntryNotFoundError } from '@beechcms/core'
|
|
2
2
|
import type { Seed } from '@beechcms/core'
|
|
3
3
|
import type { Context } from 'hono'
|
|
4
4
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { rowToApiData, buildUpdateBindings } from '../shared/content-utils'
|
|
6
5
|
import { checkPublicOperation } from './access-policy'
|
|
7
6
|
import { publicProblem } from './problem-details'
|
|
8
7
|
import { slugify } from './slug-utils'
|
|
9
8
|
import { sanitizePublicPayload } from './sanitize'
|
|
10
9
|
import { createNotification } from '../shared/notification-service'
|
|
11
|
-
|
|
12
|
-
type Bindings = {
|
|
13
|
-
DB: D1Database
|
|
14
|
-
PUBLIC_READ_API_KEY?: string
|
|
15
|
-
PUBLIC_WRITE_API_KEY?: string
|
|
16
|
-
ENV?: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type Variables = {
|
|
20
|
-
jwtPayload: { sub: string; email?: string }
|
|
21
|
-
getSeed: (slug: string) => Seed | null
|
|
22
|
-
seedRegistry: Record<string, Seed>
|
|
23
|
-
}
|
|
10
|
+
import { AppEnv } from '../types'
|
|
24
11
|
|
|
25
12
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
26
|
-
type PublicCtx = Context<
|
|
13
|
+
type PublicCtx = Context<AppEnv>
|
|
27
14
|
type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
|
|
28
15
|
|
|
29
|
-
function errorMessage(
|
|
30
|
-
if (
|
|
16
|
+
function errorMessage(context: PublicCtx, error: unknown): string {
|
|
17
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
31
18
|
return 'An unexpected error occurred.'
|
|
32
19
|
}
|
|
33
20
|
|
|
@@ -45,48 +32,47 @@ function removeNullishFields(data: Record<string, unknown>): Record<string, unkn
|
|
|
45
32
|
return next
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
function parseBody(
|
|
49
|
-
return
|
|
35
|
+
function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
|
|
36
|
+
return context.req.json<unknown>()
|
|
50
37
|
.then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
|
|
51
38
|
.catch(() => ({
|
|
52
39
|
ok: false,
|
|
53
|
-
response: publicProblem(
|
|
40
|
+
response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
|
|
54
41
|
}))
|
|
55
42
|
}
|
|
56
43
|
|
|
57
|
-
function resolveSlug(
|
|
44
|
+
function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
|
|
58
45
|
const slugRequested = Object.hasOwn(body, 'slug')
|
|
59
46
|
if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
|
|
60
47
|
const requestedSlug = cleanStr(body.slug)
|
|
61
48
|
if (!requestedSlug) {
|
|
62
|
-
return { ok: false, response: publicProblem(
|
|
49
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
|
|
63
50
|
}
|
|
64
51
|
return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
|
|
65
52
|
}
|
|
66
53
|
|
|
67
|
-
function resolveStatus(
|
|
54
|
+
function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
|
|
68
55
|
if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
|
|
69
56
|
const statusValue = body.status
|
|
70
57
|
if (!isValidContentStatus(statusValue)) {
|
|
71
|
-
return { ok: false, response: publicProblem(
|
|
58
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
|
|
72
59
|
}
|
|
73
|
-
return { ok: true, value: statusValue }
|
|
60
|
+
return { ok: true, value: statusValue as string }
|
|
74
61
|
}
|
|
75
62
|
|
|
76
63
|
function resolveData(
|
|
77
|
-
|
|
64
|
+
context: PublicCtx,
|
|
78
65
|
seed: Seed,
|
|
79
|
-
body: Record<string, unknown
|
|
80
|
-
currentRow: Record<string, unknown>
|
|
66
|
+
body: Record<string, unknown>
|
|
81
67
|
): ResolveResult<Record<string, unknown>> {
|
|
82
68
|
if (!Object.hasOwn(body, 'data')) {
|
|
83
|
-
// No data update — return empty patch
|
|
69
|
+
// No data update — return empty patch
|
|
84
70
|
return { ok: true, value: {} }
|
|
85
71
|
}
|
|
86
72
|
|
|
87
73
|
const rawData = asRecord(body.data)
|
|
88
74
|
if (!rawData) {
|
|
89
|
-
return { ok: false, response: publicProblem(
|
|
75
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
|
|
90
76
|
}
|
|
91
77
|
|
|
92
78
|
const sensitiveAliases = Object.keys(rawData).filter((alias) => {
|
|
@@ -94,90 +80,79 @@ function resolveData(
|
|
|
94
80
|
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
95
81
|
})
|
|
96
82
|
if (sensitiveAliases.length > 0) {
|
|
97
|
-
return { ok: false, response: publicProblem(
|
|
83
|
+
return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
|
|
98
84
|
}
|
|
99
85
|
|
|
100
86
|
const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
|
|
101
87
|
if (!sanitized.ok) {
|
|
102
88
|
if (sanitized.status === 422) {
|
|
103
|
-
return { ok: false, response: publicProblem(
|
|
89
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
|
|
104
90
|
}
|
|
105
|
-
return { ok: false, response: publicProblem(
|
|
91
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
|
|
106
92
|
}
|
|
107
93
|
|
|
108
|
-
|
|
109
|
-
const currentAliasData = rowToApiData(seed, currentRow)
|
|
110
|
-
const merged = removeNullishFields({ ...currentAliasData, ...sanitized.data })
|
|
111
|
-
return { ok: true, value: merged }
|
|
94
|
+
return { ok: true, value: removeNullishFields(sanitized.data) }
|
|
112
95
|
}
|
|
113
96
|
|
|
114
|
-
export async function publicEditHandler(
|
|
115
|
-
const seedSlug =
|
|
116
|
-
const id =
|
|
117
|
-
const seed =
|
|
97
|
+
export async function publicEditHandler(context: PublicCtx) {
|
|
98
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
99
|
+
const id = context.req.param('id') ?? ''
|
|
100
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
118
101
|
if (!seed) {
|
|
119
|
-
return publicProblem(
|
|
102
|
+
return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
120
103
|
}
|
|
121
104
|
const access = checkPublicOperation(seed, 'edit')
|
|
122
105
|
if (!access.ok) {
|
|
123
|
-
return publicProblem(
|
|
106
|
+
return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
124
107
|
}
|
|
125
108
|
|
|
126
109
|
if (!UUID_REGEX.test(id)) {
|
|
127
|
-
return publicProblem(
|
|
110
|
+
return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
|
|
128
111
|
}
|
|
129
112
|
|
|
130
|
-
const
|
|
113
|
+
const repository = context.get('repository')
|
|
131
114
|
|
|
132
115
|
try {
|
|
133
|
-
const
|
|
134
|
-
const currentRow = await DB.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
|
|
135
|
-
.bind(id)
|
|
136
|
-
.first<Record<string, unknown>>()
|
|
137
|
-
|
|
138
|
-
if (!currentRow) {
|
|
139
|
-
return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
|
|
140
|
-
}
|
|
116
|
+
const entry = await repository.findById(seed, id)
|
|
141
117
|
|
|
142
|
-
const bodyResult = await parseBody(
|
|
118
|
+
const bodyResult = await parseBody(context)
|
|
143
119
|
if (!bodyResult.ok) return bodyResult.response
|
|
144
120
|
|
|
145
|
-
const slugResult = resolveSlug(
|
|
121
|
+
const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
|
|
146
122
|
if (!slugResult.ok) return slugResult.response
|
|
147
123
|
|
|
148
|
-
const statusResult = resolveStatus(
|
|
124
|
+
const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
|
|
149
125
|
if (!statusResult.ok) return statusResult.response
|
|
150
126
|
|
|
151
|
-
const dataResult = resolveData(
|
|
127
|
+
const dataResult = resolveData(context, seed, bodyResult.value)
|
|
152
128
|
if (!dataResult.ok) return dataResult.response
|
|
153
129
|
|
|
154
|
-
if (slugResult.value.slugRequested) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (slugExisting) {
|
|
160
|
-
return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
|
|
130
|
+
if (slugResult.value.slugRequested && slugResult.value.nextSlug !== entry.slug) {
|
|
131
|
+
const exists = await repository.existsSlug(seed, slugResult.value.nextSlug, id)
|
|
132
|
+
if (exists) {
|
|
133
|
+
return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
|
|
161
134
|
}
|
|
162
135
|
}
|
|
163
136
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
137
|
+
const updateData = { ...dataResult.value }
|
|
138
|
+
if (slugResult.value.slugRequested) {
|
|
139
|
+
(updateData as any).slug = slugResult.value.nextSlug
|
|
140
|
+
}
|
|
168
141
|
|
|
169
|
-
await
|
|
170
|
-
.bind(slugResult.value.nextSlug, statusResult.value, now, ...bindings, id)
|
|
171
|
-
.run()
|
|
142
|
+
await repository.update(seed, id, updateData, statusResult.value)
|
|
172
143
|
|
|
173
|
-
await createNotification(
|
|
144
|
+
await createNotification(context, {
|
|
174
145
|
title: `${seed.label}: Modifica`,
|
|
175
146
|
message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
|
|
176
147
|
type: 'info',
|
|
177
148
|
})
|
|
178
149
|
|
|
179
|
-
return
|
|
180
|
-
} catch (
|
|
181
|
-
|
|
150
|
+
return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof EntryNotFoundError) {
|
|
153
|
+
return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
|
|
154
|
+
}
|
|
155
|
+
console.error('Public edit error:', error)
|
|
156
|
+
return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(context, error) })
|
|
182
157
|
}
|
|
183
158
|
}
|