Skip to content

Commit 8b94a45

Browse files
authored
feat(approvals): out-of-office auto-skip + self-service delegation (#3235)
Community-core OOO continuity for approvals (#1322): sys_approval_delegation object, OOO auto-skip in expandApprovers for individually-routed approvers (user/field/manager) reusing isGrantActive, ooo_substitute audit + notifications, client reassign helper, and docs. Enterprise governance tracked in objectstack-ai/cloud#855.
1 parent 78b74b4 commit 8b94a45

11 files changed

Lines changed: 520 additions & 12 deletions

File tree

content/docs/automation/approvals.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,43 @@ internal timer (~5 min). Without a `job` service registered, SLA timers are
215215
**display-only** and no escalation ever fires.
216216
</Callout>
217217

218+
### Out-of-office delegation
219+
220+
An approval routes to a specific person, and people take leave. Declare an
221+
**out-of-office delegation** so an approver's individually-routed slots reroute
222+
to a backup while they're away — the approval never freezes:
223+
224+
```bash
225+
curl -b cookies.txt -X POST \
226+
https://your-app.example.com/api/v1/data/sys_approval_delegation \
227+
-H "Content-Type: application/json" \
228+
-d '{
229+
"delegator_id": "usr_alice",
230+
"delegate_id": "usr_bob",
231+
"valid_from": "2026-05-26T00:00:00Z",
232+
"valid_until": "2026-05-30T00:00:00Z",
233+
"reason": "Annual leave"
234+
}'
235+
```
236+
237+
When a request opens, approvers that resolve to a **specific individual**
238+
(`type: user`, `type: field`, `type: manager`) are checked against active
239+
delegations and rerouted to the delegate. The window is half-open
240+
`[valid_from, valid_until)` (UTC), evaluated **at resolution time** — no
241+
background job — so a lapsed delegation simply stops applying. Chains
242+
(A → B → C) are followed and cycle-safe. The delegate acts under **their own
243+
identity**; the reroute is recorded as an `ooo_substitute` audit action and both
244+
the delegate and the skipped approver are notified.
245+
246+
<Callout type="info">
247+
**Group-routed approvers are not rerouted.** `type: position` / `team` /
248+
`department` / `org_membership_level` keep their whole membership, so there is
249+
nothing to skip; a position-holder's leave is handled by position delegation
250+
(ADR-0091), not here. Out-of-office delegation is self-service — create your own
251+
row via the data API (Setup → Approvals → *Delegations (OOO)*). For a single
252+
in-flight request, `reassign` hands one slot to another user directly.
253+
</Callout>
254+
218255
### Error codes
219256

220257
| Code | HTTP | Meaning |

docs/PLATFORM_GAPS_FROM_TEMPLATES.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
| 17 | 🟡 P2 | 批量 | 批量操作 UI 入口存在但未端到端验证 | 大数据集运营卡顿 |
3030
| 18 | 🟡 P2 | i18n | zh-CN ↔ English 切换偶发错乱 | 国内客户体验差 |
3131
| 19 | 🟢 P3 | 企业 | SSO / SAML / Okta 未在模板中验证 | 中大客户准入门槛 |
32-
| 20 | 🟢 P3 | 企业 | 多步审批、委派、不在岗代理缺失 | 同上 |
32+
| 20 | 🟢 P3 | 企业 | ~~多步审批、委派、不在岗代理缺失~~ 委派 / OOO 社区版部分已修复;代办访问·治理归企业版 | 同上 |
3333
| 21 | 🟢 P3 | 企业 | 外部审计员 / 只读访客门户缺失 | 合规类应用刚需 |
3434
| 22 | 🟢 P3 | 移动 | 移动端响应式未验证 | 审批走手机是基本盘 |
3535
| 23 | 🟢 P3 | 文档 | ~~`services.data.{get,update}` 等 API 未文档化~~ | Fixed in Unreleased: 新增 `content/docs/kernel/runtime-services/*` |
@@ -275,10 +275,18 @@
275275

276276
平台是否支持企业身份接入?模板里只用了本地账号。建议补一个 reference integration + 文档。
277277

278-
### 20. 多步审批、委派、不在岗代理
278+
### 20. ~~多步审批、委派、不在岗代理~~
279279

280280
#5 的延伸——这是企业版的"准入条件"。
281281

282+
**Partially fixed(社区版部分,#1322):** 委派 / 不在岗(OOO)的"审批不冻结"连续性底线已落在开源核心 `plugin-approvals`
283+
284+
- **不在岗自动跳过(M1)**:新增自助对象 `sys_approval_delegation``{delegator_id, delegate_id, valid_from, valid_until, reason}`)。`ApprovalService.expandApprovers` 解析到具体个人(`type: user` / `field` / `manager`)时,命中生效中的委派即按链路改派给候补人(复用 `isGrantActive` 的半开时间窗,解析时判定,无后台任务;带环/自引用保护)。职位路径的休假仍由 ADR-0091 职务代理覆盖。
285+
- **临时委派(M2)**:沿用既有 `ApprovalService.reassign``POST /approvals/requests/:id/reassign` + 客户端 `approvals.reassign`),单任务转交并留审计。
286+
- **审计 + 通知(M4)**:改派写 `sys_approval_action``action: 'ooo_substitute'`,记 `A → B — reason`)并通知候补人与被跳过人。
287+
288+
**仍属企业版(objectstack-ai/cloud#855):** 代办访问 / act-as 收件箱、委派治理 + 职责分离(SoD)、组织级代管他人控制台、合规报表 / 认证复核。多步 / 并行审批见 #5(P1),与本条分开推进。
289+
282290
### 21. 外部审计员 / 只读访客门户
283291

284292
合规类应用必备:外部审计员需要只读访问指定证据集合,不能给完整账号。建议引入"受限链接 / 临时访客角色"。

packages/client/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,6 +2515,25 @@ export class ObjectStackClient {
25152515
return this.unwrapResponse<ApprovalDecisionResult>(res);
25162516
},
25172517

2518+
/**
2519+
* Hand a pending-approver slot to someone else (#1322 M2 — self-service
2520+
* task delegation). `to` is the new approver; `from` defaults to the
2521+
* caller. Records a `reassign` audit action and notifies the new approver.
2522+
* (Standing out-of-office delegation is CRUD on `sys_approval_delegation`
2523+
* via the generic data API, so it needs no dedicated helper here.)
2524+
*/
2525+
reassign: async (
2526+
requestId: string,
2527+
input: { to: string; from?: string; actorId?: string; comment?: string },
2528+
): Promise<{ request: ApprovalRequestRow }> => {
2529+
const route = this.getRoute('approvals');
2530+
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/reassign`, {
2531+
method: 'POST',
2532+
body: JSON.stringify({ to: input.to, from: input.from, actorId: input.actorId, comment: input.comment })
2533+
});
2534+
return this.unwrapResponse<{ request: ApprovalRequestRow }>(res);
2535+
},
2536+
25182537
/**
25192538
* Audit trail (the immutable action log) for an approval request.
25202539
*/

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,3 +1076,150 @@ describe('record-lock hook (node era)', () => {
10761076
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
10771077
});
10781078
});
1079+
1080+
// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
1081+
//
1082+
// When a resolved individual approver has declared an active OOO delegation,
1083+
// the slot is rerouted to the delegate at resolution time (never a background
1084+
// job), audited as `ooo_substitute`, and both parties are notified. Group /
1085+
// graph approvers (position/team/department/tier) are left untouched.
1086+
describe('ApprovalService — out-of-office delegation (#1322)', () => {
1087+
// Mid-window instant for the issue's own example (leave 5/26–5/30).
1088+
const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
1089+
let engine: ReturnType<typeof makeFakeEngine>;
1090+
let svc: ApprovalService;
1091+
let emitted: any[];
1092+
1093+
function seedDelegation(rows: Array<Record<string, any>>) {
1094+
engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
1095+
id: `del${i}`,
1096+
organization_id: 't1',
1097+
valid_from: '2026-05-26T00:00:00Z',
1098+
valid_until: '2026-05-30T00:00:00Z',
1099+
reason: 'Annual leave',
1100+
...r,
1101+
}));
1102+
}
1103+
1104+
beforeEach(() => {
1105+
engine = makeFakeEngine();
1106+
emitted = [];
1107+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
1108+
svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
1109+
});
1110+
1111+
it('type:user — reroutes an out-of-office approver to the delegate', async () => {
1112+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1113+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1114+
expect(req.pending_approvers).toEqual(['bob']);
1115+
});
1116+
1117+
it('records an ooo_substitute audit action with "A → B — reason"', async () => {
1118+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1119+
await svc.openNodeRequest(openInput(['alice']), CTX);
1120+
const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
1121+
expect(sub).toBeTruthy();
1122+
expect(sub.comment).toBe('alice → bob — Annual leave');
1123+
expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
1124+
});
1125+
1126+
it('notifies both the delegate and the skipped approver', async () => {
1127+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1128+
await svc.openNodeRequest(openInput(['alice']), CTX);
1129+
const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
1130+
const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
1131+
expect(to?.audience).toEqual(['bob']);
1132+
expect(from?.audience).toEqual(['alice']);
1133+
});
1134+
1135+
it('does not reroute before valid_from (window not yet open)', async () => {
1136+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
1137+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1138+
expect(req.pending_approvers).toEqual(['alice']);
1139+
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
1140+
});
1141+
1142+
it('does not reroute at/after valid_until (half-open window)', async () => {
1143+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
1144+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1145+
expect(req.pending_approvers).toEqual(['alice']);
1146+
});
1147+
1148+
it('type:field — reroutes the user stored in the record field', async () => {
1149+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1150+
const input = {
1151+
...openInput([]),
1152+
record: { id: 'opp1', reviewer: 'alice' },
1153+
config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
1154+
};
1155+
const req = await svc.openNodeRequest(input as any, CTX);
1156+
expect(req.pending_approvers).toEqual(['bob']);
1157+
});
1158+
1159+
it('type:manager — reroutes when the resolved manager is out of office', async () => {
1160+
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
1161+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1162+
const input = {
1163+
...openInput([]),
1164+
record: { id: 'opp1', owner_id: 'carol' },
1165+
config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
1166+
};
1167+
const req = await svc.openNodeRequest(input as any, CTX);
1168+
expect(req.pending_approvers).toEqual(['bob']);
1169+
});
1170+
1171+
it('follows a delegation chain A → B → C', async () => {
1172+
seedDelegation([
1173+
{ delegator_id: 'alice', delegate_id: 'bob' },
1174+
{ delegator_id: 'bob', delegate_id: 'carol' },
1175+
]);
1176+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1177+
expect(req.pending_approvers).toEqual(['carol']);
1178+
expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
1179+
});
1180+
1181+
it('stops on a cycle A → B → A without looping', async () => {
1182+
seedDelegation([
1183+
{ delegator_id: 'alice', delegate_id: 'bob' },
1184+
{ delegator_id: 'bob', delegate_id: 'alice' },
1185+
]);
1186+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1187+
expect(req.pending_approvers).toEqual(['bob']);
1188+
});
1189+
1190+
it('ignores a self-delegation (A → A)', async () => {
1191+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
1192+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1193+
expect(req.pending_approvers).toEqual(['alice']);
1194+
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
1195+
});
1196+
1197+
it('leaves approvers unchanged when there is no active delegation', async () => {
1198+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1199+
expect(req.pending_approvers).toEqual(['alice']);
1200+
});
1201+
1202+
it('does not OOO-substitute group-routed (position) approvers', async () => {
1203+
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
1204+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1205+
const input = {
1206+
...openInput([]),
1207+
config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
1208+
};
1209+
const req = await svc.openNodeRequest(input as any, CTX);
1210+
// Position-routed leave is ADR-0091's job, not this path: the holder stays.
1211+
expect(req.pending_approvers).toEqual(['alice']);
1212+
});
1213+
1214+
it('respects tenant scope: a rule scoped to another org does not apply', async () => {
1215+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
1216+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1217+
expect(req.pending_approvers).toEqual(['alice']);
1218+
});
1219+
1220+
it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
1221+
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
1222+
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
1223+
expect(req.pending_approvers).toEqual(['bob']);
1224+
});
1225+
});

0 commit comments

Comments
 (0)