@beechcms/api 0.4.1 → 0.4.3

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 (67) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-BMkd1Irh.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/factory.ts +12 -4
  8. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  9. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  10. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  11. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  12. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  13. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  14. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  15. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  16. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  17. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  18. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  19. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  20. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  21. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  22. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  23. package/src/features/automations/action-executors/index.ts +33 -0
  24. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  25. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  26. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  27. package/src/features/automations/automation-runner.ts +81 -0
  28. package/src/features/automations/automation-runner.utils.ts +43 -0
  29. package/src/features/automations/automations.handler.ts +193 -0
  30. package/src/features/automations/automations.schema.ts +160 -0
  31. package/src/features/automations/context-resolver.ts +148 -0
  32. package/src/features/automations/cron-runner.ts +136 -0
  33. package/src/features/automations/cron-runner.utils.ts +40 -0
  34. package/src/features/automations/filter-translation.ts +42 -0
  35. package/src/features/automations/index.ts +12 -0
  36. package/src/features/automations/template-grammar.ts +241 -0
  37. package/src/features/automations/var-access-resolver.ts +136 -0
  38. package/src/features/automations/when-evaluator.ts +83 -0
  39. package/src/features/automations/when-pushdown.ts +53 -0
  40. package/src/features/content/handlers/create.ts +8 -0
  41. package/src/features/content/handlers/delete.ts +8 -1
  42. package/src/features/content/handlers/update.ts +8 -0
  43. package/src/features/draft/draft.handler.ts +51 -164
  44. package/src/features/draft/draft.middleware.ts +62 -0
  45. package/src/features/email/email.service.ts +13 -0
  46. package/src/features/email/email.types.ts +10 -0
  47. package/src/features/email/index.ts +2 -1
  48. package/src/features/email/templates/automation-mail.ts +15 -0
  49. package/src/features/settings/settings.handler.ts +2 -1
  50. package/src/index.ts +40 -8
  51. package/src/middleware/repository.middleware.ts +32 -1
  52. package/src/public/cache-utils.ts +34 -0
  53. package/src/public/entry-projection.ts +42 -0
  54. package/src/public/idempotency.ts +19 -0
  55. package/src/public/problem-details.ts +5 -0
  56. package/src/public/public-add.ts +17 -63
  57. package/src/public/public-read.ts +20 -177
  58. package/src/public/read-list.ts +50 -0
  59. package/src/public/read-single.ts +44 -0
  60. package/src/shared/automations.repository.d1.ts +146 -0
  61. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  62. package/src/shared/execution-context-scheduler.ts +9 -0
  63. package/src/shared/storage-utils.ts +3 -3
  64. package/src/types.ts +5 -1
  65. package/src/upload.ts +3 -2
  66. package/assets/dashboard/assets/index-CH13idU1.js +0 -554
  67. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
