@objectstack/plugin-approvals 7.3.0 → 7.4.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +85 -0
- package/dist/index.d.mts +6431 -107
- package/dist/index.d.ts +6431 -107
- package/dist/index.js +1237 -776
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1244 -779
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/scripts/i18n-extract.config.ts +32 -0
- package/src/approval-node.test.ts +182 -0
- package/src/approval-node.ts +131 -0
- package/src/approval-service.test.ts +205 -304
- package/src/approval-service.ts +208 -491
- package/src/approvals-plugin.ts +61 -53
- package/src/index.ts +12 -11
- package/src/lifecycle-hooks.ts +67 -202
- package/src/nav-contribution.test.ts +46 -0
- package/src/sys-approval-action.object.ts +120 -0
- package/src/sys-approval-request.object.ts +227 -0
- package/src/translations/en.objects.generated.ts +156 -0
- package/src/translations/es-ES.objects.generated.ts +156 -0
- package/src/translations/index.ts +23 -0
- package/src/translations/ja-JP.objects.generated.ts +156 -0
- package/src/translations/zh-CN.objects.generated.ts +156 -0
- package/src/action-executor.ts +0 -313
- package/src/phase-b.test.ts +0 -263
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-generated by 'os i18n extract' for locale 'ja-JP'.
|
|
5
|
+
* Edit translations in place; re-run extract (with --merge) to fill new gaps.
|
|
6
|
+
* Do not hand-edit the structure — only the leaf string values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TranslationData } from '@objectstack/spec/system';
|
|
10
|
+
|
|
11
|
+
export const jaJPObjects: NonNullable<TranslationData['objects']> = {
|
|
12
|
+
sys_approval_request: {
|
|
13
|
+
label: "承認リクエスト",
|
|
14
|
+
pluralLabel: "承認リクエスト",
|
|
15
|
+
description: "送信ごとに追跡されるライブ承認インスタンス",
|
|
16
|
+
fields: {
|
|
17
|
+
id: {
|
|
18
|
+
label: "リクエスト ID"
|
|
19
|
+
},
|
|
20
|
+
organization_id: {
|
|
21
|
+
label: "組織",
|
|
22
|
+
help: "この承認リクエストを所有するテナント(送信者コンテキストから伝播)"
|
|
23
|
+
},
|
|
24
|
+
process_name: {
|
|
25
|
+
label: "ソース",
|
|
26
|
+
help: "リクエストの発生元 — ノード駆動の承認では `flow:<flowName|nodeId>`"
|
|
27
|
+
},
|
|
28
|
+
object_name: {
|
|
29
|
+
label: "オブジェクト"
|
|
30
|
+
},
|
|
31
|
+
record_id: {
|
|
32
|
+
label: "レコード ID"
|
|
33
|
+
},
|
|
34
|
+
submitter_id: {
|
|
35
|
+
label: "送信者"
|
|
36
|
+
},
|
|
37
|
+
submitter_comment: {
|
|
38
|
+
label: "送信者コメント"
|
|
39
|
+
},
|
|
40
|
+
status: {
|
|
41
|
+
label: "ステータス",
|
|
42
|
+
help: "リクエストのライフサイクル状態",
|
|
43
|
+
options: {
|
|
44
|
+
pending: "保留中",
|
|
45
|
+
approved: "承認済み",
|
|
46
|
+
rejected: "却下済み",
|
|
47
|
+
recalled: "取り消し済み"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
current_step: {
|
|
51
|
+
label: "現在のステップ",
|
|
52
|
+
help: "承認待ちのステップの機械名"
|
|
53
|
+
},
|
|
54
|
+
current_step_index: {
|
|
55
|
+
label: "現在のステップ番号"
|
|
56
|
+
},
|
|
57
|
+
pending_approvers: {
|
|
58
|
+
label: "承認待ち承認者",
|
|
59
|
+
help: "現在のステップを処理できるユーザー ID のカンマ区切りリスト"
|
|
60
|
+
},
|
|
61
|
+
payload_json: {
|
|
62
|
+
label: "スナップショット",
|
|
63
|
+
help: "送信時のレコードスナップショット"
|
|
64
|
+
},
|
|
65
|
+
flow_run_id: {
|
|
66
|
+
label: "Flow Run",
|
|
67
|
+
help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
|
|
68
|
+
},
|
|
69
|
+
flow_node_id: {
|
|
70
|
+
label: "Flow Node",
|
|
71
|
+
help: "Approval node id within the flow that opened this request (ADR-0019)."
|
|
72
|
+
},
|
|
73
|
+
node_config_json: {
|
|
74
|
+
label: "Node Config",
|
|
75
|
+
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
|
|
76
|
+
},
|
|
77
|
+
completed_at: {
|
|
78
|
+
label: "完了日時"
|
|
79
|
+
},
|
|
80
|
+
created_at: {
|
|
81
|
+
label: "作成日時"
|
|
82
|
+
},
|
|
83
|
+
updated_at: {
|
|
84
|
+
label: "更新日時"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
_views: {
|
|
88
|
+
my_pending: {
|
|
89
|
+
label: "自分の保留中"
|
|
90
|
+
},
|
|
91
|
+
submitted_by_me: {
|
|
92
|
+
label: "自分が送信"
|
|
93
|
+
},
|
|
94
|
+
completed: {
|
|
95
|
+
label: "完了済み"
|
|
96
|
+
},
|
|
97
|
+
all_requests: {
|
|
98
|
+
label: "すべて"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
sys_approval_action: {
|
|
103
|
+
label: "承認アクション",
|
|
104
|
+
pluralLabel: "承認アクション",
|
|
105
|
+
description: "承認アクションの追記専用監査証跡",
|
|
106
|
+
fields: {
|
|
107
|
+
id: {
|
|
108
|
+
label: "アクション ID"
|
|
109
|
+
},
|
|
110
|
+
organization_id: {
|
|
111
|
+
label: "組織",
|
|
112
|
+
help: "このアクションを所有するテナント(親リクエストと同じ)"
|
|
113
|
+
},
|
|
114
|
+
request_id: {
|
|
115
|
+
label: "リクエスト"
|
|
116
|
+
},
|
|
117
|
+
step_name: {
|
|
118
|
+
label: "ステップ",
|
|
119
|
+
help: "アクション時点のステップの機械名"
|
|
120
|
+
},
|
|
121
|
+
step_index: {
|
|
122
|
+
label: "ステップ番号"
|
|
123
|
+
},
|
|
124
|
+
action: {
|
|
125
|
+
label: "アクション",
|
|
126
|
+
options: {
|
|
127
|
+
submit: "申請",
|
|
128
|
+
approve: "承認",
|
|
129
|
+
reject: "却下",
|
|
130
|
+
recall: "取消",
|
|
131
|
+
escalate: "エスカレーション"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
actor_id: {
|
|
135
|
+
label: "操作者"
|
|
136
|
+
},
|
|
137
|
+
comment: {
|
|
138
|
+
label: "コメント"
|
|
139
|
+
},
|
|
140
|
+
created_at: {
|
|
141
|
+
label: "作成日時"
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
_views: {
|
|
145
|
+
recent: {
|
|
146
|
+
label: "最近"
|
|
147
|
+
},
|
|
148
|
+
by_actor: {
|
|
149
|
+
label: "操作者別"
|
|
150
|
+
},
|
|
151
|
+
all_actions: {
|
|
152
|
+
label: "すべて"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-generated by 'os i18n extract' for locale 'zh-CN'.
|
|
5
|
+
* Edit translations in place; re-run extract (with --merge) to fill new gaps.
|
|
6
|
+
* Do not hand-edit the structure — only the leaf string values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TranslationData } from '@objectstack/spec/system';
|
|
10
|
+
|
|
11
|
+
export const zhCNObjects: NonNullable<TranslationData['objects']> = {
|
|
12
|
+
sys_approval_request: {
|
|
13
|
+
label: "审批请求",
|
|
14
|
+
pluralLabel: "审批请求",
|
|
15
|
+
description: "按提交记录跟踪的实时审批实例",
|
|
16
|
+
fields: {
|
|
17
|
+
id: {
|
|
18
|
+
label: "请求 ID"
|
|
19
|
+
},
|
|
20
|
+
organization_id: {
|
|
21
|
+
label: "组织",
|
|
22
|
+
help: "拥有该审批请求的租户(从提交方上下文传播)"
|
|
23
|
+
},
|
|
24
|
+
process_name: {
|
|
25
|
+
label: "来源",
|
|
26
|
+
help: "请求来源 —— 节点驱动的审批为 `flow:<flowName|nodeId>`"
|
|
27
|
+
},
|
|
28
|
+
object_name: {
|
|
29
|
+
label: "对象"
|
|
30
|
+
},
|
|
31
|
+
record_id: {
|
|
32
|
+
label: "记录 ID"
|
|
33
|
+
},
|
|
34
|
+
submitter_id: {
|
|
35
|
+
label: "提交人"
|
|
36
|
+
},
|
|
37
|
+
submitter_comment: {
|
|
38
|
+
label: "提交备注"
|
|
39
|
+
},
|
|
40
|
+
status: {
|
|
41
|
+
label: "状态",
|
|
42
|
+
help: "请求的生命周期状态",
|
|
43
|
+
options: {
|
|
44
|
+
pending: "待处理",
|
|
45
|
+
approved: "已批准",
|
|
46
|
+
rejected: "已拒绝",
|
|
47
|
+
recalled: "已撤回"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
current_step: {
|
|
51
|
+
label: "当前步骤",
|
|
52
|
+
help: "当前等待审批的步骤机器名称"
|
|
53
|
+
},
|
|
54
|
+
current_step_index: {
|
|
55
|
+
label: "当前步骤索引"
|
|
56
|
+
},
|
|
57
|
+
pending_approvers: {
|
|
58
|
+
label: "待审批人",
|
|
59
|
+
help: "可在当前步骤执行操作的用户 ID,逗号分隔"
|
|
60
|
+
},
|
|
61
|
+
payload_json: {
|
|
62
|
+
label: "快照",
|
|
63
|
+
help: "提交时的记录快照"
|
|
64
|
+
},
|
|
65
|
+
flow_run_id: {
|
|
66
|
+
label: "Flow Run",
|
|
67
|
+
help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
|
|
68
|
+
},
|
|
69
|
+
flow_node_id: {
|
|
70
|
+
label: "Flow Node",
|
|
71
|
+
help: "Approval node id within the flow that opened this request (ADR-0019)."
|
|
72
|
+
},
|
|
73
|
+
node_config_json: {
|
|
74
|
+
label: "Node Config",
|
|
75
|
+
help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
|
|
76
|
+
},
|
|
77
|
+
completed_at: {
|
|
78
|
+
label: "完成时间"
|
|
79
|
+
},
|
|
80
|
+
created_at: {
|
|
81
|
+
label: "创建时间"
|
|
82
|
+
},
|
|
83
|
+
updated_at: {
|
|
84
|
+
label: "更新时间"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
_views: {
|
|
88
|
+
my_pending: {
|
|
89
|
+
label: "我的待办"
|
|
90
|
+
},
|
|
91
|
+
submitted_by_me: {
|
|
92
|
+
label: "我提交的"
|
|
93
|
+
},
|
|
94
|
+
completed: {
|
|
95
|
+
label: "已完成"
|
|
96
|
+
},
|
|
97
|
+
all_requests: {
|
|
98
|
+
label: "全部"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
sys_approval_action: {
|
|
103
|
+
label: "审批动作",
|
|
104
|
+
pluralLabel: "审批动作",
|
|
105
|
+
description: "追加写入的审批操作审计记录",
|
|
106
|
+
fields: {
|
|
107
|
+
id: {
|
|
108
|
+
label: "动作 ID"
|
|
109
|
+
},
|
|
110
|
+
organization_id: {
|
|
111
|
+
label: "组织",
|
|
112
|
+
help: "拥有该动作的租户(与父请求保持一致)"
|
|
113
|
+
},
|
|
114
|
+
request_id: {
|
|
115
|
+
label: "请求"
|
|
116
|
+
},
|
|
117
|
+
step_name: {
|
|
118
|
+
label: "步骤",
|
|
119
|
+
help: "执行该动作时对应步骤的机器名称"
|
|
120
|
+
},
|
|
121
|
+
step_index: {
|
|
122
|
+
label: "步骤索引"
|
|
123
|
+
},
|
|
124
|
+
action: {
|
|
125
|
+
label: "操作",
|
|
126
|
+
options: {
|
|
127
|
+
submit: "提交",
|
|
128
|
+
approve: "批准",
|
|
129
|
+
reject: "拒绝",
|
|
130
|
+
recall: "撤回",
|
|
131
|
+
escalate: "升级"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
actor_id: {
|
|
135
|
+
label: "执行人"
|
|
136
|
+
},
|
|
137
|
+
comment: {
|
|
138
|
+
label: "评论"
|
|
139
|
+
},
|
|
140
|
+
created_at: {
|
|
141
|
+
label: "创建时间"
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
_views: {
|
|
145
|
+
recent: {
|
|
146
|
+
label: "最近"
|
|
147
|
+
},
|
|
148
|
+
by_actor: {
|
|
149
|
+
label: "按执行人"
|
|
150
|
+
},
|
|
151
|
+
all_actions: {
|
|
152
|
+
label: "全部"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
package/src/action-executor.ts
DELETED
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Approval Action Executor — M11.C15.B
|
|
5
|
-
*
|
|
6
|
-
* Pure dispatcher that runs the `ApprovalAction` items declared on
|
|
7
|
-
* `ApprovalProcess.onSubmit / onFinalApprove / onFinalReject / onRecall`
|
|
8
|
-
* and `ApprovalStep.onApprove / onReject`.
|
|
9
|
-
*
|
|
10
|
-
* Supported action types:
|
|
11
|
-
* - `field_update` — write `config.field = config.value` on the
|
|
12
|
-
* business record (under SYSTEM_CTX so the lock hook is bypassed).
|
|
13
|
-
* `config.value` may be a literal or `"$status"` / `"$now"` /
|
|
14
|
-
* `"$actor"` / `"$comment"` token resolved against the runtime
|
|
15
|
-
* context.
|
|
16
|
-
* - `inbox_notify` — insert one `sys_notification` row per target.
|
|
17
|
-
* `config.to` may be `'submitter' | 'pending_approvers'` or an
|
|
18
|
-
* explicit `string[]` of user ids. `config.title` / `config.body`
|
|
19
|
-
* interpolate `{record_id}`, `{object}`, `{status}`, `{step}`,
|
|
20
|
-
* `{actor}`, `{comment}`.
|
|
21
|
-
* - `webhook` — POST `config.body` (JSON) to `config.url`,
|
|
22
|
-
* fire-and-forget (caller awaits with timeout). Headers default
|
|
23
|
-
* to `Content-Type: application/json`. Failures are logged, not
|
|
24
|
-
* thrown, so a flaky receiver can't deadlock the approval flow.
|
|
25
|
-
*
|
|
26
|
-
* Unimplemented (logged + skipped):
|
|
27
|
-
* - `email_alert` — needs SMTP transport, later milestone.
|
|
28
|
-
* - `script` — needs sandboxed runner, later milestone.
|
|
29
|
-
* - `connector_action` — needs connector registry, later milestone.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import type { ApprovalEngine } from './approval-service.js';
|
|
33
|
-
|
|
34
|
-
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
35
|
-
|
|
36
|
-
export interface ActionLogger {
|
|
37
|
-
info?: (msg: string, meta?: any) => void;
|
|
38
|
-
warn?: (msg: string, meta?: any) => void;
|
|
39
|
-
error?: (msg: string, meta?: any) => void;
|
|
40
|
-
debug?: (msg: string, meta?: any) => void;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const noopLogger: Required<ActionLogger> = {
|
|
44
|
-
info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** Possible trigger points; passed to executors for tokenization. */
|
|
48
|
-
export type ApprovalTrigger =
|
|
49
|
-
| 'submit'
|
|
50
|
-
| 'step_approve'
|
|
51
|
-
| 'final_approve'
|
|
52
|
-
| 'step_reject'
|
|
53
|
-
| 'final_reject'
|
|
54
|
-
| 'recall';
|
|
55
|
-
|
|
56
|
-
export interface ExecutionContext {
|
|
57
|
-
/** The trigger that caused these actions to fire. */
|
|
58
|
-
trigger: ApprovalTrigger;
|
|
59
|
-
/** Approval process row (parsed `definition`). */
|
|
60
|
-
process: any;
|
|
61
|
-
/** Approval request row (post-transition). */
|
|
62
|
-
request: any;
|
|
63
|
-
/** Current step config (when applicable). */
|
|
64
|
-
step?: any;
|
|
65
|
-
/** Business record (optional — looked up on demand if needed). */
|
|
66
|
-
record?: any;
|
|
67
|
-
/** Actor whose decision triggered the action; for `submit` this is the submitter. */
|
|
68
|
-
actorId?: string | null;
|
|
69
|
-
/** Comment passed with the decision. */
|
|
70
|
-
comment?: string | null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Default fetch implementation — overridable for tests. */
|
|
74
|
-
export type FetchLike = (
|
|
75
|
-
input: any,
|
|
76
|
-
init?: any,
|
|
77
|
-
) => Promise<{ ok: boolean; status: number; statusText: string }>;
|
|
78
|
-
|
|
79
|
-
export interface ExecuteActionsOptions {
|
|
80
|
-
engine: ApprovalEngine;
|
|
81
|
-
logger?: ActionLogger;
|
|
82
|
-
fetch?: FetchLike;
|
|
83
|
-
/** Maximum webhook duration in ms; default 5000. */
|
|
84
|
-
webhookTimeoutMs?: number;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const DEFAULT_WEBHOOK_TIMEOUT_MS = 5000;
|
|
88
|
-
|
|
89
|
-
/** Public entry point — run an ordered list of actions. */
|
|
90
|
-
export async function executeActions(
|
|
91
|
-
actions: any[] | undefined | null,
|
|
92
|
-
ctx: ExecutionContext,
|
|
93
|
-
opts: ExecuteActionsOptions,
|
|
94
|
-
): Promise<void> {
|
|
95
|
-
if (!Array.isArray(actions) || actions.length === 0) return;
|
|
96
|
-
const log = { ...noopLogger, ...(opts.logger ?? {}) };
|
|
97
|
-
for (const a of actions) {
|
|
98
|
-
try {
|
|
99
|
-
await runOne(a, ctx, opts, log);
|
|
100
|
-
} catch (err: any) {
|
|
101
|
-
// Approval actions must not crash the transition — log + continue.
|
|
102
|
-
log.error?.(`[approvals] action '${a?.type ?? '<unknown>'}' failed: ${err?.message ?? err}`, {
|
|
103
|
-
action: a, trigger: ctx.trigger, request_id: ctx.request?.id,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function runOne(
|
|
110
|
-
action: any,
|
|
111
|
-
ctx: ExecutionContext,
|
|
112
|
-
opts: ExecuteActionsOptions,
|
|
113
|
-
log: Required<ActionLogger>,
|
|
114
|
-
): Promise<void> {
|
|
115
|
-
if (!action || typeof action !== 'object') return;
|
|
116
|
-
switch (action.type) {
|
|
117
|
-
case 'field_update': return runFieldUpdate(action, ctx, opts, log);
|
|
118
|
-
case 'inbox_notify': return runInboxNotify(action, ctx, opts, log);
|
|
119
|
-
case 'webhook': return runWebhook(action, ctx, opts, log);
|
|
120
|
-
case 'email_alert':
|
|
121
|
-
case 'script':
|
|
122
|
-
case 'connector_action':
|
|
123
|
-
log.warn?.(`[approvals] action type '${action.type}' is not implemented yet — skipping`, {
|
|
124
|
-
action_name: action.name, trigger: ctx.trigger,
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
default:
|
|
128
|
-
log.warn?.(`[approvals] unknown action type '${action.type}' — skipping`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── field_update ──────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
async function runFieldUpdate(
|
|
135
|
-
action: any,
|
|
136
|
-
ctx: ExecutionContext,
|
|
137
|
-
opts: ExecuteActionsOptions,
|
|
138
|
-
log: Required<ActionLogger>,
|
|
139
|
-
): Promise<void> {
|
|
140
|
-
const cfg = action.config ?? {};
|
|
141
|
-
const field: string | undefined = cfg.field;
|
|
142
|
-
if (!field) {
|
|
143
|
-
log.warn?.('[approvals] field_update missing config.field');
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
const value = resolveValueToken(cfg.value, ctx);
|
|
147
|
-
const object = ctx.process?.object_name ?? ctx.process?.object;
|
|
148
|
-
const recordId = ctx.request?.record_id;
|
|
149
|
-
if (!object || !recordId) {
|
|
150
|
-
log.warn?.('[approvals] field_update missing object/record context');
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
await opts.engine.update(
|
|
154
|
-
object,
|
|
155
|
-
{ id: recordId, [field]: value },
|
|
156
|
-
{ context: SYSTEM_CTX },
|
|
157
|
-
);
|
|
158
|
-
log.debug?.(`[approvals] field_update ${object}/${recordId} set ${field}`, { value });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Resolve `$status`, `$now`, `$actor`, `$comment` or literal value. */
|
|
162
|
-
function resolveValueToken(raw: unknown, ctx: ExecutionContext): unknown {
|
|
163
|
-
if (typeof raw !== 'string') return raw;
|
|
164
|
-
switch (raw) {
|
|
165
|
-
case '$status': return ctx.request?.status ?? null;
|
|
166
|
-
case '$now': return new Date().toISOString();
|
|
167
|
-
case '$actor': return ctx.actorId ?? null;
|
|
168
|
-
case '$comment': return ctx.comment ?? null;
|
|
169
|
-
case '$step': return ctx.request?.current_step ?? null;
|
|
170
|
-
case '$request_id': return ctx.request?.id ?? null;
|
|
171
|
-
default: return raw;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── inbox_notify ──────────────────────────────────────────────────
|
|
176
|
-
|
|
177
|
-
function interpolate(template: string, ctx: ExecutionContext): string {
|
|
178
|
-
if (typeof template !== 'string') return template as any;
|
|
179
|
-
return template
|
|
180
|
-
.replace(/\{record_id\}/g, String(ctx.request?.record_id ?? ''))
|
|
181
|
-
.replace(/\{object\}/g, String(ctx.process?.object_name ?? ctx.process?.object ?? ''))
|
|
182
|
-
.replace(/\{status\}/g, String(ctx.request?.status ?? ''))
|
|
183
|
-
.replace(/\{step\}/g, String(ctx.request?.current_step ?? ''))
|
|
184
|
-
.replace(/\{actor\}/g, String(ctx.actorId ?? ''))
|
|
185
|
-
.replace(/\{comment\}/g, String(ctx.comment ?? ''))
|
|
186
|
-
.replace(/\{process\}/g, String(ctx.process?.name ?? ''));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function runInboxNotify(
|
|
190
|
-
action: any,
|
|
191
|
-
ctx: ExecutionContext,
|
|
192
|
-
opts: ExecuteActionsOptions,
|
|
193
|
-
log: Required<ActionLogger>,
|
|
194
|
-
): Promise<void> {
|
|
195
|
-
const cfg = action.config ?? {};
|
|
196
|
-
const recipients = resolveRecipients(cfg.to, ctx);
|
|
197
|
-
if (recipients.length === 0) {
|
|
198
|
-
log.debug?.('[approvals] inbox_notify resolved no recipients — skipping');
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const title = interpolate(cfg.title ?? 'Approval update', ctx);
|
|
202
|
-
const body = interpolate(cfg.body ?? '', ctx);
|
|
203
|
-
// sys_notification.type is a select with a fixed enum; 'system' is the
|
|
204
|
-
// safe default. Callers may override via cfg.notificationType but must
|
|
205
|
-
// pick a value the schema accepts.
|
|
206
|
-
const type = String(cfg.notificationType ?? 'system');
|
|
207
|
-
const rawLink = cfg.link
|
|
208
|
-
? interpolate(String(cfg.link), ctx)
|
|
209
|
-
: `/console/system/approvals?requestId=${encodeURIComponent(ctx.request?.id ?? '')}`;
|
|
210
|
-
// sys_notification.url is a URL field — only forward absolute URLs.
|
|
211
|
-
// Relative deep-links (`/system/approvals`) get stripped to satisfy
|
|
212
|
-
// validation; the recipient can still navigate via the source linkage.
|
|
213
|
-
const url = /^https?:\/\//i.test(rawLink) ? rawLink : null;
|
|
214
|
-
const now = new Date().toISOString();
|
|
215
|
-
|
|
216
|
-
for (const recipient of recipients) {
|
|
217
|
-
try {
|
|
218
|
-
await opts.engine.insert(
|
|
219
|
-
'sys_notification',
|
|
220
|
-
{
|
|
221
|
-
id: `notif_${cryptoRandom()}`,
|
|
222
|
-
recipient_id: String(recipient),
|
|
223
|
-
type,
|
|
224
|
-
title,
|
|
225
|
-
body,
|
|
226
|
-
url,
|
|
227
|
-
is_read: false,
|
|
228
|
-
source_object: ctx.process?.object_name ?? ctx.process?.object ?? null,
|
|
229
|
-
source_id: ctx.request?.record_id ?? null,
|
|
230
|
-
created_at: now,
|
|
231
|
-
updated_at: now,
|
|
232
|
-
},
|
|
233
|
-
{ context: SYSTEM_CTX },
|
|
234
|
-
);
|
|
235
|
-
} catch (err: any) {
|
|
236
|
-
// Notification persistence is best-effort.
|
|
237
|
-
log.warn?.(`[approvals] inbox_notify insert failed for ${recipient}: ${err?.message ?? err}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function resolveRecipients(to: unknown, ctx: ExecutionContext): string[] {
|
|
243
|
-
if (Array.isArray(to)) return to.map(String).filter(Boolean);
|
|
244
|
-
if (typeof to === 'string') {
|
|
245
|
-
if (to === 'submitter') return ctx.request?.submitter_id ? [String(ctx.request.submitter_id)] : [];
|
|
246
|
-
if (to === 'pending_approvers') {
|
|
247
|
-
const list = ctx.request?.pending_approvers ?? [];
|
|
248
|
-
if (Array.isArray(list)) return list.map(String).filter(Boolean);
|
|
249
|
-
if (typeof list === 'string') return list.split(',').map(s => s.trim()).filter(Boolean);
|
|
250
|
-
return [];
|
|
251
|
-
}
|
|
252
|
-
// Fall through: literal user id.
|
|
253
|
-
return [to];
|
|
254
|
-
}
|
|
255
|
-
return [];
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function cryptoRandom(): string {
|
|
259
|
-
const g: any = globalThis as any;
|
|
260
|
-
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
261
|
-
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ── webhook ──────────────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
async function runWebhook(
|
|
267
|
-
action: any,
|
|
268
|
-
ctx: ExecutionContext,
|
|
269
|
-
opts: ExecuteActionsOptions,
|
|
270
|
-
log: Required<ActionLogger>,
|
|
271
|
-
): Promise<void> {
|
|
272
|
-
const cfg = action.config ?? {};
|
|
273
|
-
const url: string | undefined = cfg.url;
|
|
274
|
-
if (!url) {
|
|
275
|
-
log.warn?.('[approvals] webhook missing config.url');
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
const fetchImpl: FetchLike = opts.fetch ?? (globalThis as any).fetch;
|
|
279
|
-
if (!fetchImpl) {
|
|
280
|
-
log.warn?.('[approvals] webhook skipped — no fetch implementation available');
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const timeoutMs = opts.webhookTimeoutMs ?? DEFAULT_WEBHOOK_TIMEOUT_MS;
|
|
284
|
-
const headers = { 'Content-Type': 'application/json', ...(cfg.headers ?? {}) };
|
|
285
|
-
const payload = {
|
|
286
|
-
trigger: ctx.trigger,
|
|
287
|
-
request: ctx.request,
|
|
288
|
-
step: ctx.step ? { name: ctx.step.name, index: ctx.request?.current_step_index } : null,
|
|
289
|
-
actor_id: ctx.actorId ?? null,
|
|
290
|
-
comment: ctx.comment ?? null,
|
|
291
|
-
process_name: ctx.process?.name,
|
|
292
|
-
object: ctx.process?.object_name ?? ctx.process?.object,
|
|
293
|
-
...(cfg.body && typeof cfg.body === 'object' ? cfg.body : {}),
|
|
294
|
-
};
|
|
295
|
-
// Manual timeout — works in Node 18+ without AbortController dependency.
|
|
296
|
-
const controller = (globalThis as any).AbortController ? new (globalThis as any).AbortController() : null;
|
|
297
|
-
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
|
298
|
-
try {
|
|
299
|
-
const res = await fetchImpl(url, {
|
|
300
|
-
method: cfg.method ?? 'POST',
|
|
301
|
-
headers,
|
|
302
|
-
body: JSON.stringify(payload),
|
|
303
|
-
signal: controller?.signal,
|
|
304
|
-
});
|
|
305
|
-
if (!res.ok) {
|
|
306
|
-
log.warn?.(`[approvals] webhook ${url} → ${res.status} ${res.statusText}`);
|
|
307
|
-
}
|
|
308
|
-
} catch (err: any) {
|
|
309
|
-
log.warn?.(`[approvals] webhook ${url} failed: ${err?.message ?? err}`);
|
|
310
|
-
} finally {
|
|
311
|
-
clearTimeout(timer);
|
|
312
|
-
}
|
|
313
|
-
}
|