@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.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-BMkd1Irh.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/factory.ts +12 -4
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +8 -0
- package/src/features/content/handlers/delete.ts +8 -1
- package/src/features/content/handlers/update.ts +8 -0
- package/src/features/draft/draft.handler.ts +51 -164
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/settings/settings.handler.ts +2 -1
- package/src/index.ts +40 -8
- package/src/middleware/repository.middleware.ts +32 -1
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +17 -63
- package/src/public/public-read.ts +20 -177
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/types.ts +5 -1
- package/src/upload.ts +3 -2
- package/assets/dashboard/assets/index-CH13idU1.js +0 -554
- 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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 —
|
|
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
|
|
117
|
-
context
|
|
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 —
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 —
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 —
|
|
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
|
+
})
|