@@ -0,0 +1,136 @@
1
+ import type { Seed } from '@beechcms/core'
2
+ import type { ParsedKey, InlineCondition } from './template-grammar'
3
+ import { materializeCollection } from './action-executors/set-variable.executor'
4
+
5
+ type VarAccessParsed = Extract<ParsedKey, { kind: 'var_access' }>
6
+
7
+ function toNumeric(v: unknown): number {
8
+ const n = Number(v)
9
+ if (Number.isFinite(n)) return n
10
+ if (typeof v === 'string') {
11
+ const d = Date.parse(v)
12
+ if (!Number.isNaN(d)) return d / 1000
13
+ }
14
+ return NaN
15
+ }
16
+
17
+ function numericCoerce(a: unknown, b: unknown): { left: unknown; right: unknown } {
18
+ const na = toNumeric(a)
19
+ const nb = toNumeric(b)
20
+ const numeric = Number.isFinite(na) || Number.isFinite(nb)
21
+ return { left: numeric ? na : a, right: numeric ? nb : b }
22
+ }
23
+
24
+ function applyCondition(record: Record<string, unknown>, cond: InlineCondition): boolean {
25
+ const raw = record[cond.column]
26
+ const { left, right } = numericCoerce(raw, cond.value)
27
+ switch (cond.op) {
28
+ case '=': return left === right
29
+ case '!=': return left !== right
30
+ case '<': return (left as number) < (right as number)
31
+ case '>': return (left as number) > (right as number)
32
+ case '<=': return (left as number) <= (right as number)
33
+ case '>=': return (left as number) >= (right as number)
34
+ }
35
+ }
36
+
37
+ function applyConditions(
38
+ record: Record<string, unknown>,
39
+ conditions: InlineCondition[],
40
+ ): boolean {
41
+ return conditions.every((c) => applyCondition(record, c))
42
+ }
43
+
44
+ function filterItems(
45
+ items: Array<Record<string, unknown>>,
46
+ conditions: InlineCondition[],
47
+ ): Array<Record<string, unknown>> {
48
+ if (conditions.length === 0) return items
49
+ return items.filter((r) => applyConditions(r, conditions))
50
+ }
51
+
52
+ function getItems(value: unknown): Array<Record<string, unknown>> | null {
53
+ if (!value || typeof value !== 'object') return null
54
+ const items = (value as any)['_items']
55
+ return Array.isArray(items) ? items : null
56
+ }
57
+
58
+ const EMPTY_SEED = { branches: [] } as unknown as Seed
59
+
60
+ export function resolveVarAccess(
61
+ parsed: VarAccessParsed,
62
+ variables: Record<string, unknown>,
63
+ onMissing?: (field: string) => void,
64
+ ): unknown {
65
+ if (!(parsed.name in variables)) {
66
+ onMissing?.(parsed.name)
67
+ return undefined
68
+ }
69
+
70
+ let current: unknown = variables[parsed.name]
71
+ const { steps, conditions } = parsed
72
+
73
+ for (let i = 0; i < steps.length; i++) {
74
+ const step = steps[i]
75
+
76
+ // ── array selector ─────────────────────────────────────────────────────
77
+ if (step.type === 'array') {
78
+ const items = getItems(current) ?? (Array.isArray(current) ? current as Array<Record<string, unknown>> : null)
79
+ if (!items) return undefined
80
+ const idSet = new Set(step.ids)
81
+ const subset = items.filter((r) => idSet.has(String(r['id'] ?? '')))
82
+ // Apply conditions to the subset before building the collection
83
+ const filtered = filterItems(subset, conditions)
84
+ current = materializeCollection(EMPTY_SEED, filtered, null)
85
+ // Conditions consumed — no post-filter needed
86
+ continue
87
+ }
88
+
89
+ // ── nav: firstone / lastone ────────────────────────────────────────────
90
+ if (step.type === 'nav') {
91
+ // Nav moves us from a collection to a single record.
92
+ // Conditions are a guard on that record, NOT a pre-filter on _items.
93
+ const record = (current as any)?.[step.nav] as Record<string, unknown> | null | undefined
94
+ if (!record) { current = null; continue }
95
+ // Apply conditions to the single record (post-filter)
96
+ if (conditions.length > 0 && !applyConditions(record, conditions)) return undefined
97
+ current = record
98
+ continue
99
+ }
100
+
101
+ // ── aggregate ──────────────────────────────────────────────────────────
102
+ if (step.type === 'agg') {
103
+ // Apply conditions to _items before aggregating
104
+ const items = getItems(current)
105
+ if (items !== null) {
106
+ const filtered = filterItems(items, conditions)
107
+ current = materializeCollection(EMPTY_SEED, filtered, null)
108
+ }
109
+ if (step.op === 'count') {
110
+ current = (current as any)?.['count']
111
+ } else if (step.field) {
112
+ const sub = (current as any)?.[step.op]
113
+ current = typeof sub === 'object' && sub !== null ? sub[step.field] : sub
114
+ } else {
115
+ current = (current as any)?.[step.op]
116
+ }
117
+ // Conditions consumed by filtering _items
118
+ return current
119
+ }
120
+
121
+ // ── field access ───────────────────────────────────────────────────────
122
+ if (step.type === 'field') {
123
+ current = (current as any)?.[step.name]
124
+ }
125
+ }
126
+
127
+ // Post-filter on single record (conditions not consumed by agg or array)
128
+ if (conditions.length > 0 && current !== null && current !== undefined) {
129
+ if (typeof current === 'object' && !Array.isArray(current)) {
130
+ if (!applyConditions(current as Record<string, unknown>, conditions)) return undefined
131
+ }
132
+ // For scalar values: conditions cannot be checked — pass through
133
+ }
134
+
135
+ return current
136
+ }
@@ -0,0 +1,83 @@
1
+ import type { WhenNode, WhenPredicate, WhenGroup, WhenOperand } from '@beechcms/core'
2
+ import type { ResolvedContext } from './context-resolver'
3
+ import { parseTemplateKey } from './template-grammar'
4
+
5
+ export function evaluateWhen(
6
+ node: WhenNode | null,
7
+ context: ResolvedContext,
8
+ ): boolean {
9
+ if (!node) return true
10
+ if (node.kind === 'predicate') return evalPredicate(node, context)
11
+ return evalGroup(node, context)
12
+ }
13
+
14
+ function evalGroup(group: WhenGroup, context: ResolvedContext): boolean {
15
+ if (group.children.length === 0) {
16
+ const empty = group.op === 'AND'
17
+ return group.negate ? !empty : empty
18
+ }
19
+
20
+ for (const child of group.children) {
21
+ const r = evaluateWhen(child, context)
22
+ if (group.op === 'AND' && !r) return group.negate ? true : false
23
+ if (group.op === 'OR' && r) return group.negate ? false : true
24
+ }
25
+
26
+ const combined = group.op === 'AND'
27
+ return group.negate ? !combined : combined
28
+ }
29
+
30
+ function resolveOperand(operand: WhenOperand, context: ResolvedContext): unknown {
31
+ if (operand.kind === 'literal') return operand.value
32
+
33
+ // this.field (dot notation from UI) → this:field (colon, parsed as scoped key)
34
+ const key = operand.key.startsWith('this.') ? 'this:' + operand.key.slice(5) : operand.key
35
+ const parsed = parseTemplateKey(key)
36
+ if (!parsed) return null
37
+
38
+ const val = context.lookup(parsed)
39
+ return val ?? null
40
+ }
41
+
42
+ function evalPredicate(pred: WhenPredicate, context: ResolvedContext): boolean {
43
+ const left = resolveOperand(pred.left, context)
44
+ const right = pred.right !== undefined ? resolveOperand(pred.right, context) : undefined
45
+ return compare(pred.op, left, right)
46
+ }
47
+
48
+ function compare(op: string, left: unknown, right: unknown): boolean {
49
+ switch (op) {
50
+ case 'isempty': return left == null || left === ''
51
+ case 'isnotempty': return left != null && left !== ''
52
+
53
+ case 'eq': return coerceEqual(left, right)
54
+ case 'neq': return !coerceEqual(left, right)
55
+
56
+ case 'gt': return Number(left) > Number(right)
57
+ case 'gte': return Number(left) >= Number(right)
58
+ case 'lt': return Number(left) < Number(right)
59
+ case 'lte': return Number(left) <= Number(right)
60
+
61
+ case 'contains': return typeof left === 'string' && typeof right === 'string' && left.includes(right)
62
+ case 'startswith': return typeof left === 'string' && typeof right === 'string' && left.startsWith(right)
63
+ case 'endswith': return typeof left === 'string' && typeof right === 'string' && left.endsWith(right)
64
+
65
+ case 'in': return Array.isArray(right) && right.some((r) => coerceEqual(left, r))
66
+ case 'notin': return !Array.isArray(right) || !right.some((r) => coerceEqual(left, r))
67
+
68
+ case 'matches': {
69
+ if (typeof left !== 'string' || typeof right !== 'string') return false
70
+ try { return new RegExp(right).test(left) } catch { return false }
71
+ }
72
+
73
+ default: return false
74
+ }
75
+ }
76
+
77
+ function coerceEqual(a: unknown, b: unknown): boolean {
78
+ if (a === null && b === null) return true
79
+ if (a === null || b === null) return false
80
+ if (typeof a === 'number' && typeof b === 'string' && !Number.isNaN(Number(b))) return a === Number(b)
81
+ if (typeof b === 'number' && typeof a === 'string' && !Number.isNaN(Number(a))) return Number(a) === b
82
+ return a === b
83
+ }
@@ -0,0 +1,53 @@
1
+ import type { WhenNode, WhenPredicate, TriggerCondition, FilterGroup, Seed } from '@beechcms/core'
2
+ import { conditionToFilterGroup } from './filter-translation'
3
+
4
+ const PUSHDOWN_OPS = new Set<string>(['eq', 'neq', 'contains', 'gt', 'lt', 'isempty', 'isnotempty'])
5
+
6
+ export function extractPushdownFilters(
7
+ node: WhenNode | null,
8
+ seed: Seed,
9
+ ): FilterGroup[] {
10
+ if (!node) return []
11
+
12
+ // Single predicate at root
13
+ if (node.kind === 'predicate') {
14
+ const f = tryPushdown(node, seed)
15
+ return f ? [f] : []
16
+ }
17
+
18
+ // Only extract direct predicate children of the outermost AND group (non-negated)
19
+ if (node.kind === 'group' && node.op === 'AND' && !node.negate) {
20
+ return node.children.flatMap((child) => {
21
+ if (child.kind !== 'predicate') return []
22
+ const f = tryPushdown(child, seed)
23
+ return f ? [f] : []
24
+ })
25
+ }
26
+
27
+ return []
28
+ }
29
+
30
+ function tryPushdown(pred: WhenPredicate, seed: Seed): FilterGroup | null {
31
+ if (pred.left.kind !== 'ref') return null
32
+
33
+ const key = pred.left.key
34
+ if (!key.startsWith('this.')) return null
35
+ const field = key.slice(5)
36
+ if (!field) return null
37
+
38
+ if (!PUSHDOWN_OPS.has(pred.op)) return null
39
+
40
+ if (pred.op === 'isempty' || pred.op === 'isnotempty') {
41
+ return conditionToFilterGroup(
42
+ { field, op: pred.op as TriggerCondition['op'], value: null },
43
+ seed,
44
+ )
45
+ }
46
+
47
+ if (!pred.right || pred.right.kind !== 'literal') return null
48
+
49
+ return conditionToFilterGroup(
50
+ { field, op: pred.op as TriggerCondition['op'], value: pred.right.value },
51
+ seed,
52
+ )
53
+ }
@@ -136,6 +136,14 @@ export async function createHandler(context: Context<AppEnv>) {
136
136
  },
