@beechcms/api 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.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 +110 -156
- package/src/public/public-read.ts +59 -216
- 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,270 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { WhenNode } from '@beechcms/core'
|
|
3
|
+
import { evaluateWhen } from '../when-evaluator'
|
|
4
|
+
import type { ResolvedContext } from '../context-resolver'
|
|
5
|
+
import { parseTemplateKey } from '../template-grammar'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Minimal ResolvedContext builder for testing
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
function makeContext(entry: Record<string, unknown>): ResolvedContext {
|
|
12
|
+
return {
|
|
13
|
+
triggerEntry: entry,
|
|
14
|
+
lookup(parsed, onMissing) {
|
|
15
|
+
if (!parsed) return undefined
|
|
16
|
+
if (parsed.kind === 'simple') {
|
|
17
|
+
const parts = parsed.path.split('.')
|
|
18
|
+
let cur: unknown = entry
|
|
19
|
+
for (const p of parts) {
|
|
20
|
+
cur = (cur as Record<string, unknown>)?.[p]
|
|
21
|
+
}
|
|
22
|
+
if (cur === undefined && onMissing) onMissing(parsed.path)
|
|
23
|
+
return cur
|
|
24
|
+
}
|
|
25
|
+
if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.op === 'field' && parsed.field) {
|
|
26
|
+
return (entry as Record<string, unknown>)[parsed.field]
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const entry = { status: 'paid', total: 150, name: 'Alice', tag: 'gold' }
|
|
34
|
+
const ctx = makeContext(entry)
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// null / empty → true
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe('evaluateWhen — null/empty', () => {
|
|
41
|
+
it('null node returns true', () => {
|
|
42
|
+
expect(evaluateWhen(null, ctx)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('empty AND group returns true', () => {
|
|
46
|
+
const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
|
|
47
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('empty OR group returns false', () => {
|
|
51
|
+
const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
|
|
52
|
+
expect(evaluateWhen(node, ctx)).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Basic predicates
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('evaluateWhen — predicates', () => {
|
|
61
|
+
it('eq literal', () => {
|
|
62
|
+
const node: WhenNode = {
|
|
63
|
+
kind: 'predicate',
|
|
64
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
65
|
+
op: 'eq',
|
|
66
|
+
right: { kind: 'literal', value: 'paid' },
|
|
67
|
+
}
|
|
68
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('eq literal fails', () => {
|
|
72
|
+
const node: WhenNode = {
|
|
73
|
+
kind: 'predicate',
|
|
74
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
75
|
+
op: 'eq',
|
|
76
|
+
right: { kind: 'literal', value: 'unpaid' },
|
|
77
|
+
}
|
|
78
|
+
expect(evaluateWhen(node, ctx)).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('gt numeric', () => {
|
|
82
|
+
const node: WhenNode = {
|
|
83
|
+
kind: 'predicate',
|
|
84
|
+
left: { kind: 'ref', key: 'this.total' },
|
|
85
|
+
op: 'gt',
|
|
86
|
+
right: { kind: 'literal', value: 100 },
|
|
87
|
+
}
|
|
88
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('lte numeric', () => {
|
|
92
|
+
const node: WhenNode = {
|
|
93
|
+
kind: 'predicate',
|
|
94
|
+
left: { kind: 'ref', key: 'this.total' },
|
|
95
|
+
op: 'lte',
|
|
96
|
+
right: { kind: 'literal', value: 150 },
|
|
97
|
+
}
|
|
98
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('contains string', () => {
|
|
102
|
+
const node: WhenNode = {
|
|
103
|
+
kind: 'predicate',
|
|
104
|
+
left: { kind: 'ref', key: 'this.name' },
|
|
105
|
+
op: 'contains',
|
|
106
|
+
right: { kind: 'literal', value: 'lic' },
|
|
107
|
+
}
|
|
108
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('startswith', () => {
|
|
112
|
+
const node: WhenNode = {
|
|
113
|
+
kind: 'predicate',
|
|
114
|
+
left: { kind: 'ref', key: 'this.name' },
|
|
115
|
+
op: 'startswith',
|
|
116
|
+
right: { kind: 'literal', value: 'Ali' },
|
|
117
|
+
}
|
|
118
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('isempty — non-empty field', () => {
|
|
122
|
+
const node: WhenNode = {
|
|
123
|
+
kind: 'predicate',
|
|
124
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
125
|
+
op: 'isempty',
|
|
126
|
+
}
|
|
127
|
+
expect(evaluateWhen(node, ctx)).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('isnotempty — non-empty field', () => {
|
|
131
|
+
const node: WhenNode = {
|
|
132
|
+
kind: 'predicate',
|
|
133
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
134
|
+
op: 'isnotempty',
|
|
135
|
+
}
|
|
136
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('matches regex', () => {
|
|
140
|
+
const node: WhenNode = {
|
|
141
|
+
kind: 'predicate',
|
|
142
|
+
left: { kind: 'ref', key: 'this.tag' },
|
|
143
|
+
op: 'matches',
|
|
144
|
+
right: { kind: 'literal', value: '^g' },
|
|
145
|
+
}
|
|
146
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('literal-to-literal comparison', () => {
|
|
150
|
+
const node: WhenNode = {
|
|
151
|
+
kind: 'predicate',
|
|
152
|
+
left: { kind: 'literal', value: 10 },
|
|
153
|
+
op: 'lt',
|
|
154
|
+
right: { kind: 'literal', value: 20 },
|
|
155
|
+
}
|
|
156
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// AND / OR groups with short-circuit
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe('evaluateWhen — groups', () => {
|
|
165
|
+
it('AND group — all true', () => {
|
|
166
|
+
const node: WhenNode = {
|
|
167
|
+
kind: 'group',
|
|
168
|
+
op: 'AND',
|
|
169
|
+
children: [
|
|
170
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
171
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('AND group — one false', () => {
|
|
178
|
+
const node: WhenNode = {
|
|
179
|
+
kind: 'group',
|
|
180
|
+
op: 'AND',
|
|
181
|
+
children: [
|
|
182
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
183
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
expect(evaluateWhen(node, ctx)).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('OR group — first true short-circuits', () => {
|
|
190
|
+
const node: WhenNode = {
|
|
191
|
+
kind: 'group',
|
|
192
|
+
op: 'OR',
|
|
193
|
+
children: [
|
|
194
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
195
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('negate wraps group result', () => {
|
|
202
|
+
const node: WhenNode = {
|
|
203
|
+
kind: 'group',
|
|
204
|
+
op: 'AND',
|
|
205
|
+
negate: true,
|
|
206
|
+
children: [
|
|
207
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
expect(evaluateWhen(node, ctx)).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('nested AND inside OR', () => {
|
|
214
|
+
const node: WhenNode = {
|
|
215
|
+
kind: 'group',
|
|
216
|
+
op: 'AND',
|
|
217
|
+
children: [
|
|
218
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
|
|
219
|
+
{
|
|
220
|
+
kind: 'group',
|
|
221
|
+
op: 'OR',
|
|
222
|
+
children: [
|
|
223
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
|
|
224
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 5000 } },
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
}
|
|
229
|
+
expect(evaluateWhen(node, ctx)).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// null handling
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
describe('evaluateWhen — null operands', () => {
|
|
238
|
+
const emptyCtx = makeContext({})
|
|
239
|
+
|
|
240
|
+
it('null == null → eq true', () => {
|
|
241
|
+
const node: WhenNode = {
|
|
242
|
+
kind: 'predicate',
|
|
243
|
+
left: { kind: 'ref', key: 'this.missing' },
|
|
244
|
+
op: 'eq',
|
|
245
|
+
right: { kind: 'literal', value: null },
|
|
246
|
+
}
|
|
247
|
+
expect(evaluateWhen(node, emptyCtx)).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('null isempty → true', () => {
|
|
251
|
+
const node: WhenNode = {
|
|
252
|
+
kind: 'predicate',
|
|
253
|
+
left: { kind: 'ref', key: 'this.missing' },
|
|
254
|
+
op: 'isempty',
|
|
255
|
+
}
|
|
256
|
+
expect(evaluateWhen(node, emptyCtx)).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('null gt 0 → false (no exception)', () => {
|
|
260
|
+
const node: WhenNode = {
|
|
261
|
+
kind: 'predicate',
|
|
262
|
+
left: { kind: 'ref', key: 'this.missing' },
|
|
263
|
+
op: 'gt',
|
|
264
|
+
right: { kind: 'literal', value: 0 },
|
|
265
|
+
}
|
|
266
|
+
expect(() => evaluateWhen(node, emptyCtx)).not.toThrow()
|
|
267
|
+
expect(evaluateWhen(node, emptyCtx)).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { WhenNode, Seed } from '@beechcms/core'
|
|
3
|
+
import { extractPushdownFilters } from '../when-pushdown'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Minimal Seed
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeSeed(aliases: string[] = []): Seed {
|
|
10
|
+
return {
|
|
11
|
+
slug: 'orders',
|
|
12
|
+
label: 'Orders',
|
|
13
|
+
displayNameAlias: aliases[0] ?? 'id',
|
|
14
|
+
branches: aliases.map((alias) => ({ alias, label: alias, type: 'text' as const })),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const seed = makeSeed(['status', 'total', 'name', 'tag'])
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// null / empty
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('extractPushdownFilters — null/empty', () => {
|
|
25
|
+
it('null returns []', () => {
|
|
26
|
+
expect(extractPushdownFilters(null, seed)).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('empty AND group returns []', () => {
|
|
30
|
+
const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
|
|
31
|
+
expect(extractPushdownFilters(node, seed)).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('empty OR group returns []', () => {
|
|
35
|
+
const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
|
|
36
|
+
expect(extractPushdownFilters(node, seed)).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Safe single-predicate push-down
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('extractPushdownFilters — safe predicates', () => {
|
|
45
|
+
it('root eq predicate is pushed', () => {
|
|
46
|
+
const node: WhenNode = {
|
|
47
|
+
kind: 'predicate',
|
|
48
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
49
|
+
op: 'eq',
|
|
50
|
+
right: { kind: 'literal', value: 'paid' },
|
|
51
|
+
}
|
|
52
|
+
const result = extractPushdownFilters(node, seed)
|
|
53
|
+
expect(result).toHaveLength(1)
|
|
54
|
+
expect(result[0].column).toBe('status')
|
|
55
|
+
expect(result[0].conditions[0].op).toBe('eq')
|
|
56
|
+
expect(result[0].conditions[0].value).toBe('paid')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('root neq predicate is pushed', () => {
|
|
60
|
+
const node: WhenNode = {
|
|
61
|
+
kind: 'predicate',
|
|
62
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
63
|
+
op: 'neq',
|
|
64
|
+
right: { kind: 'literal', value: 'cancelled' },
|
|
65
|
+
}
|
|
66
|
+
const result = extractPushdownFilters(node, seed)
|
|
67
|
+
expect(result).toHaveLength(1)
|
|
68
|
+
expect(result[0].conditions[0].op).toBe('neq')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('root gt predicate is pushed', () => {
|
|
72
|
+
const node: WhenNode = {
|
|
73
|
+
kind: 'predicate',
|
|
74
|
+
left: { kind: 'ref', key: 'this.total' },
|
|
75
|
+
op: 'gt',
|
|
76
|
+
right: { kind: 'literal', value: 100 },
|
|
77
|
+
}
|
|
78
|
+
const result = extractPushdownFilters(node, seed)
|
|
79
|
+
expect(result).toHaveLength(1)
|
|
80
|
+
expect(result[0].conditions[0].op).toBe('gt')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('root lt predicate is pushed', () => {
|
|
84
|
+
const node: WhenNode = {
|
|
85
|
+
kind: 'predicate',
|
|
86
|
+
left: { kind: 'ref', key: 'this.total' },
|
|
87
|
+
op: 'lt',
|
|
88
|
+
right: { kind: 'literal', value: 500 },
|
|
89
|
+
}
|
|
90
|
+
const result = extractPushdownFilters(node, seed)
|
|
91
|
+
expect(result).toHaveLength(1)
|
|
92
|
+
expect(result[0].conditions[0].op).toBe('lt')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('root contains predicate is pushed', () => {
|
|
96
|
+
const node: WhenNode = {
|
|
97
|
+
kind: 'predicate',
|
|
98
|
+
left: { kind: 'ref', key: 'this.name' },
|
|
99
|
+
op: 'contains',
|
|
100
|
+
right: { kind: 'literal', value: 'Alice' },
|
|
101
|
+
}
|
|
102
|
+
const result = extractPushdownFilters(node, seed)
|
|
103
|
+
expect(result).toHaveLength(1)
|
|
104
|
+
expect(result[0].conditions[0].op).toBe('contains')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('root isempty predicate is pushed (no right operand)', () => {
|
|
108
|
+
const node: WhenNode = {
|
|
109
|
+
kind: 'predicate',
|
|
110
|
+
left: { kind: 'ref', key: 'this.name' },
|
|
111
|
+
op: 'isempty',
|
|
112
|
+
}
|
|
113
|
+
const result = extractPushdownFilters(node, seed)
|
|
114
|
+
expect(result).toHaveLength(1)
|
|
115
|
+
expect(result[0].conditions[0].op).toBe('is_empty')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('root isnotempty predicate is pushed (no right operand)', () => {
|
|
119
|
+
const node: WhenNode = {
|
|
120
|
+
kind: 'predicate',
|
|
121
|
+
left: { kind: 'ref', key: 'this.name' },
|
|
122
|
+
op: 'isnotempty',
|
|
123
|
+
}
|
|
124
|
+
const result = extractPushdownFilters(node, seed)
|
|
125
|
+
expect(result).toHaveLength(1)
|
|
126
|
+
expect(result[0].conditions[0].op).toBe('is_not_empty')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// AND group — only direct safe predicate children are pushed
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe('extractPushdownFilters — AND group', () => {
|
|
135
|
+
it('all safe AND children are pushed', () => {
|
|
136
|
+
const node: WhenNode = {
|
|
137
|
+
kind: 'group',
|
|
138
|
+
op: 'AND',
|
|
139
|
+
children: [
|
|
140
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
141
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
|
|
142
|
+
],
|
|
143
|
+
}
|
|
144
|
+
const result = extractPushdownFilters(node, seed)
|
|
145
|
+
expect(result).toHaveLength(2)
|
|
146
|
+
expect(result.map((r) => r.column)).toContain('status')
|
|
147
|
+
expect(result.map((r) => r.column)).toContain('total')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('mixed AND: unsafe children skipped, safe ones pushed', () => {
|
|
151
|
+
const node: WhenNode = {
|
|
152
|
+
kind: 'group',
|
|
153
|
+
op: 'AND',
|
|
154
|
+
children: [
|
|
155
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
156
|
+
// gte is not in PUSHDOWN_OPS → skipped
|
|
157
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gte', right: { kind: 'literal', value: 100 } },
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
const result = extractPushdownFilters(node, seed)
|
|
161
|
+
expect(result).toHaveLength(1)
|
|
162
|
+
expect(result[0].column).toBe('status')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Unsafe operators stay in-memory
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
describe('extractPushdownFilters — unsafe operators stay in-memory', () => {
|
|
171
|
+
for (const op of ['gte', 'lte', 'startswith', 'endswith', 'matches', 'in', 'notin'] as const) {
|
|
172
|
+
it(`${op} is NOT pushed`, () => {
|
|
173
|
+
const node: WhenNode = {
|
|
174
|
+
kind: 'predicate',
|
|
175
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
176
|
+
op,
|
|
177
|
+
right: { kind: 'literal', value: 'x' },
|
|
178
|
+
}
|
|
179
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// OR branches stay in-memory
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe('extractPushdownFilters — OR branches stay in-memory', () => {
|
|
189
|
+
it('OR root group returns []', () => {
|
|
190
|
+
const node: WhenNode = {
|
|
191
|
+
kind: 'group',
|
|
192
|
+
op: 'OR',
|
|
193
|
+
children: [
|
|
194
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
195
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('AND group with nested OR child: only direct predicate siblings are pushed', () => {
|
|
202
|
+
const node: WhenNode = {
|
|
203
|
+
kind: 'group',
|
|
204
|
+
op: 'AND',
|
|
205
|
+
children: [
|
|
206
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
207
|
+
{
|
|
208
|
+
kind: 'group',
|
|
209
|
+
op: 'OR',
|
|
210
|
+
children: [
|
|
211
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
|
|
212
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
const result = extractPushdownFilters(node, seed)
|
|
218
|
+
// Only the direct 'status' predicate is pushed; the nested OR group is skipped
|
|
219
|
+
expect(result).toHaveLength(1)
|
|
220
|
+
expect(result[0].column).toBe('status')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Negated group stays in-memory
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe('extractPushdownFilters — negated AND group stays in-memory', () => {
|
|
229
|
+
it('negated AND root returns []', () => {
|
|
230
|
+
const node: WhenNode = {
|
|
231
|
+
kind: 'group',
|
|
232
|
+
op: 'AND',
|
|
233
|
+
negate: true,
|
|
234
|
+
children: [
|
|
235
|
+
{ kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Cross-seed refs stay in-memory
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
describe('extractPushdownFilters — cross-seed refs stay in-memory', () => {
|
|
247
|
+
it('ref not starting with this. is NOT pushed', () => {
|
|
248
|
+
const node: WhenNode = {
|
|
249
|
+
kind: 'predicate',
|
|
250
|
+
left: { kind: 'ref', key: 'customers:lastone:status' },
|
|
251
|
+
op: 'eq',
|
|
252
|
+
right: { kind: 'literal', value: 'active' },
|
|
253
|
+
}
|
|
254
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('right operand is a ref (not literal) → NOT pushed', () => {
|
|
258
|
+
const node: WhenNode = {
|
|
259
|
+
kind: 'predicate',
|
|
260
|
+
left: { kind: 'ref', key: 'this.status' },
|
|
261
|
+
op: 'eq',
|
|
262
|
+
right: { kind: 'ref', key: 'customers:lastone:status' },
|
|
263
|
+
}
|
|
264
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('literal left operand is NOT pushed', () => {
|
|
268
|
+
const node: WhenNode = {
|
|
269
|
+
kind: 'predicate',
|
|
270
|
+
left: { kind: 'literal', value: 'paid' },
|
|
271
|
+
op: 'eq',
|
|
272
|
+
right: { kind: 'literal', value: 'paid' },
|
|
273
|
+
}
|
|
274
|
+
expect(extractPushdownFilters(node, seed)).toHaveLength(0)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
type CreateEntryAction = Extract<AutomationAction, { type: 'create_entry' }>
|
|
4
|
+
type SeedResolver = (slug: string) => Seed | null
|
|
5
|
+
|
|
6
|
+
export async function executeCreateEntry(
|
|
7
|
+
action: CreateEntryAction,
|
|
8
|
+
entry: Record<string, unknown>,
|
|
9
|
+
repository: ContentRepository,
|
|
10
|
+
getSeed: SeedResolver,
|
|
11
|
+
idGenerator: IIdGenerator,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const targetSeed = getSeed(action.seed_slug)
|
|
14
|
+
if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`)
|
|
15
|
+
|
|
16
|
+
const payload: Record<string, unknown> = {}
|
|
17
|
+
for (const [targetField, sourceField] of Object.entries(action.field_map)) {
|
|
18
|
+
payload[targetField] = (sourceField as string) in entry ? entry[sourceField as string] : sourceField
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const newId = idGenerator.uuid()
|
|
22
|
+
await repository.create(targetSeed, newId, newId, 'draft', payload)
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AutomationAction, ContentRepository, Seed } from '@beechcms/core'
|
|
2
|
+
import type { ResolvedContext } from '../context-resolver'
|
|
3
|
+
import { interpolate } from '../automation-runner.utils'
|
|
4
|
+
|
|
5
|
+
type EditFieldAction = Extract<AutomationAction, { type: 'edit_field' }>
|
|
6
|
+
|
|
7
|
+
export async function executeEditField(
|
|
8
|
+
action: EditFieldAction,
|
|
9
|
+
entry: Record<string, unknown>,
|
|
10
|
+
context: ResolvedContext,
|
|
11
|
+
repository: ContentRepository,
|
|
12
|
+
seed: Seed,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const id = entry.id
|
|
15
|
+
if (typeof id !== 'string') {
|
|
16
|
+
throw new Error('edit_field: entry.id missing')
|
|
17
|
+
}
|
|
18
|
+
const resolved = typeof action.value === 'string'
|
|
19
|
+
? interpolate(action.value, context)
|
|
20
|
+
: action.value
|
|
21
|
+
await repository.update(seed, id, { [action.field]: resolved })
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AutomationAction, ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
|
|
2
|
+
import type { ResolvedContext } from '../context-resolver'
|
|
3
|
+
import { executeWebhook } from './webhook.executor'
|
|
4
|
+
import { executeSendMail } from './send-mail.executor'
|
|
5
|
+
import { executeEditField } from './edit-field.executor'
|
|
6
|
+
import { executeCreateEntry } from './create-entry.executor'
|
|
7
|
+
import { executeSetVariable } from './set-variable.executor'
|
|
8
|
+
|
|
9
|
+
export interface ActionContext {
|
|
10
|
+
entry: Record<string, unknown>
|
|
11
|
+
env: Record<string, string | undefined>
|
|
12
|
+
repository: ContentRepository
|
|
13
|
+
getSeed: (slug: string) => Seed | null
|
|
14
|
+
seed: Seed
|
|
15
|
+
idGenerator: IIdGenerator
|
|
16
|
+
context: ResolvedContext
|
|
17
|
+
variables: Record<string, unknown>
|
|
18
|
+
// TODO Sprint 8: context will also power evaluateWhen() for per-action when-guards
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function executeAction(action: AutomationAction, ctx: ActionContext): Promise<void> {
|
|
22
|
+
switch (action.type) {
|
|
23
|
+
case 'set_variable': return executeSetVariable(action, ctx)
|
|
24
|
+
case 'webhook': return executeWebhook(action, ctx.context)
|
|
25
|
+
case 'send_mail': return executeSendMail(action, ctx.context, ctx.env)
|
|
26
|
+
case 'edit_field': return executeEditField(action, ctx.entry, ctx.context, ctx.repository, ctx.seed)
|
|
27
|
+
case 'create_entry': return executeCreateEntry(action, ctx.entry, ctx.repository, ctx.getSeed, ctx.idGenerator)
|
|
28
|
+
default: {
|
|
29
|
+
const _exhaustive: never = action
|
|
30
|
+
throw new Error(`unknown action type: ${(action as { type: string }).type}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AutomationAction } from '@beechcms/core'
|
|
2
|
+
import type { ResolvedContext } from '../context-resolver'
|
|
3
|
+
import { sendAutomationMail } from '../../email'
|
|
4
|
+
import { interpolate } from '../automation-runner.utils'
|
|
5
|
+
|
|
6
|
+
type SendMailAction = Extract<AutomationAction, { type: 'send_mail' }>
|
|
7
|
+
|
|
8
|
+
export async function executeSendMail(
|
|
9
|
+
action: SendMailAction,
|
|
10
|
+
context: ResolvedContext,
|
|
11
|
+
env: Record<string, string | undefined>,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const missingFields: string[] = []
|
|
14
|
+
const logMissing = (field: string) => missingFields.push(field)
|
|
15
|
+
|
|
16
|
+
const to = interpolate(action.to, context, '[missing]', logMissing)
|
|
17
|
+
const subject = interpolate(action.subject_template, context, '[missing]', logMissing)
|
|
18
|
+
const body = interpolate(action.body_template, context, '[missing]', logMissing)
|
|
19
|
+
|
|
20
|
+
if (missingFields.length > 0) {
|
|
21
|
+
console.warn('[automations:send_mail] placeholders evaluated to default value due to missing concrete data:', missingFields)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const apiKey = env.EMAIL_API_KEY ?? env.RESEND_API_KEY
|
|
25
|
+
if (!apiKey) {
|
|
26
|
+
console.error('[automations:send_mail] Execution skipped: Email API key is missing from the environment configuration.')
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await sendAutomationMail({
|
|
32
|
+
to,
|
|
33
|
+
subject,
|
|
34
|
+
body,
|
|
35
|
+
apiKey,
|
|
36
|
+
from: env.EMAIL_FROM,
|
|
37
|
+
})
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('[automations:send_mail] failed to send automation email via provider:', error)
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
}
|