Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,43 @@ internal timer (~5 min). Without a `job` service registered, SLA timers are
**display-only** and no escalation ever fires.
</Callout>

### Out-of-office delegation

An approval routes to a specific person, and people take leave. Declare an
**out-of-office delegation** so an approver's individually-routed slots reroute
to a backup while they're away — the approval never freezes:

```bash
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/data/sys_approval_delegation \
-H "Content-Type: application/json" \
-d '{
"delegator_id": "usr_alice",
"delegate_id": "usr_bob",
"valid_from": "2026-05-26T00:00:00Z",
"valid_until": "2026-05-30T00:00:00Z",
"reason": "Annual leave"
}'
```

When a request opens, approvers that resolve to a **specific individual**
(`type: user`, `type: field`, `type: manager`) are checked against active
delegations and rerouted to the delegate. The window is half-open
`[valid_from, valid_until)` (UTC), evaluated **at resolution time** — no
background job — so a lapsed delegation simply stops applying. Chains
(A → B → C) are followed and cycle-safe. The delegate acts under **their own
identity**; the reroute is recorded as an `ooo_substitute` audit action and both
the delegate and the skipped approver are notified.

<Callout type="info">
**Group-routed approvers are not rerouted.** `type: position` / `team` /
`department` / `org_membership_level` keep their whole membership, so there is
nothing to skip; a position-holder's leave is handled by position delegation
(ADR-0091), not here. Out-of-office delegation is self-service — create your own
row via the data API (Setup → Approvals → *Delegations (OOO)*). For a single
in-flight request, `reassign` hands one slot to another user directly.
</Callout>

### Error codes

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

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

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

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