137
137
  })
138
138
 
139
+ context.get('scheduler').waitUntil(
140
+ context.get('automationRunner').run({
141
+ seedSlug: slug,
142
+ event: 'create',
143
+ entry: { id, slug: finalSlug, status, ...privacyData },
144
+ }),
145
+ )
146
+
139
147
  return context.json({ id }, 201)
140
148
  } catch (error) {
141
149
  if (error instanceof SlugConflictError) {
@@ -49,7 +49,14 @@ export async function deleteHandler(context: Context<AppEnv>) {
49
49
  },
50
50
  })
51
51
 
52
-
52
+ context.get('scheduler').waitUntil(
53
+ context.get('automationRunner').run({
54
+ seedSlug: schemaSlug,
55
+ event: 'delete',
56
+ entry: { ...row, id: entryId },
57
+ }),
58
+ )
59
+
53
60
  const r2ObjectKeys = extractMediaKeysFromData(seed, row)
54
61
  if (r2ObjectKeys.length > 0) {
55
62
  await deleteR2Objects(context, r2ObjectKeys).catch((error) => {
@@ -182,6 +182,14 @@ export async function updateHandler(context: Context<AppEnv>) {
182
182
  },
183
183
  })
184
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
+ )
192
+
185
193
  return context.json({ success: true })
