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