@beechcms/api 0.5.0 → 0.6.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/Searching.svg +1 -0
- package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
- package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/assets/dashboard/noResult.svg +43 -0
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +27 -0
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
- package/src/auth/bcrypt-hash-provider.ts +6 -2
- package/src/auth/constants.ts +4 -0
- package/src/auth/generate-refresh-token.test.ts +8 -4
- package/src/auth/hash-provider.test.ts +14 -5
- package/src/auth/jose-token-service.ts +7 -0
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +9 -1
- package/src/auth/login.ts +5 -1
- package/src/auth/refresh.ts +7 -1
- package/src/auth/token-service.test.ts +9 -1
- package/src/factory.ts +37 -16
- package/src/features/automations/__tests__/action-executors.test.ts +59 -72
- package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
- package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
- package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
- package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
- package/src/features/automations/action-executors/index.ts +5 -1
- package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
- package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
- package/src/features/automations/action-executors/webhook.executor.ts +55 -13
- package/src/features/automations/automation-runner.ts +4 -0
- package/src/features/automations/automation-runner.utils.ts +4 -0
- package/src/features/automations/automations.handler.ts +4 -0
- package/src/features/automations/automations.schema.ts +19 -2
- package/src/features/automations/context-resolver.ts +4 -0
- package/src/features/automations/cron-runner.ts +4 -0
- package/src/features/automations/cron-runner.utils.ts +4 -0
- package/src/features/automations/filter-translation.ts +4 -0
- package/src/features/automations/index.ts +4 -0
- package/src/features/automations/template-grammar.ts +4 -0
- package/src/features/automations/var-access-resolver.ts +4 -0
- package/src/features/automations/when-evaluator.ts +4 -0
- package/src/features/automations/when-pushdown.ts +4 -0
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +6 -0
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +19 -55
- package/src/features/content/handlers/delete.ts +12 -37
- package/src/features/content/handlers/facets.ts +4 -0
- package/src/features/content/handlers/get.ts +4 -0
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +105 -19
- package/src/features/content/handlers/update.ts +19 -64
- package/src/features/content/index.ts +6 -0
- package/src/features/draft/draft.handler.ts +33 -8
- package/src/features/draft/draft.middleware.ts +4 -0
- package/src/features/draft/index.ts +4 -0
- package/src/features/email/email.provider.ts +4 -0
- package/src/features/email/email.service.ts +50 -45
- package/src/features/email/email.types.ts +12 -1
- package/src/features/email/index.ts +4 -0
- package/src/features/email/providers/resend.ts +4 -0
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +4 -0
- package/src/features/email/templates/password-changed.ts +4 -0
- package/src/features/email/templates/password-reset.ts +4 -0
- package/src/features/email/templates/shell.ts +4 -0
- package/src/features/notifications/index.ts +4 -0
- package/src/features/notifications/notifications.handler.ts +4 -0
- package/src/features/password-reset/index.ts +5 -1
- package/src/features/password-reset/request.ts +12 -2
- package/src/features/password-reset/reset.ts +12 -2
- package/src/features/rotate-field/index.ts +4 -0
- package/src/features/rotate-field/rotate-field.handler.ts +4 -0
- package/src/features/rotate-field/rotate-field.schema.ts +4 -0
- package/src/features/schema/schema.handler.ts +118 -3
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +632 -0
- package/src/features/seeds/seeds.handler.ts +693 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +98 -8
- package/src/features/setup/index.ts +124 -11
- package/src/features/stats/index.ts +4 -0
- package/src/features/stats/stats.handler.ts +11 -21
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +28 -19
- package/src/media-utils.ts +4 -0
- package/src/middleware/auth-providers.middleware.ts +13 -0
- package/src/middleware/observability.middleware.ts +21 -3
- package/src/middleware/rate-limit.middleware.ts +4 -0
- package/src/middleware/repository.middleware.ts +25 -2
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +4 -0
- package/src/middleware.ts +5 -7
- package/src/public/access-policy.ts +4 -0
- package/src/public/api-key-middleware.ts +4 -0
- package/src/public/cache-utils.ts +4 -0
- package/src/public/entry-projection.ts +4 -0
- package/src/public/idempotency.ts +4 -0
- package/src/public/index.ts +4 -0
- package/src/public/problem-details.ts +71 -0
- package/src/public/public-add.ts +4 -0
- package/src/public/public-edit.ts +4 -0
- package/src/public/public-errors.ts +4 -0
- package/src/public/public-read.ts +4 -0
- package/src/public/public-routes.ts +4 -0
- package/src/public/query-builder.test.ts +4 -0
- package/src/public/query-builder.ts +4 -0
- package/src/public/rate-limit-middleware.ts +4 -0
- package/src/public/read-list.ts +4 -0
- package/src/public/read-single.ts +4 -0
- package/src/public/response-builder.ts +4 -0
- package/src/public/sanitize.ts +4 -0
- package/src/public/slug-utils.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.ts +4 -0
- package/src/search-utils.test.ts +4 -0
- package/src/search-utils.ts +4 -0
- package/src/search.ts +4 -0
- package/src/shared/apply-policies.test.ts +4 -0
- package/src/shared/apply-policies.ts +4 -0
- package/src/shared/automations.repository.d1.ts +4 -0
- package/src/shared/background-notification-service.test.ts +4 -0
- package/src/shared/background-notification-service.ts +4 -0
- package/src/shared/base.repository.d1.ts +4 -0
- package/src/shared/content-utils.test.ts +4 -0
- package/src/shared/content-utils.ts +4 -0
- package/src/shared/content.repository.d1.test.ts +147 -1
- package/src/shared/content.repository.d1.ts +535 -66
- package/src/shared/d1-activity-log.repository.test.ts +4 -0
- package/src/shared/d1-activity-log.repository.ts +4 -0
- package/src/shared/d1-activity-logger.test.ts +4 -0
- package/src/shared/d1-activity-logger.ts +4 -0
- package/src/shared/d1-analytics.repository.test.ts +4 -0
- package/src/shared/d1-analytics.repository.ts +4 -0
- package/src/shared/d1-content-scan.repository.test.ts +9 -5
- package/src/shared/d1-content-scan.repository.ts +15 -7
- package/src/shared/d1-notification.repository.test.ts +4 -0
- package/src/shared/d1-notification.repository.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.ts +4 -0
- package/src/shared/d1-search.repository.test.ts +4 -0
- package/src/shared/d1-search.repository.ts +4 -0
- package/src/shared/d1-session.repository.test.ts +4 -0
- package/src/shared/d1-session.repository.ts +4 -0
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +8 -4
- package/src/shared/d1-user.repository.ts +15 -5
- package/src/shared/d1-widget.repository.test.ts +4 -0
- package/src/shared/d1-widget.repository.ts +4 -0
- package/src/shared/demo-data-sql.ts +54 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +4 -0
- package/src/shared/fixed-clock.ts +4 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/idempotency.repository.d1.test.ts +4 -0
- package/src/shared/idempotency.repository.d1.ts +4 -0
- package/src/shared/in-memory-activity-logger.ts +4 -0
- package/src/shared/in-memory-notification-service.ts +4 -0
- package/src/shared/in-memory-seed.repository.ts +51 -0
- package/src/shared/media.repository.d1.test.ts +4 -0
- package/src/shared/media.repository.d1.ts +4 -0
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +4 -0
- package/src/shared/request-utils.ts +4 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +11 -0
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +16 -15
- package/src/shared/storage/s3-bucket.ts +34 -2
- package/src/shared/storage-utils.ts +4 -0
- package/src/shared/system-stats.repository.d1.test.ts +4 -0
- package/src/shared/system-stats.repository.d1.ts +5 -1
- package/src/types.ts +23 -3
- package/src/upload.ts +134 -123
- package/src/widget.ts +6 -2
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- package/src/shared/storage/r2-binding-bucket.ts +0 -81
|
@@ -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 { parsePositiveInt, parseQueryFilters, cleanStr, toEngineFilters } from '../../../shared/query-utils'
|
|
3
7
|
import { applyVisibility } from '../../../shared/apply-policies'
|
|
@@ -5,24 +9,103 @@ import { publicProblem } from '../../../public/problem-details'
|
|
|
5
9
|
import { CONTENT_ERRORS } from '../constants'
|
|
6
10
|
import { AppEnv } from '../../../types'
|
|
7
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Builds a compact `relations` map for the list response.
|
|
14
|
+
* For each relation branch in the seed, collects non-null referenced ids from
|
|
15
|
+
* the page items, then fires ONE batched query per target seed to retrieve
|
|
16
|
+
* only the label column. This is O(R) extra queries per page (R = # relation
|
|
17
|
+
* branches) instead of O(N*R) per-row fetches.
|
|
18
|
+
*
|
|
19
|
+
* Shape: { [branchAlias]: { [targetId]: labelString } }
|
|
20
|
+
*/
|
|
21
|
+
async function buildRelationsMap(
|
|
22
|
+
context: Context<AppEnv>,
|
|
23
|
+
seed: Parameters<typeof applyVisibility>[1],
|
|
24
|
+
entries: Record<string, unknown>[]
|
|
25
|
+
): Promise<Record<string, Record<string, string>>> {
|
|
26
|
+
const relationBranches = seed.branches.filter(
|
|
27
|
+
(b: { type: string }) => b.type === 'relation'
|
|
28
|
+
) as Array<{ alias: string; targetSeed?: string }>
|
|
29
|
+
|
|
30
|
+
if (relationBranches.length === 0) return {}
|
|
31
|
+
|
|
32
|
+
const relations: Record<string, Record<string, string>> = {}
|
|
33
|
+
const repository = context.get('repository')
|
|
34
|
+
const seedRegistry = context.get('seedRegistry')
|
|
35
|
+
|
|
36
|
+
for (const branch of relationBranches) {
|
|
37
|
+
const targetSlug = branch.targetSeed
|
|
38
|
+
if (!targetSlug) continue
|
|
39
|
+
|
|
40
|
+
const targetSeedDef = seedRegistry.get(targetSlug)
|
|
41
|
+
if (!targetSeedDef) continue
|
|
42
|
+
|
|
43
|
+
const labelAlias = targetSeedDef.displayNameAlias ?? 'title'
|
|
44
|
+
|
|
45
|
+
// Collect unique non-null ids referenced by this branch
|
|
46
|
+
const ids = Array.from(
|
|
47
|
+
new Set(
|
|
48
|
+
entries
|
|
49
|
+
.map((entry) => {
|
|
50
|
+
const data = entry.data as Record<string, unknown>
|
|
51
|
+
return typeof data[branch.alias] === 'string' && data[branch.alias]
|
|
52
|
+
? (data[branch.alias] as string)
|
|
53
|
+
: null
|
|
54
|
+
})
|
|
55
|
+
.filter((id): id is string => id !== null)
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (ids.length === 0) continue
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const { items } = await repository.findMany(targetSeedDef, {
|
|
63
|
+
filters: [
|
|
64
|
+
{
|
|
65
|
+
column: 'id',
|
|
66
|
+
type: 'system' as const,
|
|
67
|
+
conditions: [{ op: 'in' as const, value: ids }],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
fields: ['id', labelAlias],
|
|
71
|
+
pagination: { limit: ids.length, offset: 0 },
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const map: Record<string, string> = {}
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const id = (item as Record<string, unknown>).id as string
|
|
77
|
+
const data = (item as Record<string, unknown>).data as Record<string, unknown>
|
|
78
|
+
const label = data[labelAlias]
|
|
79
|
+
map[id] = label != null && label !== '' ? String(label) : id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
relations[branch.alias] = map
|
|
83
|
+
} catch {
|
|
84
|
+
// Non-fatal: if the target seed table doesn't exist yet, skip
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return relations
|
|
89
|
+
}
|
|
90
|
+
|
|
8
91
|
export async function listHandler(context: Context<AppEnv>) {
|
|
9
92
|
const slug = context.req.param('slug')
|
|
10
93
|
if (!slug) {
|
|
11
|
-
return publicProblem(context, {
|
|
12
|
-
type: 'content-invalid-slug',
|
|
13
|
-
title: 'Bad Request',
|
|
14
|
-
status: 400,
|
|
15
|
-
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
94
|
+
return publicProblem(context, {
|
|
95
|
+
type: 'content-invalid-slug',
|
|
96
|
+
title: 'Bad Request',
|
|
97
|
+
status: 400,
|
|
98
|
+
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
16
99
|
})
|
|
17
100
|
}
|
|
18
101
|
|
|
19
102
|
const seed = context.get('getSeed')(slug)
|
|
20
103
|
if (!seed) {
|
|
21
|
-
return publicProblem(context, {
|
|
22
|
-
type: 'content-seed-not-found',
|
|
23
|
-
title: 'Not Found',
|
|
24
|
-
status: 404,
|
|
25
|
-
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
104
|
+
return publicProblem(context, {
|
|
105
|
+
type: 'content-seed-not-found',
|
|
106
|
+
title: 'Not Found',
|
|
107
|
+
status: 404,
|
|
108
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
26
109
|
})
|
|
27
110
|
}
|
|
28
111
|
|
|
@@ -33,12 +116,12 @@ export async function listHandler(context: Context<AppEnv>) {
|
|
|
33
116
|
const sortDirRaw = cleanStr(query.sortDir)?.toLowerCase() ?? 'asc'
|
|
34
117
|
const rawFilters = parseQueryFilters(query.filters)
|
|
35
118
|
const engineFilters = toEngineFilters(rawFilters)
|
|
36
|
-
|
|
37
|
-
// Note: repository doesn't yet support has_pending_draft filter/column natively
|
|
119
|
+
|
|
120
|
+
// Note: repository doesn't yet support has_pending_draft filter/column natively
|
|
38
121
|
// in findMany without SQL manipulation. We'll stick to basic findMany for now
|
|
39
122
|
// and might need to enhance the repository if this feature is critical for v1 of this refactor.
|
|
40
123
|
// The legacy code was doing a lot of SQL injection here.
|
|
41
|
-
|
|
124
|
+
|
|
42
125
|
const page = parsePositiveInt(query.page, 1)
|
|
43
126
|
const limit = Math.min(parsePositiveInt(query.limit, 25), 100)
|
|
44
127
|
const offset = (page - 1) * limit
|
|
@@ -75,14 +158,17 @@ export async function listHandler(context: Context<AppEnv>) {
|
|
|
75
158
|
return context.json(entries)
|
|
76
159
|
}
|
|
77
160
|
|
|
78
|
-
|
|
161
|
+
// Build compact relation labels map for N+1 mitigation
|
|
162
|
+
const relations = await buildRelationsMap(context, seed, entries)
|
|
163
|
+
|
|
164
|
+
return context.json({ items: entries, total, page, limit, relations })
|
|
79
165
|
} catch (error) {
|
|
80
166
|
console.error('Content list error:', error)
|
|
81
|
-
return publicProblem(context, {
|
|
82
|
-
type: 'content-database-error',
|
|
83
|
-
title: 'Internal Server Error',
|
|
84
|
-
status: 500,
|
|
85
|
-
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
167
|
+
return publicProblem(context, {
|
|
168
|
+
type: 'content-database-error',
|
|
169
|
+
title: 'Internal Server Error',
|
|
170
|
+
status: 500,
|
|
171
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
86
172
|
})
|
|
87
173
|
}
|
|
88
174
|
}
|
|
@@ -1,34 +1,28 @@
|
|
|
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
9
|
validateAndSanitizeSeedPayload,
|
|
6
|
-
resolvePolicies
|
|
7
|
-
EntryNotFoundError,
|
|
8
|
-
SlugConflictError
|
|
10
|
+
resolvePolicies
|
|
9
11
|
} from '@beechcms/core'
|
|
10
12
|
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
11
13
|
import { publicProblem } from '../../../public/problem-details'
|
|
14
|
+
import {
|
|
15
|
+
normalizeBody,
|
|
16
|
+
contentValidationProblem,
|
|
17
|
+
logContentActivity,
|
|
18
|
+
dispatchContentAutomation,
|
|
19
|
+
handleContentDatabaseError
|
|
20
|
+
} from './helpers'
|
|
12
21
|
import { CONTENT_ERRORS } from '../constants'
|
|
13
22
|
import { cleanStr } from '../../../shared/query-utils'
|
|
14
23
|
import { AppEnv } from '../../../types'
|
|
15
24
|
|
|
16
|
-
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
17
|
-
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
18
|
-
}
|
|
19
25
|
|
|
20
|
-
function contentValidationProblem(
|
|
21
|
-
context: Context,
|
|
22
|
-
details: Array<{ field: string; expected: string; received: string; message: string }>
|
|
23
|
-
) {
|
|
24
|
-
return publicProblem(context, {
|
|
25
|
-
type: 'content-validation-failed',
|
|
26
|
-
title: 'Bad Request',
|
|
27
|
-
status: 400,
|
|
28
|
-
detail: 'Validation failed',
|
|
29
|
-
errors: details
|
|
30
|
-
})
|
|
31
|
-
}
|
|
32
26
|
|
|
33
27
|
export async function updateHandler(context: Context<AppEnv>) {
|
|
34
28
|
const slug = context.req.param('slug')
|
|
@@ -113,10 +107,11 @@ export async function updateHandler(context: Context<AppEnv>) {
|
|
|
113
107
|
}
|
|
114
108
|
|
|
115
109
|
const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
|
|
116
|
-
operation: 'update',
|
|
117
|
-
allowNull: true,
|
|
118
|
-
requireAtLeastOneValidField: true,
|
|
110
|
+
operation: 'update',
|
|
111
|
+
allowNull: true,
|
|
112
|
+
requireAtLeastOneValidField: true,
|
|
119
113
|
enforceRequiredFields: true,
|
|
114
|
+
idGenerator: context.get('idGenerator'),
|
|
120
115
|
})
|
|
121
116
|
|
|
122
117
|
if (validation.dangerousFields.length > 0) {
|
|
@@ -169,51 +164,11 @@ export async function updateHandler(context: Context<AppEnv>) {
|
|
|
169
164
|
const jwtPayload = context.get('jwtPayload')
|
|
170
165
|
const title = mergedData.title || mergedData.name || newSlug
|
|
171
166
|
|
|
172
|
-
context
|
|
173
|
-
|
|
174
|
-
entityType: 'content',
|
|
175
|
-
entityId: id,
|
|
176
|
-
entitySlug: slug,
|
|
177
|
-
details: { title },
|
|
178
|
-
actor: {
|
|
179
|
-
id: jwtPayload.sub,
|
|
180
|
-
email: jwtPayload.email ?? 'unknown',
|
|
181
|
-
name: jwtPayload.name ?? null,
|
|
182
|
-
},
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
context.get('scheduler').waitUntil(
|
|
186
|
-
context.get('automationRunner').run({
|
|
187
|
-
seedSlug: slug,
|
|
188
|
-
event: 'update',
|
|
189
|
-
entry: { ...current, ...mergedData, id, status: newStatus },
|
|
190
|
-
}),
|
|
191
|
-
)
|
|
167
|
+
logContentActivity(context, 'update', id, slug, String(title))
|
|
168
|
+
dispatchContentAutomation(context, slug, 'update', { ...current, ...mergedData, id, status: newStatus })
|
|
192
169
|
|
|
193
170
|
return context.json({ success: true })
|
|
194
171
|
} catch (error) {
|
|
195
|
-
|
|
196
|
-
return publicProblem(context, {
|
|
197
|
-
type: 'content-not-found',
|
|
198
|
-
title: 'Not Found',
|
|
199
|
-
status: 404,
|
|
200
|
-
detail: CONTENT_ERRORS.NOT_FOUND
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
if (error instanceof SlugConflictError) {
|
|
204
|
-
return publicProblem(context, {
|
|
205
|
-
type: 'content-slug-conflict',
|
|
206
|
-
title: 'Conflict',
|
|
207
|
-
status: 409,
|
|
208
|
-
detail: CONTENT_ERRORS.SLUG_CONFLICT
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
console.error('Content update error:', error)
|
|
212
|
-
return publicProblem(context, {
|
|
213
|
-
type: 'content-database-error',
|
|
214
|
-
title: 'Internal Server Error',
|
|
215
|
-
status: 500,
|
|
216
|
-
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
217
|
-
})
|
|
172
|
+
return handleContentDatabaseError(context, error)
|
|
218
173
|
}
|
|
219
174
|
}
|
|
@@ -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 { Hono } from 'hono'
|
|
2
6
|
import { AppEnv } from '../../types'
|
|
3
7
|
import { listHandler } from './handlers/list'
|
|
@@ -6,6 +10,7 @@ import { createHandler } from './handlers/create'
|
|
|
6
10
|
import { updateHandler } from './handlers/update'
|
|
7
11
|
import { deleteHandler } from './handlers/delete'
|
|
8
12
|
import { facetsHandler } from './handlers/facets'
|
|
13
|
+
import { bulkHandler } from './handlers/bulk.handler'
|
|
9
14
|
|
|
10
15
|
const content = new Hono<AppEnv>()
|
|
11
16
|
|
|
@@ -14,6 +19,7 @@ content.get('/:slug/facets', facetsHandler)
|
|
|
14
19
|
content.get('/:schema_slug/by-slug/:entry_slug', getBySlugHandler)
|
|
15
20
|
content.get('/:slug/:id', getByIdHandler)
|
|
16
21
|
content.post('/:slug', createHandler)
|
|
22
|
+
content.patch('/:slug/bulk', bulkHandler)
|
|
17
23
|
content.put('/:slug/:id', updateHandler)
|
|
18
24
|
content.delete('/:slug/:id', deleteHandler)
|
|
19
25
|
|
|
@@ -1,8 +1,13 @@
|
|
|
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
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
6
|
import { Context, Hono } from 'hono'
|
|
3
|
-
import {
|
|
4
|
-
validateAndSanitizeSeedPayload,
|
|
5
|
-
resolvePolicies
|
|
7
|
+
import {
|
|
8
|
+
validateAndSanitizeSeedPayload,
|
|
9
|
+
resolvePolicies,
|
|
10
|
+
RelationTargetNotFoundError,
|
|
6
11
|
} from '@beechcms/core'
|
|
7
12
|
import { publicProblem } from '../../public/problem-details'
|
|
8
13
|
import { cleanStr } from '../../shared/query-utils'
|
|
@@ -13,6 +18,13 @@ import { draftGuard } from './draft.middleware'
|
|
|
13
18
|
|
|
14
19
|
const draftApp = new Hono<AppEnv>()
|
|
15
20
|
|
|
21
|
+
// GET /drafts — Unified list of pending drafts across all draft-enabled seeds.
|
|
22
|
+
draftApp.get('/drafts', async (context) => {
|
|
23
|
+
const seeds = context.get('seedRegistry').draftEnabled()
|
|
24
|
+
const repository = context.get('repository')
|
|
25
|
+
const drafts = await repository.findPendingDrafts(seeds)
|
|
26
|
+
return context.json(drafts)
|
|
27
|
+
})
|
|
16
28
|
|
|
17
29
|
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
18
30
|
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
@@ -35,7 +47,7 @@ function logDraftActivity(
|
|
|
35
47
|
actor: {
|
|
36
48
|
id: actor.sub,
|
|
37
49
|
email: actor.email ?? 'unknown',
|
|
38
|
-
name: actor.name
|
|
50
|
+
name: [actor.name, actor.surname].filter(Boolean).join(' ') || null,
|
|
39
51
|
},
|
|
40
52
|
})
|
|
41
53
|
}
|
|
@@ -73,10 +85,11 @@ draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
|
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
const validation = validateAndSanitizeSeedPayload(seed, body, {
|
|
76
|
-
operation: 'update',
|
|
77
|
-
allowNull: true,
|
|
78
|
-
requireAtLeastOneValidField: true,
|
|
88
|
+
operation: 'update',
|
|
89
|
+
allowNull: true,
|
|
90
|
+
requireAtLeastOneValidField: true,
|
|
79
91
|
enforceRequiredFields: false,
|
|
92
|
+
idGenerator: context.get('idGenerator'),
|
|
80
93
|
})
|
|
81
94
|
|
|
82
95
|
if (validation.dangerousFields.length > 0) {
|
|
@@ -146,7 +159,19 @@ draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
|
|
|
146
159
|
})
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
|
|
162
|
+
try {
|
|
163
|
+
await repository.publishDraft(seed, id)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof RelationTargetNotFoundError) {
|
|
166
|
+
return publicProblem(context, {
|
|
167
|
+
type: 'relation-target-not-found',
|
|
168
|
+
title: 'Relation Target Not Found',
|
|
169
|
+
status: 422,
|
|
170
|
+
detail: `Field '${err.alias}' references '${err.targetSeed}' id='${err.value}' which does not exist`,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
throw err
|
|
174
|
+
}
|
|
150
175
|
|
|
151
176
|
const displayValue = draft[seed.displayNameAlias]
|
|
152
177
|
const displayStr = typeof displayValue === 'string' ? displayValue : id
|
|
@@ -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 { createMiddleware } from 'hono/factory'
|
|
2
6
|
import { EntryNotFoundError } from '@beechcms/core'
|
|
3
7
|
import { publicProblem } from '../../public/problem-details'
|
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* caller → service function → template builder → provider → Resend (or other)
|
|
6
|
-
*
|
|
7
|
-
* This is the only file that imports from both templates and the provider.
|
|
8
|
-
* No other layer knows the entire pipeline.
|
|
9
|
-
*
|
|
10
|
-
* ─── CHANGING PROVIDER ───────────────────────────────────────────────────────
|
|
11
|
-
* To replace Resend with another service, ONLY modify the
|
|
12
|
-
* `createProvider()` function below: change the import and instantiation.
|
|
13
|
-
* No other module file — nor anywhere else in the project — needs to be touched.
|
|
14
|
-
* ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
*/
|
|
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
|
+
|
|
16
5
|
import { ResendEmailProvider } from './providers/resend'
|
|
6
|
+
import { SmtpEmailProvider } from './providers/smtp'
|
|
17
7
|
import { buildPasswordResetEmail } from './templates/password-reset'
|
|
18
8
|
import { buildPasswordChangedEmail } from './templates/password-changed'
|
|
19
9
|
import { buildAutomationEmail } from './templates/automation-mail'
|
|
@@ -24,33 +14,46 @@ import type {
|
|
|
24
14
|
AutomationMailParams,
|
|
25
15
|
} from './email.types'
|
|
26
16
|
|
|
27
|
-
/** Default sender address (Resend test sender, works without a verified domain). */
|
|
28
17
|
const DEFAULT_FROM = 'Beech CMS <onboarding@resend.dev>'
|
|
29
18
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
|
|
19
|
+
export interface EmailProviderEnv {
|
|
20
|
+
/** "smtp" | "resend"; default "resend" if absent */
|
|
21
|
+
provider?: string
|
|
22
|
+
/** Resend API key, used when provider is "resend" */
|
|
23
|
+
apiKey?: string
|
|
24
|
+
/** Mailpit base URL (e.g. http://localhost:8025), used when provider is "smtp" */
|
|
25
|
+
smtpBaseUrl?: string
|
|
26
|
+
isDev?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// TODO: il sistema di selezione provider è attualmente hardcoded su "smtp" | "resend".
|
|
30
|
+
// Sarebbe meglio aprirlo a provider di terze parti (Postmark, SendGrid, Brevo, SES, ...)
|
|
31
|
+
// senza che l'utente debba toccare il codice. Opzioni da valutare:
|
|
32
|
+
// 1. Strategia plugin: EMAIL_PROVIDER accetta un path a modulo ES (`./my-provider.ts`)
|
|
33
|
+
// che esporta default class implementing EmailProvider — zero lock-in.
|
|
34
|
+
// 2. Provider SMTP generico: rimuovere il coupling con Mailpit dalla denominazione
|
|
35
|
+
// e accettare qualsiasi server SMTP via SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS
|
|
36
|
+
// (il SmtpEmailProvider attuale usa già l'HTTP API di Mailpit, non SMTP raw —
|
|
37
|
+
// andrebbe riscritto con nodemailer o un client SMTP standard per coprire qualsiasi server).
|
|
38
|
+
// 3. Webhook email: EMAIL_PROVIDER=webhook + EMAIL_WEBHOOK_URL per integrazioni custom.
|
|
39
|
+
// Riferimento: email.provider.ts definisce l'interfaccia — è già sufficientemente astratta.
|
|
40
|
+
function createProvider(env: EmailProviderEnv): EmailProvider {
|
|
41
|
+
if (env.provider === 'smtp') {
|
|
42
|
+
if (!env.smtpBaseUrl) throw new Error('SMTP provider selected but SMTP_HOST is missing')
|
|
43
|
+
return new SmtpEmailProvider({ baseUrl: env.smtpBaseUrl })
|
|
44
|
+
}
|
|
45
|
+
return new ResendEmailProvider(env.apiKey ?? '', env.isDev ?? false)
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
/**
|
|
41
|
-
* Sends the password reset link email to the specified recipient.
|
|
42
|
-
*
|
|
43
|
-
* The email body is built from the localized template in
|
|
44
|
-
* `templates/password-reset.ts` and composed with the base layout in
|
|
45
|
-
* `templates/shell.ts`.
|
|
46
|
-
*
|
|
47
|
-
* @throws If the provider rejects the request. The caller decides whether to propagate
|
|
48
|
-
* the error (request fail) or handle it silently (fire-and-forget).
|
|
49
|
-
*/
|
|
50
48
|
export async function sendPasswordResetEmail(
|
|
51
49
|
params: PasswordResetEmailParams,
|
|
52
50
|
): Promise<void> {
|
|
53
|
-
const provider = createProvider(
|
|
51
|
+
const provider = createProvider({
|
|
52
|
+
provider: params.provider,
|
|
53
|
+
apiKey: params.apiKey,
|
|
54
|
+
smtpBaseUrl: params.smtpBaseUrl,
|
|
55
|
+
isDev: params.isDev ?? false,
|
|
56
|
+
})
|
|
54
57
|
const { subject, html } = buildPasswordResetEmail(params.resetUrl, params.locale)
|
|
55
58
|
await provider.send({
|
|
56
59
|
from: params.from ?? DEFAULT_FROM,
|
|
@@ -60,18 +63,15 @@ export async function sendPasswordResetEmail(
|
|
|
60
63
|
})
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
/**
|
|
64
|
-
* Sends the "password changed" security notification to the account owner.
|
|
65
|
-
*
|
|
66
|
-
* Called after a successful password reset to notify the user. It does not have a
|
|
67
|
-
* CTA button — it is a pure notification, no action required from the user.
|
|
68
|
-
*
|
|
69
|
-
* @throws If the provider rejects the request.
|
|
70
|
-
*/
|
|
71
66
|
export async function sendPasswordChangedEmail(
|
|
72
67
|
params: PasswordChangedEmailParams,
|
|
73
68
|
): Promise<void> {
|
|
74
|
-
const provider = createProvider(
|
|
69
|
+
const provider = createProvider({
|
|
70
|
+
provider: params.provider,
|
|
71
|
+
apiKey: params.apiKey,
|
|
72
|
+
smtpBaseUrl: params.smtpBaseUrl,
|
|
73
|
+
isDev: params.isDev ?? false,
|
|
74
|
+
})
|
|
75
75
|
const { subject, html } = buildPasswordChangedEmail(params.locale)
|
|
76
76
|
await provider.send({
|
|
77
77
|
from: params.from ?? DEFAULT_FROM,
|
|
@@ -82,7 +82,12 @@ export async function sendPasswordChangedEmail(
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export async function sendAutomationMail(params: AutomationMailParams): Promise<void> {
|
|
85
|
-
const provider = createProvider(
|
|
85
|
+
const provider = createProvider({
|
|
86
|
+
provider: params.provider,
|
|
87
|
+
apiKey: params.apiKey ?? params.resendApiKey,
|
|
88
|
+
smtpBaseUrl: params.smtpBaseUrl,
|
|
89
|
+
isDev: false,
|
|
90
|
+
})
|
|
86
91
|
const message = buildAutomationEmail(params)
|
|
87
92
|
await provider.send({
|
|
88
93
|
from: params.from ?? DEFAULT_FROM,
|
|
@@ -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
|
/**
|
|
2
6
|
* Shared types for the Beech CMS email module.
|
|
3
7
|
*
|
|
@@ -92,10 +96,15 @@ export interface PasswordResetEmailParams extends BaseEmailParams {
|
|
|
92
96
|
* Constructed by the caller as `${APP_URL}/reset-password?token=${token}`.
|
|
93
97
|
*/
|
|
94
98
|
resetUrl: string
|
|
99
|
+
provider?: 'smtp' | 'resend'
|
|
100
|
+
smtpBaseUrl?: string
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/** Parameters for the "password changed" notification. No additional fields. */
|
|
98
|
-
export
|
|
104
|
+
export interface PasswordChangedEmailParams extends BaseEmailParams {
|
|
105
|
+
provider?: 'smtp' | 'resend'
|
|
106
|
+
smtpBaseUrl?: string
|
|
107
|
+
}
|
|
99
108
|
|
|
100
109
|
export interface AutomationMailParams {
|
|
101
110
|
to: string
|
|
@@ -105,4 +114,6 @@ export interface AutomationMailParams {
|
|
|
105
114
|
apiKey?: string
|
|
106
115
|
resendApiKey?: string
|
|
107
116
|
from?: string
|
|
117
|
+
provider?: 'smtp' | 'resend'
|
|
118
|
+
smtpBaseUrl?: string
|
|
108
119
|
}
|
|
@@ -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
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
6
|
import type { EmailProvider } from '../email.provider'
|
|
3
7
|
import type { OutboundEmail } from '../email.types'
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import type { EmailProvider } from '../email.provider'
|
|
7
|
+
import type { OutboundEmail } from '../email.types'
|
|
8
|
+
|
|
9
|
+
export interface SmtpProviderConfig {
|
|
10
|
+
/** Base URL of the Mailpit HTTP API, e.g. http://localhost:8025 */
|
|
11
|
+
baseUrl: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MailpitAddress { Name?: string; Email: string }
|
|
15
|
+
interface MailpitSendPayload {
|
|
16
|
+
From: MailpitAddress
|
|
17
|
+
To: MailpitAddress[]
|
|
18
|
+
Subject: string
|
|
19
|
+
HTML?: string
|
|
20
|
+
Text?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseAddress(raw: string): MailpitAddress {
|
|
24
|
+
const match = raw.match(/^\s*(.+?)\s*<([^>]+)>\s*$/)
|
|
25
|
+
if (match) return { Name: match[1], Email: match[2] }
|
|
26
|
+
return { Email: raw.trim() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SmtpEmailProvider implements EmailProvider {
|
|
30
|
+
private readonly endpoint: string
|
|
31
|
+
|
|
32
|
+
constructor(config: SmtpProviderConfig) {
|
|
33
|
+
this.endpoint = `${config.baseUrl.replace(/\/$/, '')}/api/v1/send`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async send(email: OutboundEmail): Promise<void> {
|
|
37
|
+
const payload: MailpitSendPayload = {
|
|
38
|
+
From: parseAddress(email.from),
|
|
39
|
+
To: email.to.map(parseAddress),
|
|
40
|
+
Subject: email.subject,
|
|
41
|
+
HTML: email.html,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(this.endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(payload),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const body = await response.text().catch(() => `HTTP ${response.status}`)
|
|
52
|
+
throw new Error(`[SmtpEmailProvider] send failed — ${body}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -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 type { AutomationMailParams } from '../email.types'
|
|
2
6
|
|
|
3
7
|
/** Identity builder: automation payloads are already user-authored. */
|