@beechcms/api 0.5.0 → 0.6.0-preview.1
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/Searching.svg +1 -0
- package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
- package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/assets/dashboard/noResult.svg +43 -0
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +27 -0
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
- package/src/auth/bcrypt-hash-provider.ts +6 -2
- package/src/auth/constants.ts +4 -0
- package/src/auth/generate-refresh-token.test.ts +8 -4
- package/src/auth/hash-provider.test.ts +14 -5
- package/src/auth/jose-token-service.ts +7 -0
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +9 -1
- package/src/auth/login.ts +5 -1
- package/src/auth/refresh.ts +7 -1
- package/src/auth/token-service.test.ts +9 -1
- package/src/factory.ts +37 -16
- package/src/features/automations/__tests__/action-executors.test.ts +59 -72
- package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
- package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
- package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
- package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
- package/src/features/automations/action-executors/index.ts +5 -1
- package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
- package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
- package/src/features/automations/action-executors/webhook.executor.ts +55 -13
- package/src/features/automations/automation-runner.ts +4 -0
- package/src/features/automations/automation-runner.utils.ts +4 -0
- package/src/features/automations/automations.handler.ts +4 -0
- package/src/features/automations/automations.schema.ts +19 -2
- package/src/features/automations/context-resolver.ts +4 -0
- package/src/features/automations/cron-runner.ts +4 -0
- package/src/features/automations/cron-runner.utils.ts +4 -0
- package/src/features/automations/filter-translation.ts +4 -0
- package/src/features/automations/index.ts +4 -0
- package/src/features/automations/template-grammar.ts +4 -0
- package/src/features/automations/var-access-resolver.ts +4 -0
- package/src/features/automations/when-evaluator.ts +4 -0
- package/src/features/automations/when-pushdown.ts +4 -0
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +6 -0
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +19 -55
- package/src/features/content/handlers/delete.ts +12 -37
- package/src/features/content/handlers/facets.ts +4 -0
- package/src/features/content/handlers/get.ts +4 -0
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +105 -19
- package/src/features/content/handlers/update.ts +19 -64
- package/src/features/content/index.ts +6 -0
- package/src/features/draft/draft.handler.ts +33 -8
- package/src/features/draft/draft.middleware.ts +4 -0
- package/src/features/draft/index.ts +4 -0
- package/src/features/email/email.provider.ts +4 -0
- package/src/features/email/email.service.ts +50 -45
- package/src/features/email/email.types.ts +12 -1
- package/src/features/email/index.ts +4 -0
- package/src/features/email/providers/resend.ts +4 -0
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +4 -0
- package/src/features/email/templates/password-changed.ts +4 -0
- package/src/features/email/templates/password-reset.ts +4 -0
- package/src/features/email/templates/shell.ts +4 -0
- package/src/features/notifications/index.ts +4 -0
- package/src/features/notifications/notifications.handler.ts +4 -0
- package/src/features/password-reset/index.ts +5 -1
- package/src/features/password-reset/request.ts +12 -2
- package/src/features/password-reset/reset.ts +12 -2
- package/src/features/rotate-field/index.ts +4 -0
- package/src/features/rotate-field/rotate-field.handler.ts +4 -0
- package/src/features/rotate-field/rotate-field.schema.ts +4 -0
- package/src/features/schema/schema.handler.ts +118 -3
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +632 -0
- package/src/features/seeds/seeds.handler.ts +693 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +98 -8
- package/src/features/setup/index.ts +124 -11
- package/src/features/stats/index.ts +4 -0
- package/src/features/stats/stats.handler.ts +11 -21
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +28 -19
- package/src/media-utils.ts +4 -0
- package/src/middleware/auth-providers.middleware.ts +13 -0
- package/src/middleware/observability.middleware.ts +21 -3
- package/src/middleware/rate-limit.middleware.ts +4 -0
- package/src/middleware/repository.middleware.ts +25 -2
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +4 -0
- package/src/middleware.ts +5 -7
- package/src/public/access-policy.ts +4 -0
- package/src/public/api-key-middleware.ts +4 -0
- package/src/public/cache-utils.ts +4 -0
- package/src/public/entry-projection.ts +4 -0
- package/src/public/idempotency.ts +4 -0
- package/src/public/index.ts +4 -0
- package/src/public/problem-details.ts +71 -0
- package/src/public/public-add.ts +4 -0
- package/src/public/public-edit.ts +4 -0
- package/src/public/public-errors.ts +4 -0
- package/src/public/public-read.ts +4 -0
- package/src/public/public-routes.ts +4 -0
- package/src/public/query-builder.test.ts +4 -0
- package/src/public/query-builder.ts +4 -0
- package/src/public/rate-limit-middleware.ts +4 -0
- package/src/public/read-list.ts +4 -0
- package/src/public/read-single.ts +4 -0
- package/src/public/response-builder.ts +4 -0
- package/src/public/sanitize.ts +4 -0
- package/src/public/slug-utils.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.ts +4 -0
- package/src/search-utils.test.ts +4 -0
- package/src/search-utils.ts +4 -0
- package/src/search.ts +4 -0
- package/src/shared/apply-policies.test.ts +4 -0
- package/src/shared/apply-policies.ts +4 -0
- package/src/shared/automations.repository.d1.ts +4 -0
- package/src/shared/background-notification-service.test.ts +4 -0
- package/src/shared/background-notification-service.ts +4 -0
- package/src/shared/base.repository.d1.ts +4 -0
- package/src/shared/content-utils.test.ts +4 -0
- package/src/shared/content-utils.ts +4 -0
- package/src/shared/content.repository.d1.test.ts +147 -1
- package/src/shared/content.repository.d1.ts +535 -66
- package/src/shared/d1-activity-log.repository.test.ts +4 -0
- package/src/shared/d1-activity-log.repository.ts +4 -0
- package/src/shared/d1-activity-logger.test.ts +4 -0
- package/src/shared/d1-activity-logger.ts +4 -0
- package/src/shared/d1-analytics.repository.test.ts +4 -0
- package/src/shared/d1-analytics.repository.ts +4 -0
- package/src/shared/d1-content-scan.repository.test.ts +9 -5
- package/src/shared/d1-content-scan.repository.ts +15 -7
- package/src/shared/d1-notification.repository.test.ts +4 -0
- package/src/shared/d1-notification.repository.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.ts +4 -0
- package/src/shared/d1-search.repository.test.ts +4 -0
- package/src/shared/d1-search.repository.ts +4 -0
- package/src/shared/d1-session.repository.test.ts +4 -0
- package/src/shared/d1-session.repository.ts +4 -0
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +8 -4
- package/src/shared/d1-user.repository.ts +15 -5
- package/src/shared/d1-widget.repository.test.ts +4 -0
- package/src/shared/d1-widget.repository.ts +4 -0
- package/src/shared/demo-data-sql.ts +54 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +4 -0
- package/src/shared/fixed-clock.ts +4 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/idempotency.repository.d1.test.ts +4 -0
- package/src/shared/idempotency.repository.d1.ts +4 -0
- package/src/shared/in-memory-activity-logger.ts +4 -0
- package/src/shared/in-memory-notification-service.ts +4 -0
- package/src/shared/in-memory-seed.repository.ts +51 -0
- package/src/shared/media.repository.d1.test.ts +4 -0
- package/src/shared/media.repository.d1.ts +4 -0
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +4 -0
- package/src/shared/request-utils.ts +4 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +11 -0
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +16 -15
- package/src/shared/storage/s3-bucket.ts +34 -2
- package/src/shared/storage-utils.ts +4 -0
- package/src/shared/system-stats.repository.d1.test.ts +4 -0
- package/src/shared/system-stats.repository.d1.ts +5 -1
- package/src/types.ts +23 -3
- package/src/upload.ts +134 -123
- package/src/widget.ts +6 -2
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- package/src/shared/storage/r2-binding-bucket.ts +0 -81
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { Context } from 'hono'
|
|
6
|
+
import { resolvePolicies, type BulkFieldUpdate } from '@beechcms/core'
|
|
7
|
+
import { publicProblem } from '../../../public/problem-details'
|
|
8
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
9
|
+
import { AppEnv } from '../../../types'
|
|
10
|
+
|
|
11
|
+
const MAX_BULK_SIZE = 500
|
|
12
|
+
|
|
13
|
+
const MULTI_REL_MODES = ['replace', 'add', 'remove'] as const
|
|
14
|
+
type MultiRelMode = (typeof MULTI_REL_MODES)[number]
|
|
15
|
+
|
|
16
|
+
interface MultiRelWrapper {
|
|
17
|
+
mode: MultiRelMode
|
|
18
|
+
value: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isMultiRelWrapper(v: unknown): v is MultiRelWrapper {
|
|
22
|
+
if (typeof v !== 'object' || v === null) return false
|
|
23
|
+
const obj = v as Record<string, unknown>
|
|
24
|
+
return (
|
|
25
|
+
MULTI_REL_MODES.includes(obj.mode as MultiRelMode) &&
|
|
26
|
+
Array.isArray(obj.value) &&
|
|
27
|
+
(obj.value as unknown[]).every((x) => typeof x === 'string')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function bulkHandler(context: Context<AppEnv>) {
|
|
32
|
+
const slug = context.req.param('slug')
|
|
33
|
+
if (!slug) {
|
|
34
|
+
return publicProblem(context, {
|
|
35
|
+
type: 'content-invalid-slug-or-id',
|
|
36
|
+
title: 'Bad Request',
|
|
37
|
+
status: 400,
|
|
38
|
+
detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const seed = context.get('getSeed')(slug)
|
|
43
|
+
if (!seed) {
|
|
44
|
+
return publicProblem(context, {
|
|
45
|
+
type: 'content-seed-not-found',
|
|
46
|
+
title: 'Not Found',
|
|
47
|
+
status: 404,
|
|
48
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let body: Record<string, unknown>
|
|
53
|
+
try {
|
|
54
|
+
body = await context.req.json<Record<string, unknown>>()
|
|
55
|
+
} catch {
|
|
56
|
+
return publicProblem(context, {
|
|
57
|
+
type: 'content-invalid-json',
|
|
58
|
+
title: 'Bad Request',
|
|
59
|
+
status: 400,
|
|
60
|
+
detail: CONTENT_ERRORS.INVALID_JSON_BODY,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Validate ids ─────────────────────────────────────────────────────────────
|
|
65
|
+
const ids = body.ids
|
|
66
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'string')) {
|
|
67
|
+
return publicProblem(context, {
|
|
68
|
+
type: 'bulk-invalid-ids',
|
|
69
|
+
title: 'Bad Request',
|
|
70
|
+
status: 400,
|
|
71
|
+
detail: 'ids must be a non-empty array of strings',
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (ids.length > MAX_BULK_SIZE) {
|
|
76
|
+
return publicProblem(context, {
|
|
77
|
+
type: 'bulk-size-exceeded',
|
|
78
|
+
title: 'Bad Request',
|
|
79
|
+
status: 400,
|
|
80
|
+
detail: `Cannot edit more than ${MAX_BULK_SIZE} entries at once`,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Validate fields ───────────────────────────────────────────────────────────
|
|
85
|
+
const rawFields = body.fields
|
|
86
|
+
if (
|
|
87
|
+
typeof rawFields !== 'object' ||
|
|
88
|
+
rawFields === null ||
|
|
89
|
+
Array.isArray(rawFields) ||
|
|
90
|
+
Object.keys(rawFields as object).length === 0
|
|
91
|
+
) {
|
|
92
|
+
return publicProblem(context, {
|
|
93
|
+
type: 'bulk-invalid-fields',
|
|
94
|
+
title: 'Bad Request',
|
|
95
|
+
status: 400,
|
|
96
|
+
detail: 'fields must be an object with at least one key',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fieldsRaw = rawFields as Record<string, unknown>
|
|
101
|
+
|
|
102
|
+
// Validate each alias exists on the seed and is not hidden/encrypt
|
|
103
|
+
const resolvedFields: Record<string, BulkFieldUpdate> = {}
|
|
104
|
+
|
|
105
|
+
for (const [alias, rawValue] of Object.entries(fieldsRaw)) {
|
|
106
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
107
|
+
if (!branch) {
|
|
108
|
+
return publicProblem(context, {
|
|
109
|
+
type: 'bulk-unknown-field',
|
|
110
|
+
title: 'Bad Request',
|
|
111
|
+
status: 400,
|
|
112
|
+
detail: `Field '${alias}' does not exist on seed '${slug}'`,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const policies = resolvePolicies(branch)
|
|
117
|
+
if (policies.visibility === 'hidden' || policies.privacy === 'encrypt') {
|
|
118
|
+
return publicProblem(context, {
|
|
119
|
+
type: 'field-not-bulk-editable',
|
|
120
|
+
title: 'Bad Request',
|
|
121
|
+
status: 400,
|
|
122
|
+
detail: `Field '${alias}' cannot be bulk-edited (hidden or encrypted)`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Multi-relation branches accept a { mode, value } wrapper or a plain array (treated as replace)
|
|
127
|
+
const branchAny = branch as { type: string; multiple?: boolean }
|
|
128
|
+
if (branchAny.type === 'relation' && branchAny.multiple === true) {
|
|
129
|
+
if (isMultiRelWrapper(rawValue)) {
|
|
130
|
+
const kindMap: Record<MultiRelMode, BulkFieldUpdate['kind']> = {
|
|
131
|
+
replace: 'array_replace',
|
|
132
|
+
add: 'array_add',
|
|
133
|
+
remove: 'array_remove',
|
|
134
|
+
}
|
|
135
|
+
resolvedFields[alias] = { kind: kindMap[rawValue.mode], value: rawValue.value }
|
|
136
|
+
} else if (Array.isArray(rawValue) && (rawValue as unknown[]).every((x) => typeof x === 'string')) {
|
|
137
|
+
resolvedFields[alias] = { kind: 'array_replace', value: rawValue as string[] }
|
|
138
|
+
} else {
|
|
139
|
+
return publicProblem(context, {
|
|
140
|
+
type: 'bulk-invalid-field-value',
|
|
141
|
+
title: 'Bad Request',
|
|
142
|
+
status: 400,
|
|
143
|
+
detail: `Field '${alias}' is a multi-relation and requires { mode, value } or a string array`,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
resolvedFields[alias] = { kind: 'set', value: rawValue }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Execute ───────────────────────────────────────────────────────────────────
|
|
152
|
+
const repository = context.get('repository')
|
|
153
|
+
const { updated, failed } = await repository.bulkUpdate(slug, ids as string[], resolvedFields)
|
|
154
|
+
|
|
155
|
+
// ── Activity log (single entry for the whole operation) ───────────────────────
|
|
156
|
+
const jwtPayload = context.get('jwtPayload')
|
|
157
|
+
context.get('activityLogger').log({
|
|
158
|
+
action: 'bulk_update',
|
|
159
|
+
entityType: 'content',
|
|
160
|
+
entityId: slug,
|
|
161
|
+
entitySlug: slug,
|
|
162
|
+
details: { count: updated, fields: Object.keys(resolvedFields) },
|
|
163
|
+
actor: {
|
|
164
|
+
id: jwtPayload.sub,
|
|
165
|
+
email: jwtPayload.email ?? 'unknown',
|
|
166
|
+
name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(' ') || null,
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ── Build structured failure objects ─────────────────────────────────────────
|
|
171
|
+
const failedProblems = failed.map(({ id, reason }) => ({
|
|
172
|
+
id,
|
|
173
|
+
problem: {
|
|
174
|
+
status: 422,
|
|
175
|
+
type: reason.startsWith('relation-target-not-found')
|
|
176
|
+
? 'relation-target-not-found'
|
|
177
|
+
: reason === 'not-found'
|
|
178
|
+
? 'content-not-found'
|
|
179
|
+
: 'bulk-update-error',
|
|
180
|
+
detail: reason,
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
return context.json({ updated, failed: failedProblems })
|
|
185
|
+
}
|
|
@@ -1,32 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
1
5
|
import { Context } from 'hono'
|
|
2
6
|
import {
|
|
3
7
|
slugify,
|
|
4
8
|
isValidContentStatus,
|
|
5
|
-
validateAndSanitizeSeedPayload
|
|
6
|
-
SlugConflictError
|
|
9
|
+
validateAndSanitizeSeedPayload
|
|
7
10
|
} from '@beechcms/core'
|
|
8
11
|
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
9
12
|
import { publicProblem } from '../../../public/problem-details'
|
|
13
|
+
import {
|
|
14
|
+
normalizeBody,
|
|
15
|
+
contentValidationProblem,
|
|
16
|
+
logContentActivity,
|
|
17
|
+
dispatchContentAutomation,
|
|
18
|
+
handleContentDatabaseError
|
|
19
|
+
} from './helpers'
|
|
10
20
|
import { CONTENT_ERRORS } from '../constants'
|
|
11
21
|
import { cleanStr } from '../../../shared/query-utils'
|
|
12
22
|
import { AppEnv } from '../../../types'
|
|
13
23
|
|
|
14
|
-
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
15
|
-
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
16
|
-
}
|
|
17
24
|
|
|
18
|
-
function contentValidationProblem(
|
|
19
|
-
context: Context,
|
|
20
|
-
details: Array<{ field: string; expected: string; received: string; message: string }>
|
|
21
|
-
) {
|
|
22
|
-
return publicProblem(context, {
|
|
23
|
-
type: 'content-validation-failed',
|
|
24
|
-
title: 'Bad Request',
|
|
25
|
-
status: 400,
|
|
26
|
-
detail: 'Validation failed',
|
|
27
|
-
errors: details
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
25
|
|
|
31
26
|
export async function createHandler(context: Context<AppEnv>) {
|
|
32
27
|
const slug = context.req.param('slug')
|
|
@@ -77,10 +72,11 @@ export async function createHandler(context: Context<AppEnv>) {
|
|
|
77
72
|
delete bodyForData.status
|
|
78
73
|
|
|
79
74
|
const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
|
|
80
|
-
operation: 'create',
|
|
81
|
-
allowNull: false,
|
|
82
|
-
requireAtLeastOneValidField: true,
|
|
75
|
+
operation: 'create',
|
|
76
|
+
allowNull: false,
|
|
77
|
+
requireAtLeastOneValidField: true,
|
|
83
78
|
enforceRequiredFields: true,
|
|
79
|
+
idGenerator: context.get('idGenerator'),
|
|
84
80
|
})
|
|
85
81
|
|
|
86
82
|
if (validation.dangerousFields.length > 0) {
|
|
@@ -123,43 +119,11 @@ export async function createHandler(context: Context<AppEnv>) {
|
|
|
123
119
|
const jwtPayload = context.get('jwtPayload')
|
|
124
120
|
const title = privacyData.title || privacyData.name || finalSlug
|
|
125
121
|
|
|
126
|
-
context
|
|
127
|
-
|
|
128
|
-
entityType: 'content',
|
|
129
|
-
entityId: id,
|
|
130
|
-
entitySlug: slug,
|
|
131
|
-
details: { title },
|
|
132
|
-
actor: {
|
|
133
|
-
id: jwtPayload.sub,
|
|
134
|
-
email: jwtPayload.email ?? 'unknown',
|
|
135
|
-
name: jwtPayload.name ?? null,
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
context.get('scheduler').waitUntil(
|
|
140
|
-
context.get('automationRunner').run({
|
|
141
|
-
seedSlug: slug,
|
|
142
|
-
event: 'create',
|
|
143
|
-
entry: { id, slug: finalSlug, status, ...privacyData },
|
|
144
|
-
}),
|
|
145
|
-
)
|
|
122
|
+
logContentActivity(context, 'create', id, slug, String(title))
|
|
123
|
+
dispatchContentAutomation(context, slug, 'create', { id, slug: finalSlug, status, ...privacyData })
|
|
146
124
|
|
|
147
125
|
return context.json({ id }, 201)
|
|
148
126
|
} catch (error) {
|
|
149
|
-
|
|
150
|
-
return publicProblem(context, {
|
|
151
|
-
type: 'content-slug-conflict',
|
|
152
|
-
title: 'Conflict',
|
|
153
|
-
status: 409,
|
|
154
|
-
detail: CONTENT_ERRORS.SLUG_CONFLICT
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
console.error('Content create error:', error)
|
|
158
|
-
return publicProblem(context, {
|
|
159
|
-
type: 'content-database-error',
|
|
160
|
-
title: 'Internal Server Error',
|
|
161
|
-
status: 500,
|
|
162
|
-
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
163
|
-
})
|
|
127
|
+
return handleContentDatabaseError(context, error)
|
|
164
128
|
}
|
|
165
129
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
1
5
|
import { Context } from 'hono'
|
|
2
|
-
import { EntryNotFoundError } from '@beechcms/core'
|
|
3
6
|
import { deleteR2Objects } from '../../../upload'
|
|
4
7
|
import { extractMediaKeysFromData } from '../../../media-utils'
|
|
5
8
|
import { publicProblem } from '../../../public/problem-details'
|
|
9
|
+
import {
|
|
10
|
+
logContentActivity,
|
|
11
|
+
dispatchContentAutomation,
|
|
12
|
+
handleContentDatabaseError
|
|
13
|
+
} from './helpers'
|
|
6
14
|
import { CONTENT_ERRORS } from '../constants'
|
|
7
15
|
import { AppEnv } from '../../../types'
|
|
8
16
|
|
|
@@ -33,29 +41,10 @@ export async function deleteHandler(context: Context<AppEnv>) {
|
|
|
33
41
|
// Repository.delete returns the row data for cleanup
|
|
34
42
|
const { row } = await repository.delete(seed, entryId)
|
|
35
43
|
|
|
36
|
-
const jwtPayload = context.get('jwtPayload')
|
|
37
44
|
const title = row.title || row.name || entryId
|
|
38
45
|
|
|
39
|
-
context
|
|
40
|
-
|
|
41
|
-
entityType: 'content',
|
|
42
|
-
entityId: entryId,
|
|
43
|
-
entitySlug: schemaSlug,
|
|
44
|
-
details: { title },
|
|
45
|
-
actor: {
|
|
46
|
-
id: jwtPayload.sub,
|
|
47
|
-
email: jwtPayload.email ?? 'unknown',
|
|
48
|
-
name: jwtPayload.name ?? null,
|
|
49
|
-
},
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
context.get('scheduler').waitUntil(
|
|
53
|
-
context.get('automationRunner').run({
|
|
54
|
-
seedSlug: schemaSlug,
|
|
55
|
-
event: 'delete',
|
|
56
|
-
entry: { ...row, id: entryId },
|
|
57
|
-
}),
|
|
58
|
-
)
|
|
46
|
+
logContentActivity(context, 'delete', entryId, schemaSlug, String(title))
|
|
47
|
+
dispatchContentAutomation(context, schemaSlug, 'delete', { ...row, id: entryId })
|
|
59
48
|
|
|
60
49
|
const r2ObjectKeys = extractMediaKeysFromData(seed, row)
|
|
61
50
|
if (r2ObjectKeys.length > 0) {
|
|
@@ -68,20 +57,6 @@ export async function deleteHandler(context: Context<AppEnv>) {
|
|
|
68
57
|
|
|
69
58
|
return context.json({ success: true })
|
|
70
59
|
} catch (error) {
|
|
71
|
-
|
|
72
|
-
return publicProblem(context, {
|
|
73
|
-
type: 'content-not-found',
|
|
74
|
-
title: 'Not Found',
|
|
75
|
-
status: 404,
|
|
76
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
console.error('Content delete error:', error)
|
|
80
|
-
return publicProblem(context, {
|
|
81
|
-
type: 'content-database-error',
|
|
82
|
-
title: 'Internal Server Error',
|
|
83
|
-
status: 500,
|
|
84
|
-
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
85
|
-
})
|
|
60
|
+
return handleContentDatabaseError(context, error)
|
|
86
61
|
}
|
|
87
62
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
1
5
|
import { Context } from 'hono'
|
|
2
6
|
import { publicProblem } from '../../../public/problem-details'
|
|
3
7
|
import { CONTENT_ERRORS } from '../constants'
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
1
5
|
import { Context } from 'hono'
|
|
2
6
|
import { applyVisibility } from '../../../shared/apply-policies'
|
|
3
7
|
import { publicProblem } from '../../../public/problem-details'
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { Context } from 'hono'
|
|
3
|
+
import { EntryNotFoundError, SlugConflictError } from '@beechcms/core'
|
|
4
|
+
import { normalizeBody, contentValidationProblem, logContentActivity, dispatchContentAutomation, handleContentDatabaseError } from './helpers'
|
|
5
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
6
|
+
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
vi.mock('../../../public/problem-details', () => ({
|
|
9
|
+
publicProblem: vi.fn((_ctx, details) => ({ mockProblem: true, ...details })),
|
|
10
|
+
fkProblemOrNull: vi.fn((_ctx, err, method) => {
|
|
11
|
+
if (err instanceof Error && err.message === 'FK_ERROR') return { mockFkProblem: true }
|
|
12
|
+
return null
|
|
13
|
+
}),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
describe('content handlers helpers', () => {
|
|
17
|
+
describe('normalizeBody', () => {
|
|
18
|
+
it('returns the object when input is an object', () => {
|
|
19
|
+
const obj = { a: 1 }
|
|
20
|
+
expect(normalizeBody(obj)).toBe(obj)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns an empty object when input is null', () => {
|
|
24
|
+
expect(normalizeBody(null)).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns an empty object when input is a primitive', () => {
|
|
28
|
+
expect(normalizeBody('string')).toEqual({})
|
|
29
|
+
expect(normalizeBody(123)).toEqual({})
|
|
30
|
+
expect(normalizeBody(true)).toEqual({})
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('contentValidationProblem', () => {
|
|
35
|
+
it('calls publicProblem with correct parameters', () => {
|
|
36
|
+
const ctx = {} as Context
|
|
37
|
+
const errors = [{ field: 'title', expected: 'string', received: 'number', message: 'Wrong type' }]
|
|
38
|
+
const result = contentValidationProblem(ctx, errors)
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({
|
|
41
|
+
mockProblem: true,
|
|
42
|
+
type: 'content-validation-failed',
|
|
43
|
+
title: 'Bad Request',
|
|
44
|
+
status: 400,
|
|
45
|
+
detail: 'Validation failed',
|
|
46
|
+
errors,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('logContentActivity', () => {
|
|
52
|
+
it('logs activity with correct payload', () => {
|
|
53
|
+
const mockLog = vi.fn()
|
|
54
|
+
const ctx = {
|
|
55
|
+
get: vi.fn((key) => {
|
|
56
|
+
if (key === 'jwtPayload') return { sub: 'user-1', email: 'test@test.com', name: 'John', surname: 'Doe' }
|
|
57
|
+
if (key === 'activityLogger') return { log: mockLog }
|
|
58
|
+
})
|
|
59
|
+
} as unknown as Context<any>
|
|
60
|
+
|
|
61
|
+
logContentActivity(ctx, 'create', 'entry-1', 'test-slug', 'Test Title')
|
|
62
|
+
|
|
63
|
+
expect(mockLog).toHaveBeenCalledWith({
|
|
64
|
+
action: 'create',
|
|
65
|
+
entityType: 'content',
|
|
66
|
+
entityId: 'entry-1',
|
|
67
|
+
entitySlug: 'test-slug',
|
|
68
|
+
details: { title: 'Test Title' },
|
|
69
|
+
actor: {
|
|
70
|
+
id: 'user-1',
|
|
71
|
+
email: 'test@test.com',
|
|
72
|
+
name: 'John Doe',
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles missing name and surname', () => {
|
|
78
|
+
const mockLog = vi.fn()
|
|
79
|
+
const ctx = {
|
|
80
|
+
get: vi.fn((key) => {
|
|
81
|
+
if (key === 'jwtPayload') return { sub: 'user-1' } // Missing name, surname, email
|
|
82
|
+
if (key === 'activityLogger') return { log: mockLog }
|
|
83
|
+
})
|
|
84
|
+
} as unknown as Context<any>
|
|
85
|
+
|
|
86
|
+
logContentActivity(ctx, 'update', 'entry-1', 'test-slug', 'Test Title')
|
|
87
|
+
|
|
88
|
+
expect(mockLog).toHaveBeenCalledWith({
|
|
89
|
+
action: 'update',
|
|
90
|
+
entityType: 'content',
|
|
91
|
+
entityId: 'entry-1',
|
|
92
|
+
entitySlug: 'test-slug',
|
|
93
|
+
details: { title: 'Test Title' },
|
|
94
|
+
actor: {
|
|
95
|
+
id: 'user-1',
|
|
96
|
+
email: 'unknown',
|
|
97
|
+
name: null,
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('dispatchContentAutomation', () => {
|
|
104
|
+
it('schedules automation run', () => {
|
|
105
|
+
const mockWaitUntil = vi.fn()
|
|
106
|
+
const mockRun = vi.fn().mockReturnValue('mock-promise')
|
|
107
|
+
const ctx = {
|
|
108
|
+
get: vi.fn((key) => {
|
|
109
|
+
if (key === 'scheduler') return { waitUntil: mockWaitUntil }
|
|
110
|
+
if (key === 'automationRunner') return { run: mockRun }
|
|
111
|
+
})
|
|
112
|
+
} as unknown as Context<any>
|
|
113
|
+
|
|
114
|
+
const entry = { id: 1 }
|
|
115
|
+
dispatchContentAutomation(ctx, 'posts', 'create', entry)
|
|
116
|
+
|
|
117
|
+
expect(mockRun).toHaveBeenCalledWith({
|
|
118
|
+
seedSlug: 'posts',
|
|
119
|
+
event: 'create',
|
|
120
|
+
entry,
|
|
121
|
+
})
|
|
122
|
+
expect(mockWaitUntil).toHaveBeenCalledWith('mock-promise')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('handleContentDatabaseError', () => {
|
|
127
|
+
it('handles EntryNotFoundError', () => {
|
|
128
|
+
const ctx = {} as Context<any>
|
|
129
|
+
const error = new EntryNotFoundError('Not found')
|
|
130
|
+
const result = handleContentDatabaseError(ctx, error)
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
mockProblem: true,
|
|
134
|
+
type: 'content-not-found',
|
|
135
|
+
title: 'Not Found',
|
|
136
|
+
status: 404,
|
|
137
|
+
detail: CONTENT_ERRORS.NOT_FOUND,
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('handles SlugConflictError', () => {
|
|
142
|
+
const ctx = {} as Context<any>
|
|
143
|
+
const error = new SlugConflictError('Conflict', 'slug')
|
|
144
|
+
const result = handleContentDatabaseError(ctx, error)
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
mockProblem: true,
|
|
148
|
+
type: 'content-slug-conflict',
|
|
149
|
+
title: 'Conflict',
|
|
150
|
+
status: 409,
|
|
151
|
+
detail: CONTENT_ERRORS.SLUG_CONFLICT,
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('handles foreign key error', () => {
|
|
156
|
+
const ctx = { req: { method: 'POST' } } as unknown as Context<any>
|
|
157
|
+
const error = new Error('FK_ERROR')
|
|
158
|
+
const result = handleContentDatabaseError(ctx, error)
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual({ mockFkProblem: true })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('handles generic errors as 500', () => {
|
|
164
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
165
|
+
const ctx = { req: { method: 'POST' } } as unknown as Context<any>
|
|
166
|
+
const error = new Error('Some db error')
|
|
167
|
+
const result = handleContentDatabaseError(ctx, error)
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual({
|
|
170
|
+
mockProblem: true,
|
|
171
|
+
type: 'content-database-error',
|
|
172
|
+
title: 'Internal Server Error',
|
|
173
|
+
status: 500,
|
|
174
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR,
|
|
175
|
+
})
|
|
176
|
+
expect(consoleSpy).toHaveBeenCalledWith('Content database error:', error)
|
|
177
|
+
consoleSpy.mockRestore()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
import { Context } from 'hono'
|
|
6
|
+
import { EntryNotFoundError, SlugConflictError } from '@beechcms/core'
|
|
7
|
+
import { publicProblem, fkProblemOrNull } from '../../../public/problem-details'
|
|
8
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
9
|
+
import { AppEnv } from '../../../types'
|
|
10
|
+
|
|
11
|
+
export function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
12
|
+
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function contentValidationProblem(
|
|
16
|
+
context: Context,
|
|
17
|
+
details: Array<{ field: string; expected: string; received: string; message: string }>
|
|
18
|
+
) {
|
|
19
|
+
return publicProblem(context, {
|
|
20
|
+
type: 'content-validation-failed',
|
|
21
|
+
title: 'Bad Request',
|
|
22
|
+
status: 400,
|
|
23
|
+
detail: 'Validation failed',
|
|
24
|
+
errors: details
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function logContentActivity(
|
|
29
|
+
context: Context<AppEnv>,
|
|
30
|
+
action: 'create' | 'update' | 'delete',
|
|
31
|
+
id: string,
|
|
32
|
+
slug: string,
|
|
33
|
+
title: string
|
|
34
|
+
) {
|
|
35
|
+
const jwtPayload = context.get('jwtPayload')
|
|
36
|
+
context.get('activityLogger').log({
|
|
37
|
+
action,
|
|
38
|
+
entityType: 'content',
|
|
39
|
+
entityId: id,
|
|
40
|
+
entitySlug: slug,
|
|
41
|
+
details: { title },
|
|
42
|
+
actor: {
|
|
43
|
+
id: jwtPayload.sub,
|
|
44
|
+
email: jwtPayload.email ?? 'unknown',
|
|
45
|
+
name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(' ') || null,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function dispatchContentAutomation(
|
|
51
|
+
context: Context<AppEnv>,
|
|
52
|
+
seedSlug: string,
|
|
53
|
+
event: 'create' | 'update' | 'delete',
|
|
54
|
+
entry: Record<string, unknown>
|
|
55
|
+
) {
|
|
56
|
+
context.get('scheduler').waitUntil(
|
|
57
|
+
context.get('automationRunner').run({
|
|
58
|
+
seedSlug,
|
|
59
|
+
event,
|
|
60
|
+
entry,
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function handleContentDatabaseError(context: Context<AppEnv>, error: unknown) {
|
|
66
|
+
if (error instanceof EntryNotFoundError) {
|
|
67
|
+
return publicProblem(context, {
|
|
68
|
+
type: 'content-not-found',
|
|
69
|
+
title: 'Not Found',
|
|
70
|
+
status: 404,
|
|
71
|
+
detail: CONTENT_ERRORS.NOT_FOUND
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof SlugConflictError) {
|
|
75
|
+
return publicProblem(context, {
|
|
76
|
+
type: 'content-slug-conflict',
|
|
77
|
+
title: 'Conflict',
|
|
78
|
+
status: 409,
|
|
79
|
+
detail: CONTENT_ERRORS.SLUG_CONFLICT,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fkResponse = fkProblemOrNull(context, error, context.req.method)
|
|
84
|
+
if (fkResponse) return fkResponse
|
|
85
|
+
|
|
86
|
+
console.error('Content database error:', error)
|
|
87
|
+
return publicProblem(context, {
|
|
88
|
+
type: 'content-database-error',
|
|
89
|
+
title: 'Internal Server Error',
|
|
90
|
+
status: 500,
|
|
91
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR,
|
|
92
|
+
})
|
|
93
|
+
}
|