@beechcms/api 0.5.0 → 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.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,45 +1,49 @@
1
- import { Context } from 'hono'
2
- import { publicProblem } from '../../../public/problem-details'
3
- import { CONTENT_ERRORS } from '../constants'
4
- import { AppEnv } from '../../../types'
5
-
6
- export async function facetsHandler(context: Context<AppEnv>) {
7
- const slug = context.req.param('slug')
8
- if (!slug) {
9
- return publicProblem(context, {
10
- type: 'content-invalid-slug',
11
- title: 'Bad Request',
12
- status: 400,
13
- detail: CONTENT_ERRORS.INVALID_SLUG
14
- })
15
- }
16
-
17
- const seed = context.get('getSeed')(slug)
18
- if (!seed) {
19
- return publicProblem(context, {
20
- type: 'content-seed-not-found',
21
- title: 'Not Found',
22
- status: 404,
23
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
24
- })
25
- }
26
-
27
- try {
28
- const repository = context.get('repository')
29
- const { statuses, tagsByColumn } = await repository.getFacets(seed)
30
-
31
- // Legacy format expected by the dashboard
32
- return context.json({
33
- statuses: Object.keys(statuses).sort((a, b) => a.localeCompare(b, 'it')),
34
- tagsByColumnId: tagsByColumn,
35
- })
36
- } catch (error) {
37
- console.error('Content facets error:', error)
38
- return publicProblem(context, {
39
- type: 'content-database-error',
40
- title: 'Internal Server Error',
41
- status: 500,
42
- detail: CONTENT_ERRORS.DATABASE_ERROR
43
- })
44
- }
45
- }
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 { publicProblem } from '../../../public/problem-details'
7
+ import { CONTENT_ERRORS } from '../constants'
8
+ import { AppEnv } from '../../../types'
9
+
10
+ export async function facetsHandler(context: Context<AppEnv>) {
11
+ const slug = context.req.param('slug')
12
+ if (!slug) {
13
+ return publicProblem(context, {
14
+ type: 'content-invalid-slug',
15
+ title: 'Bad Request',
16
+ status: 400,
17
+ detail: CONTENT_ERRORS.INVALID_SLUG
18
+ })
19
+ }
20
+
21
+ const seed = context.get('getSeed')(slug)
22
+ if (!seed) {
23
+ return publicProblem(context, {
24
+ type: 'content-seed-not-found',
25
+ title: 'Not Found',
26
+ status: 404,
27
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
28
+ })
29
+ }
30
+
31
+ try {
32
+ const repository = context.get('repository')
33
+ const { statuses, tagsByColumn } = await repository.getFacets(seed)
34
+
35
+ // Legacy format expected by the dashboard
36
+ return context.json({
37
+ statuses: Object.keys(statuses).sort((a, b) => a.localeCompare(b, 'it')),
38
+ tagsByColumnId: tagsByColumn,
39
+ })
40
+ } catch (error) {
41
+ console.error('Content facets error:', error)
42
+ return publicProblem(context, {
43
+ type: 'content-database-error',
44
+ title: 'Internal Server Error',
45
+ status: 500,
46
+ detail: CONTENT_ERRORS.DATABASE_ERROR
47
+ })
48
+ }
49
+ }
@@ -1,116 +1,120 @@
1
- import { Context } from 'hono'
2
- import { applyVisibility } from '../../../shared/apply-policies'
3
- import { publicProblem } from '../../../public/problem-details'
4
- import { CONTENT_ERRORS } from '../constants'
5
- import { AppEnv } from '../../../types'
6
- import { EntryNotFoundError } from '@beechcms/core'
7
-
8
- export async function getByIdHandler(context: Context<AppEnv>) {
9
- const slug = context.req.param('slug')
10
- const id = context.req.param('id')
11
- if (!slug || !id) {
12
- return publicProblem(context, {
13
- type: 'content-invalid-slug-or-id',
14
- title: 'Bad Request',
15
- status: 400,
16
- detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
17
- })
18
- }
19
-
20
- const seed = context.get('getSeed')(slug)
21
- if (!seed) {
22
- return publicProblem(context, {
23
- type: 'content-seed-not-found',
24
- title: 'Not Found',
25
- status: 404,
26
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
27
- })
28
- }
29
-
30
- try {
31
- const repository = context.get('repository')
32
- const item = await repository.findById(seed, id)
33
-
34
- let hasPendingDraft = false
35
- if (seed.allowDrafts) {
36
- hasPendingDraft = await repository.hasDraft(seed, id)
37
- }
38
-
39
- return context.json({
40
- ...item,
41
- has_pending_draft: hasPendingDraft,
42
- data: applyVisibility(item, seed)
43
- })
44
- } catch (error) {
45
- if (error instanceof EntryNotFoundError) {
46
- return publicProblem(context, {
47
- type: 'content-not-found',
48
- title: 'Not Found',
49
- status: 404,
50
- detail: CONTENT_ERRORS.NOT_FOUND
51
- })
52
- }
53
- console.error('Content detail error:', error)
54
- return publicProblem(context, {
55
- type: 'content-database-error',
56
- title: 'Internal Server Error',
57
- status: 500,
58
- detail: CONTENT_ERRORS.DATABASE_ERROR
59
- })
60
- }
61
- }
62
-
63
- export async function getBySlugHandler(context: Context<AppEnv>) {
64
- const schemaSlug = context.req.param('schema_slug')
65
- const entrySlug = context.req.param('entry_slug')
66
- if (!schemaSlug || !entrySlug) {
67
- return publicProblem(context, {
68
- type: 'content-invalid-slug-or-id',
69
- title: 'Bad Request',
70
- status: 400,
71
- detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
72
- })
73
- }
74
-
75
- const seed = context.get('getSeed')(schemaSlug)
76
- if (!seed) {
77
- return publicProblem(context, {
78
- type: 'content-seed-not-found',
79
- title: 'Not Found',
80
- status: 404,
81
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
82
- })
83
- }
84
-
85
- try {
86
- const repository = context.get('repository')
87
- const item = await repository.findBySlug(seed, entrySlug)
88
-
89
- let hasPendingDraft = false
90
- if (seed.allowDrafts) {
91
- hasPendingDraft = await repository.hasDraft(seed, item.id)
92
- }
93
-
94
- return context.json({
95
- ...item,
96
- has_pending_draft: hasPendingDraft,
97
- data: applyVisibility(item, seed)
98
- })
99
- } catch (error) {
100
- if (error instanceof EntryNotFoundError) {
101
- return publicProblem(context, {
102
- type: 'content-not-found',
103
- title: 'Not Found',
104
- status: 404,
105
- detail: CONTENT_ERRORS.NOT_FOUND
106
- })
107
- }
108
- console.error('Content by-slug error:', error)
109
- return publicProblem(context, {
110
- type: 'content-database-error',
111
- title: 'Internal Server Error',
112
- status: 500,
113
- detail: CONTENT_ERRORS.DATABASE_ERROR
114
- })
115
- }
116
- }
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 { applyVisibility } from '../../../shared/apply-policies'
7
+ import { publicProblem } from '../../../public/problem-details'
8
+ import { CONTENT_ERRORS } from '../constants'
9
+ import { AppEnv } from '../../../types'
10
+ import { EntryNotFoundError } from '@beechcms/core'
11
+
12
+ export async function getByIdHandler(context: Context<AppEnv>) {
13
+ const slug = context.req.param('slug')
14
+ const id = context.req.param('id')
15
+ if (!slug || !id) {
16
+ return publicProblem(context, {
17
+ type: 'content-invalid-slug-or-id',
18
+ title: 'Bad Request',
19
+ status: 400,
20
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
21
+ })
22
+ }
23
+
24
+ const seed = context.get('getSeed')(slug)
25
+ if (!seed) {
26
+ return publicProblem(context, {
27
+ type: 'content-seed-not-found',
28
+ title: 'Not Found',
29
+ status: 404,
30
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
31
+ })
32
+ }
33
+
34
+ try {
35
+ const repository = context.get('repository')
36
+ const item = await repository.findById(seed, id)
37
+
38
+ let hasPendingDraft = false
39
+ if (seed.allowDrafts) {
40
+ hasPendingDraft = await repository.hasDraft(seed, id)
41
+ }
42
+
43
+ return context.json({
44
+ ...item,
45
+ has_pending_draft: hasPendingDraft,
46
+ data: applyVisibility(item, seed)
47
+ })
48
+ } catch (error) {
49
+ if (error instanceof EntryNotFoundError) {
50
+ return publicProblem(context, {
51
+ type: 'content-not-found',
52
+ title: 'Not Found',
53
+ status: 404,
54
+ detail: CONTENT_ERRORS.NOT_FOUND
55
+ })
56
+ }
57
+ console.error('Content detail error:', error)
58
+ return publicProblem(context, {
59
+ type: 'content-database-error',
60
+ title: 'Internal Server Error',
61
+ status: 500,
62
+ detail: CONTENT_ERRORS.DATABASE_ERROR
63
+ })
64
+ }
65
+ }
66
+
67
+ export async function getBySlugHandler(context: Context<AppEnv>) {
68
+ const schemaSlug = context.req.param('schema_slug')
69
+ const entrySlug = context.req.param('entry_slug')
70
+ if (!schemaSlug || !entrySlug) {
71
+ return publicProblem(context, {
72
+ type: 'content-invalid-slug-or-id',
73
+ title: 'Bad Request',
74
+ status: 400,
75
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
76
+ })
77
+ }
78
+
79
+ const seed = context.get('getSeed')(schemaSlug)
80
+ if (!seed) {
81
+ return publicProblem(context, {
82
+ type: 'content-seed-not-found',
83
+ title: 'Not Found',
84
+ status: 404,
85
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
86
+ })
87
+ }
88
+
89
+ try {
90
+ const repository = context.get('repository')
91
+ const item = await repository.findBySlug(seed, entrySlug)
92
+
93
+ let hasPendingDraft = false
94
+ if (seed.allowDrafts) {
95
+ hasPendingDraft = await repository.hasDraft(seed, item.id)
96
+ }
97
+
98
+ return context.json({
99
+ ...item,
100
+ has_pending_draft: hasPendingDraft,
101
+ data: applyVisibility(item, seed)
102
+ })
103
+ } catch (error) {
104
+ if (error instanceof EntryNotFoundError) {
105
+ return publicProblem(context, {
106
+ type: 'content-not-found',
107
+ title: 'Not Found',
108
+ status: 404,
109
+ detail: CONTENT_ERRORS.NOT_FOUND
110
+ })
111
+ }
112
+ console.error('Content by-slug error:', error)
113
+ return publicProblem(context, {
114
+ type: 'content-database-error',
115
+ title: 'Internal Server Error',
116
+ status: 500,
117
+ detail: CONTENT_ERRORS.DATABASE_ERROR
118
+ })
119
+ }
120
+ }
@@ -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
+ }