@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1462 -0
- package/dist/index.d.mts +2853 -3925
- package/dist/index.d.ts +2853 -3925
- package/dist/index.js +1819 -240
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1817 -230
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -7
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -33
- package/src/action-link-pages.ts +0 -102
- package/src/approval-node.test.ts +0 -196
- package/src/approval-node.ts +0 -139
- package/src/approval-revise.test.ts +0 -411
- package/src/approval-service.test.ts +0 -1490
- package/src/approval-service.ts +0 -2360
- package/src/approvals-plugin.ts +0 -263
- package/src/index.ts +0 -39
- package/src/lifecycle-hooks.ts +0 -179
- package/src/nav-contribution.test.ts +0 -50
- package/src/sys-approval-action.object.ts +0 -140
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -103
- package/src/sys-approval-request.object.ts +0 -401
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/en.objects.generated.ts +0 -205
- package/src/translations/es-ES.objects.generated.ts +0 -205
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -205
- package/src/translations/zh-CN.objects.generated.ts +0 -205
- package/tsconfig.json +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0-rc.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,17 +13,20 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "
|
|
17
|
-
"@objectstack/formula": "
|
|
18
|
-
"@objectstack/metadata-core": "
|
|
19
|
-
"@objectstack/platform-objects": "
|
|
20
|
-
"@objectstack/spec": "
|
|
16
|
+
"@objectstack/core": "17.0.0-rc.1",
|
|
17
|
+
"@objectstack/formula": "17.0.0-rc.1",
|
|
18
|
+
"@objectstack/metadata-core": "17.0.0-rc.1",
|
|
19
|
+
"@objectstack/platform-objects": "17.0.0-rc.1",
|
|
20
|
+
"@objectstack/spec": "17.0.0-rc.1",
|
|
21
|
+
"@objectstack/types": "17.0.0-rc.1"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@types/node": "^26.1.1",
|
|
24
25
|
"typescript": "^6.0.3",
|
|
25
26
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/
|
|
27
|
+
"@objectstack/objectql": "17.0.0-rc.1",
|
|
28
|
+
"@objectstack/service-automation": "17.0.0-rc.1",
|
|
29
|
+
"@objectstack/trigger-record-change": "17.0.0-rc.1"
|
|
27
30
|
},
|
|
28
31
|
"keywords": [
|
|
29
32
|
"objectstack",
|
|
@@ -32,8 +35,14 @@
|
|
|
32
35
|
"workflow",
|
|
33
36
|
"process"
|
|
34
37
|
],
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
35
43
|
"scripts": {
|
|
36
44
|
"build": "tsup --config ../../../tsup.config.ts",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
37
46
|
"test": "vitest run --passWithNoTests"
|
|
38
47
|
}
|
|
39
48
|
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @objectstack/plugin-approvals@16.1.0 build /home/runner/work/objectstack/objectstack/packages/plugins/plugin-approvals
|
|
3
|
-
> tsup --config ../../../tsup.config.ts
|
|
4
|
-
|
|
5
|
-
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2020
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m156.89 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m275.84 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 357ms
|
|
16
|
-
[32mCJS[39m [1mdist/index.js [22m[32m158.72 KB[39m
|
|
17
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m277.08 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 372ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 22467ms
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.mts [22m[32m420.38 KB[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m420.38 KB[39m
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed.
|
|
5
|
-
* The plugin owns the i18n extraction for the objects it owns; the
|
|
6
|
-
* `translations` baseline is this plugin's OWN generated bundles so re-running
|
|
7
|
-
* `--merge` preserves every hand-translated string. (Initial zh-CN/ja-JP/es-ES
|
|
8
|
-
* strings were seeded from @objectstack/platform-objects.)
|
|
9
|
-
*
|
|
10
|
-
* os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
|
|
11
|
-
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
|
|
12
|
-
* --out=packages/plugins/plugin-approvals/src/translations
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { defineStack } from '@objectstack/spec';
|
|
16
|
-
import { SysApprovalRequest } from '../src/sys-approval-request.object.js';
|
|
17
|
-
import { SysApprovalAction } from '../src/sys-approval-action.object.js';
|
|
18
|
-
import { SysApprovalDelegation } from '../src/sys-approval-delegation.object.js';
|
|
19
|
-
import { enObjects } from '../src/translations/en.objects.generated.js';
|
|
20
|
-
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
|
|
21
|
-
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
|
|
22
|
-
import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
|
|
23
|
-
|
|
24
|
-
export default defineStack({
|
|
25
|
-
name: 'plugin-approvals-i18n-extract',
|
|
26
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalDelegation] as any,
|
|
27
|
-
translations: [
|
|
28
|
-
{ en: { objects: enObjects } },
|
|
29
|
-
{ 'zh-CN': { objects: zhCNObjects } },
|
|
30
|
-
{ 'ja-JP': { objects: jaJPObjects } },
|
|
31
|
-
{ 'es-ES': { objects: esESObjects } },
|
|
32
|
-
],
|
|
33
|
-
});
|
package/src/action-link-pages.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Session-less HTML for the actionable-link confirm/result pages (ADR-0043).
|
|
5
|
-
*
|
|
6
|
-
* Deliberately tiny and dependency-free: these pages are reached from an
|
|
7
|
-
* email or IM message by a bearer with no session, so they must not assume
|
|
8
|
-
* the Console bundle, auth state, or client-side i18n. Static bilingual
|
|
9
|
-
* (EN / 中文) copy keeps them readable for the demo audience without a
|
|
10
|
-
* locale negotiation step.
|
|
11
|
-
*
|
|
12
|
-
* The GET page NEVER mutates — the decision happens only on the POST form
|
|
13
|
-
* submit (mail-gateway link prefetchers must not approve requests).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { ApprovalRequestRow, ApprovalActionKind } from '@objectstack/spec/contracts';
|
|
17
|
-
|
|
18
|
-
function esc(s: unknown): string {
|
|
19
|
-
return String(s ?? '')
|
|
20
|
-
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
21
|
-
.replaceAll('"', '"').replaceAll("'", ''');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function shell(title: string, body: string): string {
|
|
25
|
-
return `<!doctype html>
|
|
26
|
-
<html lang="en"><head><meta charset="utf-8">
|
|
27
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28
|
-
<meta name="robots" content="noindex">
|
|
29
|
-
<title>${esc(title)}</title>
|
|
30
|
-
<style>
|
|
31
|
-
body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
32
|
-
background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
33
|
-
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
|
|
34
|
-
padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
|
35
|
-
h1{font-size:18px;margin:0 0 4px}
|
|
36
|
-
.sub{color:#64748b;font-size:13px;margin:0 0 20px}
|
|
37
|
-
.row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
|
|
38
|
-
.row b{font-weight:600;text-align:right}
|
|
39
|
-
.k{color:#64748b}
|
|
40
|
-
.actions{margin-top:22px;display:flex;gap:10px}
|
|
41
|
-
button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
|
|
42
|
-
.approve{background:#059669;color:#fff}
|
|
43
|
-
.reject{background:#fff;color:#dc2626;border-color:#fca5a5}
|
|
44
|
-
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
|
|
45
|
-
.ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
|
|
46
|
-
a{color:#2563eb;text-decoration:none}
|
|
47
|
-
.foot{margin-top:18px;font-size:12px;color:#94a3b8}
|
|
48
|
-
</style></head><body><div class="card">${body}</div></body></html>`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function summaryRows(req: ApprovalRequestRow): string {
|
|
52
|
-
const rows: Array<[string, string]> = [
|
|
53
|
-
['Process · 流程', req.process_label || req.process_name],
|
|
54
|
-
['Step · 步骤', req.step_label || req.current_step || '—'],
|
|
55
|
-
['Record · 记录', req.record_title || req.record_id],
|
|
56
|
-
['Object · 对象', req.object_label || req.object_name],
|
|
57
|
-
['Requester · 申请人', req.submitter_name || req.submitter_id || '—'],
|
|
58
|
-
];
|
|
59
|
-
return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join('');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** GET page: summary + a POST form. Rendering only — no mutation. */
|
|
63
|
-
export function renderConfirmPage(input: {
|
|
64
|
-
request: ApprovalRequestRow;
|
|
65
|
-
action: Extract<ApprovalActionKind, 'approve' | 'reject'>;
|
|
66
|
-
approverId: string;
|
|
67
|
-
token: string;
|
|
68
|
-
actPath: string;
|
|
69
|
-
}): string {
|
|
70
|
-
const approving = input.action === 'approve';
|
|
71
|
-
const verb = approving ? 'Approve · 通过' : 'Reject · 拒绝';
|
|
72
|
-
return shell(`${verb} — Approval`, `
|
|
73
|
-
<h1>${approving ? '✅ Approve this request?' : '⛔ Reject this request?'}</h1>
|
|
74
|
-
<p class="sub">${approving ? '确认通过该审批请求?' : '确认拒绝该审批请求?'}
|
|
75
|
-
Acting as · 操作身份:<b>${esc(input.approverId)}</b></p>
|
|
76
|
-
${summaryRows(input.request)}
|
|
77
|
-
<form method="post" action="${esc(input.actPath)}" class="actions">
|
|
78
|
-
<input type="hidden" name="token" value="${esc(input.token)}">
|
|
79
|
-
<button type="submit" class="${approving ? 'approve' : 'reject'}">${verb}</button>
|
|
80
|
-
</form>
|
|
81
|
-
<p class="foot">This link is single-use and expires automatically. · 此链接一次有效,过期自动失效。</p>`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const RESULT_COPY: Record<string, { cls: string; title: string; body: string }> = {
|
|
85
|
-
approved: { cls: 'ok', title: '✅ Approved · 已通过', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
86
|
-
rejected: { cls: 'ok', title: '⛔ Rejected · 已拒绝', body: 'The decision was recorded. · 审批结果已记录。' },
|
|
87
|
-
invalid: { cls: 'err', title: 'Invalid link · 链接无效', body: 'This link is not recognized. · 无法识别该链接。' },
|
|
88
|
-
expired: { cls: 'warn', title: 'Link expired · 链接已过期', body: 'Ask the requester to send a new reminder. · 请让申请人重新发送催办。' },
|
|
89
|
-
consumed: { cls: 'warn', title: 'Already used · 链接已使用', body: 'This link was already used once. · 该链接已被使用过。' },
|
|
90
|
-
not_pending: { cls: 'warn', title: 'Already decided · 请求已处理', body: 'This request is no longer pending. · 该请求已不在待审批状态。' },
|
|
91
|
-
not_approver: { cls: 'warn', title: 'No longer your approval · 已不在你名下', body: 'This approval was handed to someone else. · 该审批已转由他人处理。' },
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/** Terminal page for every redemption outcome (and stale GETs). */
|
|
95
|
-
export function renderResultPage(kind: keyof typeof RESULT_COPY, request?: ApprovalRequestRow): string {
|
|
96
|
-
const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
|
|
97
|
-
return shell(copy.title, `
|
|
98
|
-
<span class="badge ${copy.cls}">${esc(copy.title)}</span>
|
|
99
|
-
${request ? summaryRows(request) : ''}
|
|
100
|
-
<p>${esc(copy.body)}</p>
|
|
101
|
-
<p class="foot"><a href="/system/approvals">Open the Approvals Inbox · 打开审批中心</a></p>`);
|
|
102
|
-
}
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
-
import { AutomationEngine } from '@objectstack/service-automation';
|
|
5
|
-
import { ApprovalService } from './approval-service.js';
|
|
6
|
-
import { registerApprovalNode } from './approval-node.js';
|
|
7
|
-
|
|
8
|
-
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
|
|
9
|
-
|
|
10
|
-
const noopLogger = {
|
|
11
|
-
info() {}, warn() {}, error() {}, debug() {},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Tiny in-memory ObjectQL stand-in — supports the `where`-equality + `$in`
|
|
16
|
-
* queries the approval service issues, enough to drive the node bridge.
|
|
17
|
-
*/
|
|
18
|
-
function makeFakeEngine() {
|
|
19
|
-
const tables = new Map<string, any[]>();
|
|
20
|
-
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
|
|
21
|
-
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
|
|
22
|
-
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
|
|
23
|
-
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
|
|
24
|
-
return row[k] === v;
|
|
25
|
-
});
|
|
26
|
-
return {
|
|
27
|
-
tables,
|
|
28
|
-
async find(object: string, opts: any = {}) {
|
|
29
|
-
const where = opts.where ?? opts.filter ?? {};
|
|
30
|
-
let out = rows(object).filter(r => matches(r, where));
|
|
31
|
-
if (opts.limit) out = out.slice(0, opts.limit);
|
|
32
|
-
return out.map(r => ({ ...r }));
|
|
33
|
-
},
|
|
34
|
-
async insert(object: string, data: any) {
|
|
35
|
-
rows(object).push({ ...data });
|
|
36
|
-
return { ...data };
|
|
37
|
-
},
|
|
38
|
-
async update(object: string, idOrData: any) {
|
|
39
|
-
const id = idOrData.id;
|
|
40
|
-
const row = rows(object).find(r => r.id === id);
|
|
41
|
-
if (row) Object.assign(row, idOrData);
|
|
42
|
-
return row ? { ...row } : null;
|
|
43
|
-
},
|
|
44
|
-
async delete(object: string, opts: any = {}) {
|
|
45
|
-
const where = opts.where ?? {};
|
|
46
|
-
const list = rows(object);
|
|
47
|
-
for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1);
|
|
48
|
-
return { affected: 1 };
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function registerDecisionFlow(engine: AutomationEngine, approvers: Array<{ type: string; value?: string }>, behavior?: 'first_response' | 'unanimous') {
|
|
54
|
-
engine.registerFlow('deal_approval', {
|
|
55
|
-
name: 'deal_approval',
|
|
56
|
-
label: 'Deal Approval',
|
|
57
|
-
type: 'autolaunched',
|
|
58
|
-
nodes: [
|
|
59
|
-
{ id: 'start', type: 'start', label: 'Start' },
|
|
60
|
-
{ id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers, behavior } },
|
|
61
|
-
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
62
|
-
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
63
|
-
{ id: 'end', type: 'end', label: 'End' },
|
|
64
|
-
],
|
|
65
|
-
edges: [
|
|
66
|
-
{ id: 'e1', source: 'start', target: 'approve_step' },
|
|
67
|
-
{ id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' },
|
|
68
|
-
{ id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' },
|
|
69
|
-
{ id: 'e4', source: 'on_approved', target: 'end' },
|
|
70
|
-
{ id: 'e5', source: 'on_rejected', target: 'end' },
|
|
71
|
-
],
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
describe('Approval node bridge (ADR-0019)', () => {
|
|
76
|
-
let automation: AutomationEngine;
|
|
77
|
-
let service: ApprovalService;
|
|
78
|
-
let fake: ReturnType<typeof makeFakeEngine>;
|
|
79
|
-
const marks: string[] = [];
|
|
80
|
-
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
marks.length = 0;
|
|
83
|
-
automation = new AutomationEngine(noopLogger as any);
|
|
84
|
-
fake = makeFakeEngine();
|
|
85
|
-
service = new ApprovalService({ engine: fake as any, logger: noopLogger });
|
|
86
|
-
// The contract `decide()` resumes via the attached automation surface.
|
|
87
|
-
service.attachAutomation(automation);
|
|
88
|
-
registerApprovalNode(automation, service, noopLogger);
|
|
89
|
-
// A terminal "mark" node records which branch ran.
|
|
90
|
-
automation.registerNodeExecutor({
|
|
91
|
-
type: 'mark',
|
|
92
|
-
async execute(node: any) { marks.push(node.id); return { success: true }; },
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('publishes an approval action descriptor that supports pause', () => {
|
|
97
|
-
const descriptors = automation.getActionDescriptors();
|
|
98
|
-
const approval = descriptors.find(d => d.type === 'approval');
|
|
99
|
-
expect(approval).toBeDefined();
|
|
100
|
-
expect(approval!.supportsPause).toBe(true);
|
|
101
|
-
expect(approval!.category).toBe('human');
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('suspends the run on entry and opens a pending request', async () => {
|
|
105
|
-
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
106
|
-
const result = await automation.execute('deal_approval', {
|
|
107
|
-
object: 'crm_deal',
|
|
108
|
-
record: { id: 'd1', amount: 100 },
|
|
109
|
-
userId: 'submitter',
|
|
110
|
-
});
|
|
111
|
-
expect(result.status).toBe('paused');
|
|
112
|
-
expect(result.runId).toBeDefined();
|
|
113
|
-
expect(marks).toHaveLength(0);
|
|
114
|
-
|
|
115
|
-
const requests = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
116
|
-
expect(requests).toHaveLength(1);
|
|
117
|
-
expect(requests[0]).toMatchObject({
|
|
118
|
-
object_name: 'crm_deal', record_id: 'd1', flow_run_id: result.runId, flow_node_id: 'approve_step',
|
|
119
|
-
});
|
|
120
|
-
// Surfaced as a suspended run with the request id as correlation.
|
|
121
|
-
const suspended = automation.listSuspendedRuns();
|
|
122
|
-
expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id });
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('carries the flow name + authored labels onto the request row', async () => {
|
|
126
|
-
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
127
|
-
await automation.execute('deal_approval', {
|
|
128
|
-
object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
|
|
129
|
-
});
|
|
130
|
-
const [raw] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
131
|
-
// Engine-seeded `$flowName` (not the node id) names the source…
|
|
132
|
-
expect(raw.process_name).toBe('flow:deal_approval');
|
|
133
|
-
// …and authored labels ride the config snapshot for inbox display.
|
|
134
|
-
const req = (await service.listRequests({ status: 'pending' }, { isSystem: true } as any))[0];
|
|
135
|
-
expect(req.process_label).toBe('Deal Approval');
|
|
136
|
-
expect(req.step_label).toBe('Manager Approval');
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('resumes down the approve branch on approval', async () => {
|
|
140
|
-
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
141
|
-
const paused = await automation.execute('deal_approval', {
|
|
142
|
-
object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
|
|
143
|
-
});
|
|
144
|
-
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
145
|
-
|
|
146
|
-
const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
147
|
-
|
|
148
|
-
expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true });
|
|
149
|
-
expect(marks).toEqual(['on_approved']);
|
|
150
|
-
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
151
|
-
|
|
152
|
-
const finalReq = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
|
|
153
|
-
expect(finalReq.status).toBe('approved');
|
|
154
|
-
expect(paused.runId).toBeDefined();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('resumes down the reject branch on rejection', async () => {
|
|
158
|
-
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
159
|
-
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
160
|
-
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
161
|
-
|
|
162
|
-
const out = await service.decide(request.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX);
|
|
163
|
-
|
|
164
|
-
expect(out).toMatchObject({ finalized: true, decision: 'reject', resumed: true });
|
|
165
|
-
expect(marks).toEqual(['on_rejected']);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('holds a unanimous step until every approver acts, then resumes', async () => {
|
|
169
|
-
registerDecisionFlow(automation, [
|
|
170
|
-
{ type: 'user', value: 'u1' },
|
|
171
|
-
{ type: 'user', value: 'u2' },
|
|
172
|
-
], 'unanimous');
|
|
173
|
-
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
174
|
-
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
175
|
-
|
|
176
|
-
const first = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
177
|
-
expect(first.finalized).toBe(false);
|
|
178
|
-
expect(first.resumed).toBe(false);
|
|
179
|
-
expect(marks).toHaveLength(0);
|
|
180
|
-
|
|
181
|
-
const second = await service.decide(request.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
|
|
182
|
-
expect(second.finalized).toBe(true);
|
|
183
|
-
expect(second.resumed).toBe(true);
|
|
184
|
-
expect(marks).toEqual(['on_approved']);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('rejects a decision from a non-approver', async () => {
|
|
188
|
-
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
189
|
-
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
190
|
-
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
191
|
-
|
|
192
|
-
await expect(
|
|
193
|
-
service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any),
|
|
194
|
-
).rejects.toThrow(/FORBIDDEN/);
|
|
195
|
-
});
|
|
196
|
-
});
|
package/src/approval-node.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Approval-as-flow-node provider (ADR-0019).
|
|
5
|
-
*
|
|
6
|
-
* Registers an `approval` node executor on the automation engine so an approval
|
|
7
|
-
* rides the one flow engine as a durable-pause node:
|
|
8
|
-
*
|
|
9
|
-
* 1. On entry the node opens a `sys_approval_request` (reusing the mature
|
|
10
|
-
* approver-resolution / audit / lock / status-mirror machinery) and returns
|
|
11
|
-
* `{ suspend: true }` — the engine persists the run and stops traversal.
|
|
12
|
-
* 2. A decision (`ApprovalService.decide`) finalizes the request and resumes
|
|
13
|
-
* the run down the matching `approve` / `reject` out-edge.
|
|
14
|
-
*
|
|
15
|
-
* The approval *state* (request/action rows) stays first-class and owned by this
|
|
16
|
-
* plugin — a flow-run log can't drive an inbox / recall / audit. Only the
|
|
17
|
-
* orchestration (when to pause, which branch to take) moves onto the engine.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
defineActionDescriptor,
|
|
22
|
-
ApprovalNodeConfigSchema,
|
|
23
|
-
getApprovalNodeConfigJsonSchema,
|
|
24
|
-
APPROVAL_NODE_TYPE,
|
|
25
|
-
type ApprovalNodeConfig,
|
|
26
|
-
} from '@objectstack/spec/automation';
|
|
27
|
-
import type { SharingExecutionContext } from '@objectstack/spec/contracts';
|
|
28
|
-
import type { ApprovalService } from './approval-service.js';
|
|
29
|
-
|
|
30
|
-
/** Minimal surface of the automation engine this provider depends on. */
|
|
31
|
-
export interface ApprovalAutomationSurface {
|
|
32
|
-
registerNodeExecutor(executor: {
|
|
33
|
-
type: string;
|
|
34
|
-
descriptor?: unknown;
|
|
35
|
-
execute(node: any, variables: Map<string, unknown>, context: any): Promise<{
|
|
36
|
-
success: boolean;
|
|
37
|
-
output?: Record<string, unknown>;
|
|
38
|
-
error?: string;
|
|
39
|
-
suspend?: boolean;
|
|
40
|
-
correlation?: string;
|
|
41
|
-
}>;
|
|
42
|
-
}): void;
|
|
43
|
-
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface MinimalLogger {
|
|
47
|
-
info?: (msg: any, ...rest: any[]) => void;
|
|
48
|
-
warn?: (msg: any, ...rest: any[]) => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Register the `approval` node executor on the automation engine. Idempotent at
|
|
55
|
-
* the engine level (re-registering replaces). Safe to skip when no automation
|
|
56
|
-
* service is present.
|
|
57
|
-
*/
|
|
58
|
-
export function registerApprovalNode(
|
|
59
|
-
automation: ApprovalAutomationSurface,
|
|
60
|
-
service: ApprovalService,
|
|
61
|
-
logger?: MinimalLogger,
|
|
62
|
-
): void {
|
|
63
|
-
automation.registerNodeExecutor({
|
|
64
|
-
type: APPROVAL_NODE_TYPE,
|
|
65
|
-
descriptor: defineActionDescriptor({
|
|
66
|
-
type: APPROVAL_NODE_TYPE,
|
|
67
|
-
version: '1.0.0',
|
|
68
|
-
name: 'Approval',
|
|
69
|
-
description: 'Route a record for human approval; suspends the flow until a decision, '
|
|
70
|
-
+ 'then continues down the approve / reject branch.',
|
|
71
|
-
icon: 'check-circle',
|
|
72
|
-
category: 'human',
|
|
73
|
-
paradigms: ['flow'],
|
|
74
|
-
source: 'plugin',
|
|
75
|
-
// Human decision: the run suspends here awaiting an external reply.
|
|
76
|
-
supportsPause: true,
|
|
77
|
-
isAsync: true,
|
|
78
|
-
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
79
|
-
// Studio flow designer renders the Approval property form from the engine
|
|
80
|
-
// rather than a hardcoded client form — the engine owns the shape.
|
|
81
|
-
configSchema: getApprovalNodeConfigJsonSchema(),
|
|
82
|
-
}),
|
|
83
|
-
async execute(node, variables, context) {
|
|
84
|
-
const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});
|
|
85
|
-
if (!parsed.success) {
|
|
86
|
-
const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
87
|
-
return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
|
|
88
|
-
}
|
|
89
|
-
const config = parsed.data as ApprovalNodeConfig;
|
|
90
|
-
|
|
91
|
-
const runId = variables.get('$runId');
|
|
92
|
-
const record = (variables.get('$record') ?? context?.record ?? {}) as Record<string, unknown>;
|
|
93
|
-
const object = (context?.object ?? (record as any)?.object_name) as string | undefined;
|
|
94
|
-
const recordId = (record as any)?.id as string | undefined;
|
|
95
|
-
|
|
96
|
-
if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
|
|
97
|
-
if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
|
|
98
|
-
if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
|
|
99
|
-
|
|
100
|
-
// Flow identity comes from engine-seeded variables (`$flowName` /
|
|
101
|
-
// `$flowLabel`) so the request row can carry a human-readable origin;
|
|
102
|
-
// `context.flowName` is a legacy fallback for direct callers.
|
|
103
|
-
const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName;
|
|
104
|
-
const flowLabel = variables.get('$flowLabel') as string | undefined;
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
const request = await service.openNodeRequest({
|
|
108
|
-
object,
|
|
109
|
-
recordId: String(recordId),
|
|
110
|
-
runId: String(runId),
|
|
111
|
-
nodeId: node.id,
|
|
112
|
-
config,
|
|
113
|
-
flowName,
|
|
114
|
-
flowLabel,
|
|
115
|
-
nodeLabel: typeof node.label === 'string' ? node.label : undefined,
|
|
116
|
-
submitterId: context?.userId ?? null,
|
|
117
|
-
record,
|
|
118
|
-
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
119
|
-
}, {
|
|
120
|
-
...SYSTEM_CTX,
|
|
121
|
-
userId: context?.userId,
|
|
122
|
-
organizationId: context?.organizationId,
|
|
123
|
-
tenantId: context?.tenantId,
|
|
124
|
-
} as unknown as SharingExecutionContext);
|
|
125
|
-
|
|
126
|
-
logger?.info?.('[approvals] approval node suspended run', {
|
|
127
|
-
node: node.id, request: request.id, run: String(runId),
|
|
128
|
-
});
|
|
129
|
-
// Suspend the run; the request id is the correlation key surfaced on
|
|
130
|
-
// the suspended-run record for lookup.
|
|
131
|
-
return { success: true, suspend: true, correlation: request.id };
|
|
132
|
-
} catch (err: any) {
|
|
133
|
-
return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` };
|
|
134
|
-
}
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
logger?.info?.('[approvals] approval node executor registered');
|
|
139
|
-
}
|