@beechcms/api 0.4.3 → 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.
Files changed (207) hide show
  1. package/assets/dashboard/Searching.svg +1 -0
  2. package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
  3. package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
  4. package/assets/dashboard/index.html +2 -2
  5. package/assets/dashboard/noResult.svg +43 -0
  6. package/assets/dashboard/working.svg +1 -0
  7. package/migrations/0000_v040_base.sql +27 -0
  8. package/migrations/0030_test_seeds.sql +125 -0
  9. package/package.json +14 -9
  10. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  11. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  12. package/src/auth/bcrypt-hash-provider.ts +6 -2
  13. package/src/auth/constants.ts +4 -0
  14. package/src/auth/generate-refresh-token.test.ts +8 -4
  15. package/src/auth/hash-provider.test.ts +14 -5
  16. package/src/auth/jose-token-service.ts +7 -0
  17. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  18. package/src/auth/login.test.ts +9 -1
  19. package/src/auth/login.ts +5 -1
  20. package/src/auth/refresh.ts +7 -1
  21. package/src/auth/token-service.test.ts +9 -1
  22. package/src/factory.ts +37 -16
  23. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  24. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  25. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  26. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  27. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  28. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  29. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  30. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  31. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  32. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  33. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  34. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  35. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  36. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  37. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  38. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  39. package/src/features/automations/action-executors/index.ts +5 -1
  40. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  41. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  42. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  43. package/src/features/automations/automation-runner.ts +4 -0
  44. package/src/features/automations/automation-runner.utils.ts +4 -0
  45. package/src/features/automations/automations.handler.ts +4 -0
  46. package/src/features/automations/automations.schema.ts +19 -2
  47. package/src/features/automations/context-resolver.ts +4 -0
  48. package/src/features/automations/cron-runner.ts +4 -0
  49. package/src/features/automations/cron-runner.utils.ts +4 -0
  50. package/src/features/automations/filter-translation.ts +4 -0
  51. package/src/features/automations/index.ts +4 -0
  52. package/src/features/automations/template-grammar.ts +4 -0
  53. package/src/features/automations/var-access-resolver.ts +4 -0
  54. package/src/features/automations/when-evaluator.ts +4 -0
  55. package/src/features/automations/when-pushdown.ts +4 -0
  56. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  57. package/src/features/backrefs/backrefs.handler.ts +168 -0
  58. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  59. package/src/features/backrefs/index.ts +5 -0
  60. package/src/features/content/constants.ts +6 -0
  61. package/src/features/content/handlers/bulk.handler.ts +185 -0
  62. package/src/features/content/handlers/create.ts +19 -55
  63. package/src/features/content/handlers/delete.ts +12 -37
  64. package/src/features/content/handlers/facets.ts +4 -0
  65. package/src/features/content/handlers/get.ts +4 -0
  66. package/src/features/content/handlers/helpers.test.ts +180 -0
  67. package/src/features/content/handlers/helpers.ts +93 -0
  68. package/src/features/content/handlers/list.ts +105 -19
  69. package/src/features/content/handlers/update.ts +19 -64
  70. package/src/features/content/index.ts +6 -0
  71. package/src/features/draft/draft.handler.ts +33 -8
  72. package/src/features/draft/draft.middleware.ts +4 -0
  73. package/src/features/draft/index.ts +4 -0
  74. package/src/features/email/email.provider.ts +4 -0
  75. package/src/features/email/email.service.ts +50 -45
  76. package/src/features/email/email.types.ts +12 -1
  77. package/src/features/email/index.ts +4 -0
  78. package/src/features/email/providers/resend.ts +4 -0
  79. package/src/features/email/providers/smtp.ts +55 -0
  80. package/src/features/email/templates/automation-mail.ts +4 -0
  81. package/src/features/email/templates/password-changed.ts +4 -0
  82. package/src/features/email/templates/password-reset.ts +4 -0
  83. package/src/features/email/templates/shell.ts +4 -0
  84. package/src/features/notifications/index.ts +4 -0
  85. package/src/features/notifications/notifications.handler.ts +4 -0
  86. package/src/features/password-reset/index.ts +5 -1
  87. package/src/features/password-reset/request.ts +12 -2
  88. package/src/features/password-reset/reset.ts +12 -2
  89. package/src/features/rotate-field/index.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  91. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  92. package/src/features/schema/schema.handler.ts +118 -3
  93. package/src/features/seeds/index.ts +5 -0
  94. package/src/features/seeds/seeds.handler.test.ts +632 -0
  95. package/src/features/seeds/seeds.handler.ts +693 -0
  96. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  97. package/src/features/settings/settings.handler.ts +98 -8
  98. package/src/features/setup/index.ts +124 -11
  99. package/src/features/stats/index.ts +4 -0
  100. package/src/features/stats/stats.handler.ts +11 -21
  101. package/src/features/webhooks/index.ts +63 -0
  102. package/src/index.ts +28 -19
  103. package/src/media-utils.ts +4 -0
  104. package/src/middleware/auth-providers.middleware.ts +13 -0
  105. package/src/middleware/observability.middleware.ts +21 -3
  106. package/src/middleware/rate-limit.middleware.ts +4 -0
  107. package/src/middleware/repository.middleware.ts +25 -2
  108. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  109. package/src/middleware/seed-registry.middleware.ts +21 -0
  110. package/src/middleware/storage.middleware.ts +4 -0
  111. package/src/middleware.ts +5 -7
  112. package/src/public/access-policy.ts +4 -0
  113. package/src/public/api-key-middleware.ts +4 -0
  114. package/src/public/cache-utils.ts +38 -34
  115. package/src/public/entry-projection.ts +46 -42
  116. package/src/public/idempotency.ts +23 -19
  117. package/src/public/index.ts +4 -0
  118. package/src/public/problem-details.ts +71 -0
  119. package/src/public/public-add.ts +4 -0
  120. package/src/public/public-edit.ts +4 -0
  121. package/src/public/public-errors.ts +4 -0
  122. package/src/public/public-read.ts +4 -0
  123. package/src/public/public-routes.ts +4 -0
  124. package/src/public/query-builder.test.ts +4 -0
  125. package/src/public/query-builder.ts +4 -0
  126. package/src/public/rate-limit-middleware.ts +4 -0
  127. package/src/public/read-list.ts +54 -50
  128. package/src/public/read-single.ts +48 -44
  129. package/src/public/response-builder.ts +4 -0
  130. package/src/public/sanitize.ts +4 -0
  131. package/src/public/slug-utils.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  133. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  135. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  137. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  138. package/src/search-utils.test.ts +4 -0
  139. package/src/search-utils.ts +5 -1
  140. package/src/search.ts +5 -1
  141. package/src/shared/apply-policies.test.ts +4 -0
  142. package/src/shared/apply-policies.ts +4 -0
  143. package/src/shared/automations.repository.d1.ts +4 -0
  144. package/src/shared/background-notification-service.test.ts +4 -0
  145. package/src/shared/background-notification-service.ts +4 -0
  146. package/src/shared/base.repository.d1.ts +4 -0
  147. package/src/shared/content-utils.test.ts +4 -0
  148. package/src/shared/content-utils.ts +4 -0
  149. package/src/shared/content.repository.d1.test.ts +147 -1
  150. package/src/shared/content.repository.d1.ts +535 -66
  151. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  152. package/src/shared/d1-activity-log.repository.ts +4 -0
  153. package/src/shared/d1-activity-logger.test.ts +4 -0
  154. package/src/shared/d1-activity-logger.ts +4 -0
  155. package/src/shared/d1-analytics.repository.test.ts +4 -0
  156. package/src/shared/d1-analytics.repository.ts +4 -0
  157. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  158. package/src/shared/d1-content-scan.repository.ts +15 -7
  159. package/src/shared/d1-notification.repository.test.ts +4 -0
  160. package/src/shared/d1-notification.repository.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  162. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  163. package/src/shared/d1-search.repository.test.ts +4 -0
  164. package/src/shared/d1-search.repository.ts +4 -0
  165. package/src/shared/d1-session.repository.test.ts +4 -0
  166. package/src/shared/d1-session.repository.ts +4 -0
  167. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  168. package/src/shared/d1-user.repository.test.ts +8 -4
  169. package/src/shared/d1-user.repository.ts +15 -5
  170. package/src/shared/d1-widget.repository.test.ts +4 -0
  171. package/src/shared/d1-widget.repository.ts +4 -0
  172. package/src/shared/demo-data-sql.ts +54 -0
  173. package/src/shared/demo-data.repository.d1.ts +20 -0
  174. package/src/shared/execution-context-scheduler.ts +4 -0
  175. package/src/shared/fixed-clock.ts +4 -0
  176. package/src/shared/fts-sync.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  178. package/src/shared/idempotency.repository.d1.ts +4 -0
  179. package/src/shared/in-memory-activity-logger.ts +4 -0
  180. package/src/shared/in-memory-notification-service.ts +4 -0
  181. package/src/shared/in-memory-seed.repository.ts +51 -0
  182. package/src/shared/media.repository.d1.test.ts +4 -0
  183. package/src/shared/media.repository.d1.ts +4 -0
  184. package/src/shared/qstash-notification-service.test.ts +67 -0
  185. package/src/shared/qstash-notification-service.ts +55 -0
  186. package/src/shared/query-utils.ts +4 -0
  187. package/src/shared/request-utils.ts +4 -0
  188. package/src/shared/schema-mutator.d1.ts +64 -0
  189. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  190. package/src/shared/seed-layout.repository.d1.ts +62 -0
  191. package/src/shared/seed-registry-cache.test.ts +68 -0
  192. package/src/shared/seed-registry-cache.ts +49 -0
  193. package/src/shared/seed.repository.d1.test.ts +143 -0
  194. package/src/shared/seed.repository.d1.ts +98 -0
  195. package/src/shared/sequential-id-generator.ts +11 -0
  196. package/src/shared/site-settings.repository.d1.ts +53 -0
  197. package/src/shared/storage/factory.ts +16 -15
  198. package/src/shared/storage/s3-bucket.ts +34 -2
  199. package/src/shared/storage-utils.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  201. package/src/shared/system-stats.repository.d1.ts +5 -1
  202. package/src/types.ts +23 -3
  203. package/src/upload.ts +134 -123
  204. package/src/widget.ts +6 -2
  205. package/assets/dashboard/assets/index-BKWnlvnV.css +0 -1
  206. package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
  207. 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.get('activityLogger').log({
127
- action: 'create',
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
- if (error instanceof SlugConflictError) {
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.get('activityLogger').log({
40
- action: 'delete',
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
- if (error instanceof EntryNotFoundError) {
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
+ }