@beechcms/api 0.6.0-preview.1 → 0.6.0-preview.2
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/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,185 +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
|
+
// 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,129 +1,129 @@
|
|
|
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 {
|
|
7
|
-
slugify,
|
|
8
|
-
isValidContentStatus,
|
|
9
|
-
validateAndSanitizeSeedPayload
|
|
10
|
-
} from '@beechcms/core'
|
|
11
|
-
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
12
|
-
import { publicProblem } from '../../../public/problem-details'
|
|
13
|
-
import {
|
|
14
|
-
normalizeBody,
|
|
15
|
-
contentValidationProblem,
|
|
16
|
-
logContentActivity,
|
|
17
|
-
dispatchContentAutomation,
|
|
18
|
-
handleContentDatabaseError
|
|
19
|
-
} from './helpers'
|
|
20
|
-
import { CONTENT_ERRORS } from '../constants'
|
|
21
|
-
import { cleanStr } from '../../../shared/query-utils'
|
|
22
|
-
import { AppEnv } from '../../../types'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export async function createHandler(context: Context<AppEnv>) {
|
|
27
|
-
const slug = context.req.param('slug')
|
|
28
|
-
if (!slug) {
|
|
29
|
-
return publicProblem(context, {
|
|
30
|
-
type: 'content-invalid-slug',
|
|
31
|
-
title: 'Bad Request',
|
|
32
|
-
status: 400,
|
|
33
|
-
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
34
|
-
})
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const seed = context.get('getSeed')(slug)
|
|
38
|
-
if (!seed) {
|
|
39
|
-
return publicProblem(context, {
|
|
40
|
-
type: 'content-seed-not-found',
|
|
41
|
-
title: 'Not Found',
|
|
42
|
-
status: 404,
|
|
43
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
44
|
-
})
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let body: Record<string, unknown>
|
|
48
|
-
try {
|
|
49
|
-
body = normalizeBody(await context.req.json<unknown>())
|
|
50
|
-
} catch {
|
|
51
|
-
return publicProblem(context, {
|
|
52
|
-
type: 'content-invalid-json',
|
|
53
|
-
title: 'Bad Request',
|
|
54
|
-
status: 400,
|
|
55
|
-
detail: CONTENT_ERRORS.INVALID_JSON_BODY
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const entrySlug = body.slug ? slugify(String(body.slug)) : null
|
|
60
|
-
const status = cleanStr(body.status) ?? 'draft'
|
|
61
|
-
if (!isValidContentStatus(status)) {
|
|
62
|
-
return publicProblem(context, {
|
|
63
|
-
type: 'content-invalid-status',
|
|
64
|
-
title: 'Bad Request',
|
|
65
|
-
status: 400,
|
|
66
|
-
detail: 'Invalid status. Allowed values are: draft, review, published'
|
|
67
|
-
})
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const bodyForData = { ...body }
|
|
71
|
-
delete bodyForData.slug
|
|
72
|
-
delete bodyForData.status
|
|
73
|
-
|
|
74
|
-
const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
|
|
75
|
-
operation: 'create',
|
|
76
|
-
allowNull: false,
|
|
77
|
-
requireAtLeastOneValidField: true,
|
|
78
|
-
enforceRequiredFields: true,
|
|
79
|
-
idGenerator: context.get('idGenerator'),
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
if (validation.dangerousFields.length > 0) {
|
|
83
|
-
return publicProblem(context, {
|
|
84
|
-
type: 'content-dangerous-content',
|
|
85
|
-
title: 'Unprocessable Entity',
|
|
86
|
-
status: 422,
|
|
87
|
-
detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
|
|
92
|
-
|
|
93
|
-
let privacyData: Record<string, unknown>
|
|
94
|
-
try {
|
|
95
|
-
privacyData = await applyPrivacy(validation.data, seed)
|
|
96
|
-
} catch (error) {
|
|
97
|
-
if (error instanceof PrivacyPolicyError) {
|
|
98
|
-
return publicProblem(context, {
|
|
99
|
-
type: 'content-policy-not-implemented',
|
|
100
|
-
title: 'Not Implemented',
|
|
101
|
-
status: 501,
|
|
102
|
-
detail: error.message
|
|
103
|
-
})
|
|
104
|
-
}
|
|
105
|
-
throw error
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const id = context.get('idGenerator').uuid()
|
|
109
|
-
let finalSlug = entrySlug
|
|
110
|
-
if (!finalSlug) {
|
|
111
|
-
const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
|
|
112
|
-
finalSlug = slugify(String(fallbackSource))
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
const repository = context.get('repository')
|
|
117
|
-
await repository.create(seed, id, finalSlug, status, privacyData)
|
|
118
|
-
|
|
119
|
-
const jwtPayload = context.get('jwtPayload')
|
|
120
|
-
const title = privacyData.title || privacyData.name || finalSlug
|
|
121
|
-
|
|
122
|
-
logContentActivity(context, 'create', id, slug, String(title))
|
|
123
|
-
dispatchContentAutomation(context, slug, 'create', { id, slug: finalSlug, status, ...privacyData })
|
|
124
|
-
|
|
125
|
-
return context.json({ id }, 201)
|
|
126
|
-
} catch (error) {
|
|
127
|
-
return handleContentDatabaseError(context, error)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
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 {
|
|
7
|
+
slugify,
|
|
8
|
+
isValidContentStatus,
|
|
9
|
+
validateAndSanitizeSeedPayload
|
|
10
|
+
} from '@beechcms/core'
|
|
11
|
+
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
12
|
+
import { publicProblem } from '../../../public/problem-details'
|
|
13
|
+
import {
|
|
14
|
+
normalizeBody,
|
|
15
|
+
contentValidationProblem,
|
|
16
|
+
logContentActivity,
|
|
17
|
+
dispatchContentAutomation,
|
|
18
|
+
handleContentDatabaseError
|
|
19
|
+
} from './helpers'
|
|
20
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
21
|
+
import { cleanStr } from '../../../shared/query-utils'
|
|
22
|
+
import { AppEnv } from '../../../types'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
export async function createHandler(context: Context<AppEnv>) {
|
|
27
|
+
const slug = context.req.param('slug')
|
|
28
|
+
if (!slug) {
|
|
29
|
+
return publicProblem(context, {
|
|
30
|
+
type: 'content-invalid-slug',
|
|
31
|
+
title: 'Bad Request',
|
|
32
|
+
status: 400,
|
|
33
|
+
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const seed = context.get('getSeed')(slug)
|
|
38
|
+
if (!seed) {
|
|
39
|
+
return publicProblem(context, {
|
|
40
|
+
type: 'content-seed-not-found',
|
|
41
|
+
title: 'Not Found',
|
|
42
|
+
status: 404,
|
|
43
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let body: Record<string, unknown>
|
|
48
|
+
try {
|
|
49
|
+
body = normalizeBody(await context.req.json<unknown>())
|
|
50
|
+
} catch {
|
|
51
|
+
return publicProblem(context, {
|
|
52
|
+
type: 'content-invalid-json',
|
|
53
|
+
title: 'Bad Request',
|
|
54
|
+
status: 400,
|
|
55
|
+
detail: CONTENT_ERRORS.INVALID_JSON_BODY
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const entrySlug = body.slug ? slugify(String(body.slug)) : null
|
|
60
|
+
const status = cleanStr(body.status) ?? 'draft'
|
|
61
|
+
if (!isValidContentStatus(status)) {
|
|
62
|
+
return publicProblem(context, {
|
|
63
|
+
type: 'content-invalid-status',
|
|
64
|
+
title: 'Bad Request',
|
|
65
|
+
status: 400,
|
|
66
|
+
detail: 'Invalid status. Allowed values are: draft, review, published'
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const bodyForData = { ...body }
|
|
71
|
+
delete bodyForData.slug
|
|
72
|
+
delete bodyForData.status
|
|
73
|
+
|
|
74
|
+
const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
|
|
75
|
+
operation: 'create',
|
|
76
|
+
allowNull: false,
|
|
77
|
+
requireAtLeastOneValidField: true,
|
|
78
|
+
enforceRequiredFields: true,
|
|
79
|
+
idGenerator: context.get('idGenerator'),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if (validation.dangerousFields.length > 0) {
|
|
83
|
+
return publicProblem(context, {
|
|
84
|
+
type: 'content-dangerous-content',
|
|
85
|
+
title: 'Unprocessable Entity',
|
|
86
|
+
status: 422,
|
|
87
|
+
detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
|
|
92
|
+
|
|
93
|
+
let privacyData: Record<string, unknown>
|
|
94
|
+
try {
|
|
95
|
+
privacyData = await applyPrivacy(validation.data, seed)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof PrivacyPolicyError) {
|
|
98
|
+
return publicProblem(context, {
|
|
99
|
+
type: 'content-policy-not-implemented',
|
|
100
|
+
title: 'Not Implemented',
|
|
101
|
+
status: 501,
|
|
102
|
+
detail: error.message
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const id = context.get('idGenerator').uuid()
|
|
109
|
+
let finalSlug = entrySlug
|
|
110
|
+
if (!finalSlug) {
|
|
111
|
+
const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
|
|
112
|
+
finalSlug = slugify(String(fallbackSource))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const repository = context.get('repository')
|
|
117
|
+
await repository.create(seed, id, finalSlug, status, privacyData)
|
|
118
|
+
|
|
119
|
+
const jwtPayload = context.get('jwtPayload')
|
|
120
|
+
const title = privacyData.title || privacyData.name || finalSlug
|
|
121
|
+
|
|
122
|
+
logContentActivity(context, 'create', id, slug, String(title))
|
|
123
|
+
dispatchContentAutomation(context, slug, 'create', { id, slug: finalSlug, status, ...privacyData })
|
|
124
|
+
|
|
125
|
+
return context.json({ id }, 201)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return handleContentDatabaseError(context, error)
|
|
128
|
+
}
|
|
129
|
+
}
|