**Partially fixed(社区版部分,#1322):** 委派 / 不在岗(OOO)的"审批不冻结"连续性底线已落在开源核心 `plugin-approvals`:

- **不在岗自动跳过(M1)**:新增自助对象 `sys_approval_delegation`(`{delegator_id, delegate_id, valid_from, valid_until, reason}`)。`ApprovalService.expandApprovers` 解析到具体个人(`type: user` / `field` / `manager`)时,命中生效中的委派即按链路改派给候补人(复用 `isGrantActive` 的半开时间窗,解析时判定,无后台任务;带环/自引用保护)。职位路径的休假仍由 ADR-0091 职务代理覆盖。
- **临时委派(M2)**:沿用既有 `ApprovalService.reassign`(`POST /approvals/requests/:id/reassign` + 客户端 `approvals.reassign`),单任务转交并留审计。
- **审计 + 通知(M4)**:改派写 `sys_approval_action`(`action: 'ooo_substitute'`,记 `A → B — reason`)并通知候补人与被跳过人。

**仍属企业版(objectstack-ai/cloud#855):** 代办访问 / act-as 收件箱、委派治理 + 职责分离(SoD)、组织级代管他人控制台、合规报表 / 认证复核。多步 / 并行审批见 #5(P1),与本条分开推进。

### 21. 外部审计员 / 只读访客门户

合规类应用必备:外部审计员需要只读访问指定证据集合,不能给完整账号。建议引入"受限链接 / 临时访客角色"。
Expand Down
19 changes: 19 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2515,6 +2515,25 @@ export class ObjectStackClient {
return this.unwrapResponse<ApprovalDecisionResult>(res);
},

/**
* Hand a pending-approver slot to someone else (#1322 M2 — self-service
* task delegation). `to` is the new approver; `from` defaults to the
* caller. Records a `reassign` audit action and notifies the new approver.
* (Standing out-of-office delegation is CRUD on `sys_approval_delegation`
* via the generic data API, so it needs no dedicated helper here.)
*/
reassign: async (
requestId: string,
input: { to: string; from?: string; actorId?: string; comment?: string },
): Promise<{ request: ApprovalRequestRow }> => {
const route = this.getRoute('approvals');
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/reassign`, {
method: 'POST',
body: JSON.stringify({ to: input.to, from: input.from, actorId: input.actorId, comment: input.comment })
});
return this.unwrapResponse<{ request: ApprovalRequestRow }>(res);
},

/**
* Audit trail (the immutable action log) for an approval request.
*/
Expand Down
147 changes: 147 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,3 +1076,150 @@ describe('record-lock hook (node era)', () => {
expect(engine._hooks['beforeUpdate']).toHaveLength(0);
});
});

// ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
//
// When a resolved individual approver has declared an active OOO delegation,
// the slot is rerouted to the delegate at resolution time (never a background
// job), audited as `ooo_substitute`, and both parties are notified. Group /
// graph approvers (position/team/department/tier) are left untouched.
describe('ApprovalService — out-of-office delegation (#1322)', () => {
// Mid-window instant for the issue's own example (leave 5/26–5/30).
const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
let engine: ReturnType<typeof makeFakeEngine>;
let svc: ApprovalService;
let emitted: any[];

function seedDelegation(rows: Array<Record<string, any>>) {
engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
id: `del${i}`,
organization_id: 't1',
valid_from: '2026-05-26T00:00:00Z',
valid_until: '2026-05-30T00:00:00Z',
reason: 'Annual leave',
...r,
}));
}

beforeEach(() => {
engine = makeFakeEngine();
emitted = [];
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(OOO_NOW) } });
svc.attachMessaging({ emit: async (m: any) => { emitted.push(m); } });
});

it('type:user — reroutes an out-of-office approver to the delegate', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['bob']);
});

it('records an ooo_substitute audit action with "A → B — reason"', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
await svc.openNodeRequest(openInput(['alice']), CTX);
const sub = engine._tables['sys_approval_action'].find((a: any) => a.action === 'ooo_substitute');
expect(sub).toBeTruthy();
expect(sub.comment).toBe('alice → bob — Annual leave');
expect(sub.actor_id).toBeNull(); // system-recorded reroute, no human actor
});

it('notifies both the delegate and the skipped approver', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
await svc.openNodeRequest(openInput(['alice']), CTX);
const to = emitted.find(e => e.topic === 'approval.ooo_substituted');
const from = emitted.find(e => e.topic === 'approval.ooo_skipped');
expect(to?.audience).toEqual(['bob']);
expect(from?.audience).toEqual(['alice']);
});

it('does not reroute before valid_from (window not yet open)', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_from: '2026-05-28T00:00:00Z' }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['alice']);
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
});

it('does not reroute at/after valid_until (half-open window)', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', valid_until: '2026-05-27T10:00:00Z' }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['alice']);
});

it('type:field — reroutes the user stored in the record field', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
const input = {
...openInput([]),
record: { id: 'opp1', reviewer: 'alice' },
config: { approvers: [{ type: 'field', value: 'reviewer' }], behavior: 'first_response', lockRecord: true },
};
const req = await svc.openNodeRequest(input as any, CTX);
expect(req.pending_approvers).toEqual(['bob']);
});

it('type:manager — reroutes when the resolved manager is out of office', async () => {
engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
const input = {
...openInput([]),
record: { id: 'opp1', owner_id: 'carol' },
config: { approvers: [{ type: 'manager', value: 'owner_id' }], behavior: 'first_response', lockRecord: true },
};
const req = await svc.openNodeRequest(input as any, CTX);
expect(req.pending_approvers).toEqual(['bob']);
});

it('follows a delegation chain A → B → C', async () => {
seedDelegation([
{ delegator_id: 'alice', delegate_id: 'bob' },
{ delegator_id: 'bob', delegate_id: 'carol' },
]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['carol']);
expect(engine._tables['sys_approval_action'].filter((a: any) => a.action === 'ooo_substitute')).toHaveLength(2);
});

it('stops on a cycle A → B → A without looping', async () => {
seedDelegation([
{ delegator_id: 'alice', delegate_id: 'bob' },
{ delegator_id: 'bob', delegate_id: 'alice' },
]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['bob']);
});

it('ignores a self-delegation (A → A)', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'alice' }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['alice']);
expect(engine._tables['sys_approval_action'].some((a: any) => a.action === 'ooo_substitute')).toBe(false);
});

it('leaves approvers unchanged when there is no active delegation', async () => {
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['alice']);
});

it('does not OOO-substitute group-routed (position) approvers', async () => {
engine._tables['sys_user_position'] = [{ id: 'up1', user_id: 'alice', position: 'sales_manager', organization_id: 't1' }];
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
const input = {
...openInput([]),
config: { approvers: [{ type: 'position', value: 'sales_manager' }], behavior: 'first_response', lockRecord: true },
};
const req = await svc.openNodeRequest(input as any, CTX);
// Position-routed leave is ADR-0091's job, not this path: the holder stays.
expect(req.pending_approvers).toEqual(['alice']);
});

it('respects tenant scope: a rule scoped to another org does not apply', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: 't2' }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['alice']);
});

it('applies a cross-tenant (null org) rule regardless of request tenant', async () => {
seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob', organization_id: null }]);
const req = await svc.openNodeRequest(openInput(['alice']), CTX);
expect(req.pending_approvers).toEqual(['bob']);
});
});
Loading