186
194
  } catch (error) {
187
195
  if (error instanceof EntryNotFoundError) {
@@ -1,47 +1,50 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
2
+ import { Context, Hono } from 'hono'
3
3
  import {
4
4
  validateAndSanitizeSeedPayload,
5
- resolvePolicies,
6
- EntryNotFoundError
5
+ resolvePolicies
7
6
  } from '@beechcms/core'
8
7
  import { publicProblem } from '../../public/problem-details'
9
8
  import { cleanStr } from '../../shared/query-utils'
10
9
  import { applyVisibility } from '../../shared/apply-policies'
11
10
  import { AppEnv } from '../../types'
12
11
  import { CONTENT_ERRORS } from '../content/constants'
12
+ import { draftGuard } from './draft.middleware'
13
13
 
14
14
  const draftApp = new Hono<AppEnv>()
15
15
 
16
+
16
17
  function normalizeBody(raw: unknown): Record<string, unknown> {
17
18
  return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
18
19
  }
19
20
 
20
- function draftNotAllowed(context: any) {
21
- return publicProblem(context, {
22
- type: 'draft-not-allowed',
23
- title: 'Method Not Allowed',
24
- status: 405,
25
- detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
21
+ function logDraftActivity(
22
+ context: Context<AppEnv>,
23
+ id: string,
24
+ slug: string,
25
+ title: string,
26
+ note: 'draft saved' | 'draft published'
27
+ ) {
28
+ const actor = context.get('jwtPayload')
29
+ context.get('activityLogger').log({
30
+ action: 'update',
31
+ entityType: 'content',
32
+ entityId: id,
33
+ entitySlug: slug,
34
+ details: { title, note },
35
+ actor: {
36
+ id: actor.sub,
37
+ email: actor.email ?? 'unknown',
38
+ name: actor.name ?? null,
39
+ },
26
40
  })
27
41
  }
28
42
 
29
- // PUT /:slug/:id/draft — crea o sovrascrive la bozza in content_{slug}_drafts
30
- draftApp.put('/:slug/:id/draft', async (context) => {
43
+ // PUT /:slug/:id/draft — Creates or overwrites the pending draft
44
+ draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
31
45
  const slug = context.req.param('slug')
32
46
  const id = context.req.param('id')
33
-
34
- const seed = context.get('getSeed')(slug)
35
- if (!seed) {
36
- return publicProblem(context, {
37
- type: 'content-seed-not-found',
38
- title: 'Not Found',
39
- status: 404,
40
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
41
- })
42
- }
43
-
44
- if (!seed.allowDrafts) return draftNotAllowed(context)
47
+ const seed = context.get('getSeed')(slug)!
45
48
 
46
49
  let body: Record<string, unknown>
47
50
  try {
@@ -55,22 +58,6 @@ draftApp.put('/:slug/:id/draft', async (context) => {
55
58
  })
56
59
  }
57
60
 
58
- const repository = context.get('repository')
59
- try {
60
- // Verify entry existence
61
- await repository.findById(seed, id)
62
- } catch (error) {
63
- if (error instanceof EntryNotFoundError) {
64
- return publicProblem(context, {
65
- type: 'content-not-found',
66
- title: 'Not Found',
67
- status: 404,
68
- detail: CONTENT_ERRORS.NOT_FOUND
69
- })
70
- }
71
- throw error
72
- }
73
-
74
61
  const sensitiveAliases = Object.keys(body).filter((alias) => {
75
62
  const branch = seed.branches.find((b) => b.alias === alias)
76
63
  return branch != null && resolvePolicies(branch).privacy !== 'plain'
@@ -111,173 +98,73 @@ draftApp.put('/:slug/:id/draft', async (context) => {
111
98
  })
112
99
  }
113
100
 
101
+ const repository = context.get('repository')
114
102
  await repository.saveDraft(seed, id, validation.data)
115
103
 
116
- const draftSaveActor = context.get('jwtPayload')
117
- context.get('activityLogger').log({
118
- action: 'update',
119
- entityType: 'content',
120
- entityId: id,
121
- entitySlug: slug,
122
- details: {
123
- title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
124
- note: 'draft saved',
125
- },
126
- actor: {
127
- id: draftSaveActor.sub,
128
- email: draftSaveActor.email ?? 'unknown',
129
- name: draftSaveActor.name ?? null,
130
- },
131
- })
104
+ const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
105
+ logDraftActivity(context, id, slug, displayTitle, 'draft saved')
132
106
 
133
107
  return context.json({ success: true })
134
108
  })
135
109
 
136
- // GET /:slug/:id/draft — legge la bozza pendente
137
- draftApp.get('/:slug/:id/draft', async (context) => {
110
+ // GET /:slug/:id/draft — Retrieves the pending draft
111
+ draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
138
112
  const slug = context.req.param('slug')
139
113
  const id = context.req.param('id')
140
-
141
- const seed = context.get('getSeed')(slug)
142
- if (!seed) {
143
- return publicProblem(context, {
144
- type: 'content-seed-not-found',
145
- title: 'Not Found',
146
- status: 404,
147
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
148
- })
149
- }
150
-
151
- if (!seed.allowDrafts) return draftNotAllowed(context)
114
+ const seed = context.get('getSeed')(slug)!
152
115
 
153
116
  const repository = context.get('repository')
154
117
  const draft = await repository.getDraft(seed, id)
155
118
 
156
119
  if (!draft) {
157
- try {
158
- await repository.findById(seed, id)
159
- return publicProblem(context, {
160
- type: 'draft-not-found',
161
- title: 'Not Found',
162
- status: 404,
163
- detail: 'No pending draft for this entry'
164
- })
165
- } catch (error) {
166
- if (error instanceof EntryNotFoundError) {
167
- return publicProblem(context, {
168
- type: 'content-not-found',
169
- title: 'Not Found',
170
- status: 404,
171
- detail: CONTENT_ERRORS.NOT_FOUND
172
- })
173
- }
174
- throw error
175
- }
120
+ return publicProblem(context, {
121
+ type: 'draft-not-found',
122
+ title: 'Not Found',
123
+ status: 404,
124
+ detail: 'No pending draft for this entry'
125
+ })
176
126
  }
177
127
 
178
128
  return context.json({ data: applyVisibility(draft, seed) })
179
129
  })
180
130
 
181
- // POST /:slug/:id/draft/publish — promuove bozza live atomicamente
182
- draftApp.post('/:slug/:id/draft/publish', async (context) => {
131
+ // POST /:slug/:id/draft/publish — Atomically promotes draft to live
132
+ draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
183
133
  const slug = context.req.param('slug')
184
134
  const id = context.req.param('id')
185
-
186
- const seed = context.get('getSeed')(slug)
187
- if (!seed) {
188
- return publicProblem(context, {
189
- type: 'content-seed-not-found',
190
- title: 'Not Found',
191
- status: 404,
192
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
193
- })
194
- }
195
-
196
- if (!seed.allowDrafts) return draftNotAllowed(context)
135
+ const seed = context.get('getSeed')(slug)!
197
136
 
198
137
  const repository = context.get('repository')
199
138
  const draft = await repository.getDraft(seed, id)
200
139
 
201
140
  if (!draft) {
202
- try {
203
- await repository.findById(seed, id)
204
- return publicProblem(context, {
205
- type: 'draft-not-found',
206
- title: 'Not Found',
207
- status: 404,
208
- detail: 'No pending draft to publish'
209
- })
210
- } catch (error) {
211
- if (error instanceof EntryNotFoundError) {
212
- return publicProblem(context, {
213
- type: 'content-not-found',
214
- title: 'Not Found',
215
- status: 404,
216
- detail: CONTENT_ERRORS.NOT_FOUND
217
- })
218
- }
219
- throw error
220
- }
141
+ return publicProblem(context, {
142
+ type: 'draft-not-found',
143
+ title: 'Not Found',
144
+ status: 404,
145
+ detail: 'No pending draft to publish'
146
+ })
221
147
  }
222
148
 
223
149
  await repository.publishDraft(seed, id)
224
150
 
225
151
  const displayValue = draft[seed.displayNameAlias]
226
152
  const displayStr = typeof displayValue === 'string' ? displayValue : id
227
-
228
- const draftPublishActor = context.get('jwtPayload')
229
- context.get('activityLogger').log({
230
- action: 'update',
231
- entityType: 'content',
232
- entityId: id,
233
- entitySlug: slug,
234
- details: { title: displayStr, note: 'draft published' },
235
- actor: {
236
- id: draftPublishActor.sub,
237
- email: draftPublishActor.email ?? 'unknown',
238
- name: draftPublishActor.name ?? null,
239
- },
240
- })
153
+ logDraftActivity(context, id, slug, displayStr, 'draft published')
241
154
 
242
155
  return context.json({ success: true })
243
156
  })
244
157
 
245
- // DELETE /:slug/:id/draft — scarta la bozza pendente
246
- draftApp.delete('/:slug/:id/draft', async (context) => {
158
+ // DELETE /:slug/:id/draft — Discards the pending draft
159
+ draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
247
160
  const slug = context.req.param('slug')
248
161
  const id = context.req.param('id')
249
-
250
- const seed = context.get('getSeed')(slug)
251
- if (!seed) {
252
- return publicProblem(context, {
253
- type: 'content-seed-not-found',
254
- title: 'Not Found',
255
- status: 404,
256
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
257
- })
258
- }
259
-
260
- if (!seed.allowDrafts) return draftNotAllowed(context)
162
+ const seed = context.get('getSeed')(slug)!
261
163
 
262
164
  const repository = context.get('repository')
263
- try {
264
- await repository.findById(seed, id)
265
- } catch (error) {
266
- if (error instanceof EntryNotFoundError) {
267
- return publicProblem(context, {
268
- type: 'content-not-found',
269
- title: 'Not Found',
270
- status: 404,
271
- detail: CONTENT_ERRORS.NOT_FOUND
272
- })
273
- }
274
- throw error
275
- }
276
-
277
165
  await repository.deleteDraft(seed, id)
278
166
 
279
167
  return context.json({ success: true })
280
168
  })
281
169
 
282
170
  export { draftApp }
283
-
@@ -0,0 +1,62 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { EntryNotFoundError } from '@beechcms/core'
3
+ import { publicProblem } from '../../public/problem-details'
4
+ import { CONTENT_ERRORS } from '../content/constants'
5
+ import type { AppEnv } from '../../types'
6
+
7
+ /**
8
+ * Middleware that guards draft-related endpoints.
9
+ * Responsibilities:
10
+ * 1. Validates that the targeted Seed schema exists.
11
+ * 2. Ensures the Seed configuration explicitly allows drafts.
12
+ * 3. Confirms that the main live content entry exists in the repository.
13
+ */
14
+ export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
15
+ const slug = context.req.param('slug')
16
+ const id = context.req.param('id')
17
+
18
+ if (!slug || !id) {
19
+ return publicProblem(context, {
20
+ type: 'content-invalid-request',
21
+ title: 'Bad Request',
22
+ status: 400,
23
+ detail: 'Missing required route parameters',
24
+ })
25
+ }
26
+
27
+ const seed = context.get('getSeed')(slug)
28
+ if (!seed) {
29
+ return publicProblem(context, {
30
+ type: 'content-seed-not-found',
31
+ title: 'Not Found',
32
+ status: 404,
33
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND,
34
+ })
35
+ }
36
+
37
+ if (!seed.allowDrafts) {
38
+ return publicProblem(context, {
39
+ type: 'draft-not-allowed',
40
+ title: 'Method Not Allowed',
41
+ status: 405,
42
+ detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
43
+ })
44
+ }
45
+
46
+ const repository = context.get('repository')
47
+ try {
48
+ await repository.findById(seed, id)
49
+ } catch (error) {
50
+ if (error instanceof EntryNotFoundError) {
51
+ return publicProblem(context, {
52
+ type: 'content-not-found',
53
+ title: 'Not Found',
54
+ status: 404,
55
+ detail: CONTENT_ERRORS.NOT_FOUND,
56
+ })
57
+ }
58
+ throw error
59
+ }
60
+
61
+ await next()
62
+ })