@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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/Searching.svg +1 -0
- 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 +43 -0
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +265 -238
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
- package/src/auth/bcrypt-hash-provider.ts +24 -20
- package/src/auth/constants.ts +14 -10
- package/src/auth/generate-refresh-token.test.ts +23 -19
- package/src/auth/hash-provider.test.ts +55 -46
- package/src/auth/jose-token-service.ts +62 -55
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +100 -92
- package/src/auth/login.ts +78 -74
- package/src/auth/refresh.ts +11 -5
- package/src/auth/token-service.test.ts +90 -82
- package/src/factory.ts +370 -347
- package/src/features/automations/__tests__/action-executors.test.ts +255 -268
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
- package/src/features/automations/action-executors/index.ts +37 -33
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
- package/src/features/automations/action-executors/webhook.executor.ts +67 -25
- package/src/features/automations/automation-runner.ts +85 -81
- package/src/features/automations/automation-runner.utils.ts +47 -43
- package/src/features/automations/automations.handler.ts +197 -193
- package/src/features/automations/automations.schema.ts +177 -160
- package/src/features/automations/context-resolver.ts +152 -148
- package/src/features/automations/cron-runner.ts +140 -136
- package/src/features/automations/cron-runner.utils.ts +44 -40
- package/src/features/automations/filter-translation.ts +46 -42
- package/src/features/automations/index.ts +16 -12
- package/src/features/automations/template-grammar.ts +245 -241
- package/src/features/automations/var-access-resolver.ts +140 -136
- package/src/features/automations/when-evaluator.ts +87 -83
- package/src/features/automations/when-pushdown.ts +57 -53
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +16 -10
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +129 -165
- package/src/features/content/handlers/delete.ts +62 -87
- package/src/features/content/handlers/facets.ts +49 -45
- package/src/features/content/handlers/get.ts +120 -116
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +174 -88
- package/src/features/content/handlers/update.ts +174 -219
- package/src/features/content/index.ts +26 -20
- 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 -170
- package/src/features/draft/draft.middleware.ts +66 -62
- package/src/features/draft/index.ts +5 -1
- package/src/features/email/email.provider.ts +42 -38
- package/src/features/email/email.service.ts +98 -93
- package/src/features/email/email.types.ts +119 -108
- package/src/features/email/index.ts +33 -29
- package/src/features/email/providers/resend.ts +67 -63
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +19 -15
- package/src/features/email/templates/password-changed.ts +63 -59
- package/src/features/email/templates/password-reset.ts +68 -64
- package/src/features/email/templates/shell.ts +96 -92
- package/src/features/notifications/index.ts +5 -1
- package/src/features/notifications/notifications.handler.ts +105 -101
- package/src/features/password-reset/index.ts +19 -15
- package/src/features/password-reset/request.ts +92 -82
- package/src/features/password-reset/reset.ts +102 -92
- package/src/features/rotate-field/index.ts +5 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -128
- package/src/features/rotate-field/rotate-field.schema.ts +17 -13
- package/src/features/schema/schema.handler.ts +131 -16
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +1199 -0
- package/src/features/seeds/seeds.handler.ts +558 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +392 -302
- package/src/features/setup/index.ts +288 -91
- package/src/features/stats/index.ts +5 -1
- package/src/features/stats/stats.handler.ts +420 -430
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +65 -56
- package/src/media-utils.ts +82 -78
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -32
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -52
- package/src/middleware/rate-limit.middleware.ts +45 -41
- package/src/middleware/repository.middleware.ts +117 -91
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +25 -21
- package/src/middleware.ts +45 -47
- package/src/public/access-policy.ts +27 -23
- package/src/public/api-key-middleware.ts +57 -53
- package/src/public/cache-utils.ts +38 -34
- package/src/public/entry-projection.ts +46 -42
- package/src/public/idempotency.ts +23 -19
- package/src/public/index.ts +16 -12
- package/src/public/problem-details.ts +124 -53
- package/src/public/public-add.ts +114 -110
- package/src/public/public-edit.ts +163 -159
- package/src/public/public-errors.ts +19 -15
- package/src/public/public-read.ts +63 -59
- package/src/public/public-routes.ts +88 -84
- package/src/public/query-builder.test.ts +224 -220
- package/src/public/query-builder.ts +156 -152
- package/src/public/rate-limit-middleware.ts +34 -30
- package/src/public/read-list.ts +54 -50
- package/src/public/read-single.ts +48 -44
- package/src/public/response-builder.ts +30 -26
- package/src/public/sanitize.ts +69 -65
- package/src/public/slug-utils.ts +18 -14
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
- package/src/rate-limit/no-op-rate-limiter.ts +11 -7
- package/src/search-utils.test.ts +211 -207
- package/src/search-utils.ts +213 -209
- package/src/search.ts +65 -61
- package/src/shared/apply-policies.test.ts +81 -77
- package/src/shared/apply-policies.ts +67 -63
- package/src/shared/automations.repository.d1.ts +150 -146
- package/src/shared/background-notification-service.test.ts +62 -58
- package/src/shared/background-notification-service.ts +52 -48
- package/src/shared/base.repository.d1.ts +32 -28
- package/src/shared/content-utils.test.ts +165 -161
- package/src/shared/content-utils.ts +86 -82
- package/src/shared/content.repository.d1.test.ts +507 -312
- package/src/shared/content.repository.d1.ts +851 -382
- package/src/shared/d1-activity-log.repository.test.ts +140 -136
- package/src/shared/d1-activity-log.repository.ts +105 -101
- package/src/shared/d1-activity-logger.test.ts +86 -82
- package/src/shared/d1-activity-logger.ts +67 -63
- package/src/shared/d1-analytics.repository.test.ts +78 -74
- package/src/shared/d1-analytics.repository.ts +85 -81
- package/src/shared/d1-content-scan.repository.test.ts +80 -76
- package/src/shared/d1-content-scan.repository.ts +37 -29
- package/src/shared/d1-notification.repository.test.ts +128 -124
- package/src/shared/d1-notification.repository.ts +118 -114
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
- package/src/shared/d1-password-reset-token.repository.ts +56 -52
- package/src/shared/d1-search.repository.test.ts +87 -83
- package/src/shared/d1-search.repository.ts +88 -84
- package/src/shared/d1-session.repository.test.ts +125 -121
- package/src/shared/d1-session.repository.ts +102 -98
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +151 -147
- package/src/shared/d1-user.repository.ts +119 -109
- package/src/shared/d1-widget.repository.test.ts +267 -217
- package/src/shared/d1-widget.repository.ts +385 -337
- 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 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +13 -9
- package/src/shared/fixed-clock.ts +25 -21
- package/src/shared/fts-sync.ts +8 -4
- package/src/shared/idempotency.repository.d1.test.ts +83 -79
- package/src/shared/idempotency.repository.d1.ts +60 -56
- package/src/shared/in-memory-activity-logger.ts +19 -15
- package/src/shared/in-memory-notification-service.ts +19 -15
- package/src/shared/in-memory-seed.repository.ts +58 -0
- package/src/shared/media.repository.d1.test.ts +107 -103
- package/src/shared/media.repository.d1.ts +68 -64
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +141 -137
- package/src/shared/request-utils.ts +26 -22
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +33 -22
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +41 -40
- package/src/shared/storage/s3-bucket.ts +195 -163
- package/src/shared/storage-utils.ts +40 -36
- package/src/shared/system-stats.repository.d1.test.ts +58 -54
- package/src/shared/system-stats.repository.d1.ts +48 -44
- package/src/types.ts +90 -67
- package/src/upload.ts +198 -187
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -208
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- package/src/shared/storage/r2-binding-bucket.ts +0 -81
|
@@ -1,45 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|