diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 121697c5d9..abd31296f5 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -215,6 +215,43 @@ internal timer (~5 min). Without a `job` service registered, SLA timers are **display-only** and no escalation ever fires. +### 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. + + +**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. + + ### Error codes | Code | HTTP | Meaning | diff --git a/docs/PLATFORM_GAPS_FROM_TEMPLATES.md b/docs/PLATFORM_GAPS_FROM_TEMPLATES.md index 5821e681e2..01cad6cefc 100644 --- a/docs/PLATFORM_GAPS_FROM_TEMPLATES.md +++ b/docs/PLATFORM_GAPS_FROM_TEMPLATES.md @@ -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/*` | @@ -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. 外部审计员 / 只读访客门户 合规类应用必备:外部审计员需要只读访问指定证据集合,不能给完整账号。建议引入"受限链接 / 临时访客角色"。 diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ddeaaa909c..ad4b1eb63d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2515,6 +2515,25 @@ export class ObjectStackClient { return this.unwrapResponse(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. */ diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 7e78aa68ab..5f9df404a8 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -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; + let svc: ApprovalService; + let emitted: any[]; + + function seedDelegation(rows: Array>) { + 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']); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index dcfce07cae..d7cc02e1f4 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -21,6 +21,7 @@ import type { ApprovalStatus, SharingExecutionContext, } from '@objectstack/spec/contracts'; +import { isGrantActive } from '@objectstack/core'; /** * Node-era approval runtime (ADR-0019). @@ -100,6 +101,23 @@ export type ActionTokenOutcome = const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +/** + * Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out + * → C, … Bounds the walk so a mis-configured chain can't loop or resolve + * unboundedly; a cycle or self-reference also stops it early. + */ +const OOO_MAX_CHAIN = 8; + +/** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */ +interface OooSubstitution { + /** The approver who was skipped (out of office). */ + from: string; + /** The delegate the slot was routed to. */ + to: string; + /** The delegator's declared reason, if any. */ + reason: string | null; +} + function uid(prefix: string): string { const g: any = globalThis as any; if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`; @@ -296,9 +314,24 @@ export class ApprovalService implements IApprovalService { * * `role` is accepted as the deprecated spelling of `org_membership_level` * (ADR-0090 D3) for one window: it resolves identically and logs a warning. + * + * **Out-of-office (#1322 M1):** individually-routed approvers — the ones that + * resolve to a specific person (`user` / `field` / `manager`) — are passed + * through {@link ApprovalService.applyOooDelegation}, which reroutes them onto + * an active delegate when the resolved user has declared OOO. Group/graph + * approvers (`team` / `department` / `position` / `org_membership_level`) are + * left untouched: a group still has its other members, and position-routed + * leave is already covered by ADR-0091 job delegation. Pass an `opts.now` / + * `opts.substitutions` collector to record the hops for audit + notification. */ - private async expandApprovers(step: any, record?: any, organizationId?: string | null): Promise { + private async expandApprovers( + step: any, + record?: any, + organizationId?: string | null, + opts?: { now?: number; substitutions?: OooSubstitution[] }, + ): Promise { if (!step || !Array.isArray(step.approvers)) return []; + const now = opts?.now ?? this.clock.now().getTime(); const out: string[] = []; for (const a of step.approvers) { if (!a) continue; @@ -314,8 +347,14 @@ export class ApprovalService implements IApprovalService { { deprecated: a.type, canonical: type }, ); } - if (type === 'user') { out.push(String(a.value)); continue; } - if (type === 'field' && record) { out.push(String((record as any)[a.value] ?? '')); continue; } + if (type === 'user') { + for (const u of await this.applyOooDelegation(String(a.value), now, organizationId, opts?.substitutions)) out.push(u); + continue; + } + if (type === 'field' && record) { + for (const u of await this.applyOooDelegation(String((record as any)[a.value] ?? ''), now, organizationId, opts?.substitutions)) out.push(u); + continue; + } try { if (type === 'team') { const users = await this.expandTeamUsers(String(a.value)); @@ -333,7 +372,10 @@ export class ApprovalService implements IApprovalService { const subject = (record as any)[a.value] ?? (record as any).owner_id; if (subject) { const mgr = await this.lookupManager(String(subject)); - if (mgr) { out.push(mgr); continue; } + if (mgr) { + for (const u of await this.applyOooDelegation(mgr, now, organizationId, opts?.substitutions)) out.push(u); + continue; + } } } } catch { /* fall through */ } @@ -458,6 +500,74 @@ export class ApprovalService implements IApprovalService { } catch { return null; } } + /** + * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver + * id, follow any active `sys_approval_delegation` chain and return the id the + * slot should actually go to — the delegate acts under their own identity, so + * no impersonation is involved. Returns `[userId]` unchanged when there is no + * active delegation. Each hop is appended to `collector` (when supplied) so + * the caller can audit + notify (M4). + * + * The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and + * stops on a self-reference or a cycle, so a mis-declared loop degrades to the + * last reachable delegate rather than hanging. + */ + private async applyOooDelegation( + userId: string, + now: number, + organizationId?: string | null, + collector?: OooSubstitution[], + ): Promise { + const start = String(userId ?? '').trim(); + if (!start) return []; + let current = start; + const visited = new Set([current]); + for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) { + const del = await this.lookupActiveDelegation(current, now, organizationId); + if (!del) break; + const to = String(del.delegate_id ?? '').trim(); + if (!to || to === current || visited.has(to)) break; // no-op / self / cycle + collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null }); + visited.add(to); + current = to; + } + return [current]; + } + + /** + * The active OOO delegation for a delegator at `now`, or null. Validity is the + * shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at + * resolution time — never by a background job. When several rows are active, + * the one expiring soonest wins (the most specific coverage window). + */ + private async lookupActiveDelegation( + delegatorId: string, + now: number, + organizationId?: string | null, + ): Promise { + if (!delegatorId) return null; + let rows: any[] = []; + try { + rows = await this.engine.find('sys_approval_delegation', { + filter: { delegator_id: delegatorId }, + fields: ['id', 'delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason', 'organization_id'], + limit: 50, + context: SYSTEM_CTX, + } as any); + } catch { return null; } // table absent on minimal stacks — no OOO, resolve as-is + const active = (rows ?? []).filter((r: any) => + isGrantActive(r, now) + // A null-org rule applies across tenants; a scoped rule only within its tenant. + && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId))); + if (!active.length) return null; + active.sort((a: any, b: any) => { + const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY; + const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY; + return au - bu; + }); + return active[0]; + } + /** Mirror a request status onto a business-object field, if configured. */ private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise { try { @@ -512,9 +622,15 @@ export class ApprovalService implements IApprovalService { } const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null; - const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg); + const nowDate = this.clock.now(); + // OOO auto-skip (#1322 M1): reroute individually-routed approvers who are + // out of office. Collected hops drive the audit + notification below (M4). + const substitutions: OooSubstitution[] = []; + const approvers = await this.expandApprovers( + { approvers: input.config.approvers }, input.record, ctxOrg, { now: nowDate.getTime(), substitutions }, + ); - const now = this.clock.now().toISOString(); + const now = nowDate.toISOString(); const id = uid('areq'); const processName = `flow:${input.flowName ?? input.nodeId}`; // Display labels ride the config snapshot (no schema migration needed); @@ -558,6 +674,42 @@ export class ApprovalService implements IApprovalService { actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now, }, { context: SYSTEM_CTX }); + // OOO substitution audit + notification (#1322 M4). Each hop that rerouted + // an approver away from an out-of-office user is recorded on the request's + // audit trail (a system action, no human actor) and notified to both the + // delegate — who now owns the slot — and the skipped approver. + for (const sub of substitutions) { + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: id, organization_id: ctxOrg, + step_name: input.nodeId, step_index: 0, action: 'ooo_substitute', + actor_id: null, + comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`, + created_at: now, + }, { context: SYSTEM_CTX }); + await this.notify({ + topic: 'approval.ooo_substituted', + audience: [sub.to], + source: { object: 'sys_approval_request', id }, + dedupKey: `approval-ooo-${id}-${sub.to}`, + payload: { + title: 'Approval routed to you (out-of-office cover)', + message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`, + actionUrl: '/system/approvals', + }, + }); + await this.notify({ + topic: 'approval.ooo_skipped', + audience: [sub.from], + source: { object: 'sys_approval_request', id }, + dedupKey: `approval-ooo-skip-${id}-${sub.from}`, + payload: { + title: 'Approval routed to your delegate', + message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`, + actionUrl: '/system/approvals', + }, + }); + } + // Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate // hook keyed on the now-pending request; no extra write needed here. if (input.config.approvalStatusField) { diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 89c7268971..8d1ade49dd 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -5,6 +5,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalToken } from './sys-approval-token.object.js'; +import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { renderConfirmPage, renderResultPage } from './action-link-pages.js'; import { ApprovalService, @@ -70,7 +71,7 @@ export class ApprovalsServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken], + objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation], // ADR-0029 D7 — contribute the Approvals entries into the Setup app's // `group_approvals` slot. This plugin owns these objects (K2.b), so it // ships their menu too; when the plugin isn't installed the slot is empty. @@ -82,6 +83,7 @@ export class ApprovalsServicePlugin implements Plugin { items: [ { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, + { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' }, ], }, ], diff --git a/packages/plugins/plugin-approvals/src/index.ts b/packages/plugins/plugin-approvals/src/index.ts index dfbcbb7ebe..e84a1c90bb 100644 --- a/packages/plugins/plugin-approvals/src/index.ts +++ b/packages/plugins/plugin-approvals/src/index.ts @@ -13,6 +13,7 @@ export { SysApprovalRequest } from './sys-approval-request.object.js'; export { SysApprovalAction } from './sys-approval-action.object.js'; export { SysApprovalApprover } from './sys-approval-approver.object.js'; +export { SysApprovalDelegation } from './sys-approval-delegation.object.js'; export { ApprovalService, type ApprovalEngine, diff --git a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts index ce63c33303..1608e14b92 100644 --- a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts +++ b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts @@ -28,6 +28,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([ 'sys_approval_action', 'sys_approval_approver', + 'sys_approval_delegation', 'sys_approval_request', 'sys_approval_token', ]); @@ -38,6 +39,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' }); expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([ 'sys_approval_action', + 'sys_approval_delegation', 'sys_approval_request', ]); // Each entry is gated so the slot stays empty when the plugin is absent. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index dbc711de3d..6596917563 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -92,8 +92,9 @@ export const SysApprovalAction = ObjectSchema.create({ // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign / // remind / request_info / comment are thread interactions — they never // move the flow. revise / resubmit (ADR-0044) DO move it: send back for - // revision and the later resubmission. - ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit'], + // revision and the later resubmission. ooo_substitute (#1322 M1) is a + // system-recorded reroute of an out-of-office approver — no flow movement. + ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'], { label: 'Action', required: true, diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts new file mode 100644 index 0000000000..64670309e3 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1). + * + * A standing, self-declared rule: "while I (the delegator) am out between + * `valid_from` and `valid_until`, route the approver slots that would resolve + * to me onto my delegate instead." The approval service consults active rows + * in `ApprovalService.expandApprovers` when resolving an approval node's + * INDIVIDUALLY-routed approvers (`type: user` / `field` / `manager`) — the + * delegate becomes a real pending approver and acts under their own identity, + * so nothing is impersonated and the audit trail stays honest. + * + * Modelled as its own object (not a scalar on the better-auth-locked + * `sys_user`), mirroring the `sys_user_position` delegation precedent + * (ADR-0091): the validity window is enforced at RESOLUTION time via the + * shared `isGrantActive` predicate — never by a background job (ADR-0049). + * The window is half-open `[valid_from, valid_until)` in UTC. + * + * Scope note: this is the community-core OOO auto-skip only. Long-term proxy + * "act-as" access (viewing/acting on another user's full queue under their + * authority), delegation governance / segregation-of-duties, and org-wide + * administration of others' delegations are enterprise concerns tracked + * separately (objectstack-ai/cloud#855), not here. + * + * @namespace sys + */ +export const SysApprovalDelegation = ObjectSchema.create({ + name: 'sys_approval_delegation', + label: 'Approval Delegation', + pluralLabel: 'Approval Delegations', + icon: 'user-clock', + isSystem: true, + managedBy: 'system', + description: + 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).', + titleFormat: '{delegator_id} → {delegate_id}', + highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'], + + listViews: { + active: { + type: 'grid', + name: 'active', + label: 'Active', + data: { provider: 'object', object: 'sys_approval_delegation' }, + columns: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason'], + sort: [{ field: 'valid_until', order: 'asc' }], + pagination: { pageSize: 50 }, + emptyState: { + title: 'No delegations', + message: 'Declare an out-of-office delegation so approvals route to a backup while you are away.', + }, + }, + }, + + fields: { + id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }), + + delegator_id: Field.lookup('sys_user', { + label: 'Delegator', + required: true, + group: 'Delegation', + description: 'The user going out of office; their individually-routed approver slots are rerouted while active.', + }), + + delegate_id: Field.lookup('sys_user', { + label: 'Delegate', + required: true, + group: 'Delegation', + description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.', + }), + + valid_from: Field.datetime({ + label: 'Valid From', + required: false, + group: 'Delegation', + description: + 'Rule is inactive before this instant. Null = active immediately. ' + + 'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.', + }), + + valid_until: Field.datetime({ + label: 'Valid Until', + required: false, + group: 'Delegation', + description: + 'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.', + }), + + reason: Field.text({ + label: 'Reason', + required: false, + maxLength: 500, + group: 'Delegation', + description: 'Why the delegation exists (e.g. "Annual leave 5/26–5/30"). Recorded on the substitution audit row.', + }), + + organization_id: Field.lookup('sys_organization', { + label: 'Organization', + required: false, + group: 'System', + description: 'Tenant that owns this rule; null = applies across tenants for this delegator.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + }, + + indexes: [ + // Resolution-time lookup: "active delegations for this delegator". + { fields: ['delegator_id', 'organization_id'] }, + { fields: ['delegate_id'] }, + { fields: ['valid_until'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: false, + }, +}); diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index fe4d0a7ee2..92b5c2290a 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -117,7 +117,9 @@ export type ApprovalActionKind = /** ADR-0044: an approver sent the request back for revision (request finalizes `returned`). */ | 'revise' /** ADR-0044: the submitter resubmitted after rework (the next round's request opens with its own `submit`). */ - | 'resubmit'; + | 'resubmit' + /** #1322 M1: an out-of-office approver's slot was auto-rerouted to their delegate at resolution time. */ + | 'ooo_substitute'; /** Audit row. */ export interface ApprovalActionRow {