Skip to content

Commit d6c3f06

Browse files
authored
feat(approvals): server-computed decision progress + notification deep links (#3274)
objectui#2678 P1.5 backend: getRequest attaches decision_progress (unanimous/quorum got/need; per_group per-group breakdown from the open-time snapshot) for pending multi-approver requests; notify() centrally rewrites inbox actionUrls to /system/approvals?request=<id> deep links; rowFromAction now maps decision attachments through the listActions contract (browser-caught #3268 gap). 137 tests green.
1 parent 32899e6 commit d6c3f06

3 files changed

Lines changed: 152 additions & 1 deletion

File tree

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,3 +1390,74 @@ describe('ApprovalService — quorum & per_group (#3266)', () => {
13901390
expect(act.attachments).toEqual(['file_1', 'file_2']);
13911391
});
13921392
});
1393+
1394+
// ── Decision progress + notification deep links (#2678 P1.5) ──────────
1395+
describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () => {
1396+
let engine: ReturnType<typeof makeFakeEngine>;
1397+
let svc: ApprovalService;
1398+
1399+
beforeEach(() => {
1400+
engine = makeFakeEngine();
1401+
let n = 0;
1402+
const base = new Date('2026-09-01T09:00:00Z').getTime();
1403+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
1404+
});
1405+
1406+
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
1407+
...openInput([]),
1408+
config: { approvers, behavior, lockRecord: true, ...extra },
1409+
});
1410+
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
1411+
1412+
it('per_group: getRequest exposes per-group progress that updates per approval', async () => {
1413+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1414+
let row: any = await svc.getRequest(req.id, SYS);
1415+
expect(row.decision_progress).toMatchObject({ behavior: 'per_group', got: 0, need: 2 });
1416+
expect(row.decision_progress.groups).toEqual([
1417+
{ group: 'finance', got: 0, need: 1, satisfied: false },
1418+
{ group: 'legal', got: 0, need: 1, satisfied: false },
1419+
]);
1420+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1421+
row = await svc.getRequest(req.id, SYS);
1422+
expect(row.decision_progress.got).toBe(1);
1423+
expect(row.decision_progress.groups.find((g: any) => g.group === 'legal')).toMatchObject({ got: 1, satisfied: true });
1424+
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
1425+
});
1426+
1427+
it('quorum: progress reports approvals against the clamped threshold', async () => {
1428+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1429+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1430+
const row: any = await svc.getRequest(req.id, SYS);
1431+
expect(row.decision_progress).toMatchObject({ behavior: 'quorum', got: 1, need: 2 });
1432+
});
1433+
1434+
it('first_response: no decision_progress', async () => {
1435+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1436+
const row: any = await svc.getRequest(req.id, SYS);
1437+
expect(row.decision_progress).toBeUndefined();
1438+
});
1439+
1440+
it('notify: inbox actionUrl is rewritten to a request deep link', async () => {
1441+
const emitted: any[] = [];
1442+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
1443+
const req = await svc.openNodeRequest(openInput(['u1', 'u2']), CTX);
1444+
await svc.reassign(req.id, { actorId: 'u1', to: 'u7' }, SYS);
1445+
const note = emitted.find(e => e.topic === 'approval.reassigned');
1446+
expect(note.payload.actionUrl).toBe(`/system/approvals?request=${encodeURIComponent(req.id)}`);
1447+
});
1448+
});
1449+
1450+
// listActions must surface decision attachments through the contract mapping
1451+
// (#3266 — the column existed but rowFromAction dropped it; caught in browser).
1452+
describe('ApprovalService — listActions attachments mapping (#3266)', () => {
1453+
it('returns the attachments recorded on a decision', async () => {
1454+
const engine = makeFakeEngine();
1455+
let n = 0;
1456+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
1457+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1458+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
1459+
const acts = await svc.listActions(req.id, SYS);
1460+
const approve = acts.find(a => a.action === 'approve');
1461+
expect(approve?.attachments).toEqual(['file_a']);
1462+
});
1463+
});

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ function rowFromAction(row: any): ApprovalActionRow {
205205
action: row.action,
206206
actor_id: row.actor_id ?? undefined,
207207
comment: row.comment ?? undefined,
208+
// Decision attachments (#3266). The column shipped in #3268 but this
209+
// contract mapping didn't — the raw engine row carried the fileIds while
210+
// every consumer of listActions saw none (caught by browser verification).
211+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
208212
created_at: row.created_at ?? undefined,
209213
};
210214
}
@@ -268,8 +272,20 @@ export class ApprovalService implements IApprovalService {
268272
}): Promise<number> {
269273
const audience = input.audience.filter(a => a && !a.includes(':'));
270274
if (!this.messaging || !audience.length) return 0;
275+
// Deep-link the inbox (#2678 P1.5): a notification about one request should
276+
// land on that request, not the bare inbox. Rewritten centrally so every
277+
// call site — and any future one — inherits it; the query param is read by
278+
// the console inbox to auto-open the drawer.
279+
let payload = input.payload;
280+
if (
281+
payload?.actionUrl === '/system/approvals'
282+
&& input.source?.object === 'sys_approval_request'
283+
&& input.source.id
284+
) {
285+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
286+
}
271287
try {
272-
await this.messaging.emit({ severity: 'info', ...input, audience });
288+
await this.messaging.emit({ severity: 'info', ...input, payload, audience });
273289
return audience.length;
274290
} catch (err: any) {
275291
this.logger?.warn?.('[approvals] notification failed', {
@@ -2204,9 +2220,56 @@ export class ApprovalService implements IApprovalService {
22042220
const row = rowFromRequest(rows[0]);
22052221
await this.enrichRows([row]);
22062222
await this.attachFlowSteps(row);
2223+
await this.attachDecisionProgress(row, rows[0]);
22072224
return row;
22082225
}
22092226

2227+
/**
2228+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
2229+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
2230+
* for a PENDING request whose behavior aggregates multiple approvals
2231+
* (`unanimous` / `quorum` / `per_group`), expose
2232+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
2233+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
2234+
* `first_response` requests carry no progress (one approval finalizes).
2235+
* Display-only and best-effort — errors leave the row untouched.
2236+
*/
2237+
private async attachDecisionProgress(row: ApprovalRequestRow, raw: any): Promise<void> {
2238+
try {
2239+
if (row.status !== 'pending') return;
2240+
const cfg = parseJson<any>(raw.node_config_json, undefined);
2241+
const behavior = cfg?.behavior ?? 'first_response';
2242+
if (behavior !== 'unanimous' && behavior !== 'quorum' && behavior !== 'per_group') return;
2243+
2244+
const acts = await this.engine.find('sys_approval_action', {
2245+
where: { request_id: row.id, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
2246+
});
2247+
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
2248+
2249+
const snapshot = cfg?.__approverGroups as Record<string, string[]> | undefined;
2250+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...(row.pending_approvers ?? [])];
2251+
const total = slate.length || 1;
2252+
2253+
const progress: any = { behavior, got: approved.size, need: total };
2254+
if (behavior === 'quorum') {
2255+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
2256+
} else if (behavior === 'per_group' && snapshot) {
2257+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
2258+
const size: Record<string, number> = {};
2259+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
2260+
const got: Record<string, number> = {};
2261+
for (const a of approved) for (const g of (snapshot[a] ?? [])) got[g] = (got[g] ?? 0) + 1;
2262+
progress.groups = Object.keys(size).sort().map(g => {
2263+
const need = Math.min(perGroupNeed, size[g]);
2264+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
2265+
});
2266+
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
2267+
progress.need = progress.groups.length;
2268+
}
2269+
(row as any).decision_progress = progress;
2270+
} catch { /* display-only enrichment */ }
2271+
}
2272+
22102273
/**
22112274
* Derive approval-step progress from the owning flow's graph (single-read
22122275
* enrichment only — list reads skip it). Walks from the start node

packages/spec/src/contracts/approval-service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ export interface ApprovalRequestRow {
9797
* `node_config_json` snapshot (`__round`), so no schema migration.
9898
*/
9999
round?: number;
100+
/**
101+
* Server-computed decision aggregation progress (#3266, single-request reads
102+
* of PENDING requests only). Present when the node's behavior aggregates
103+
* multiple approvals: `unanimous` (got/need = approvals of total),
104+
* `quorum` (got/need = approvals of the M threshold), `per_group`
105+
* (got/need = satisfied groups of total groups, plus per-group detail).
106+
* Absent for `first_response`. Display-only — the engine's finalization
107+
* tally in decideNode stays authoritative.
108+
*/
109+
decision_progress?: {
110+
behavior: 'unanimous' | 'quorum' | 'per_group';
111+
got: number;
112+
need: number;
113+
groups?: Array<{ group: string; got: number; need: number; satisfied: boolean }>;
114+
};
100115
}
101116

102117
/** Kinds of entries on a request's audit trail. */
@@ -130,6 +145,8 @@ export interface ApprovalActionRow {
130145
action: ApprovalActionKind;
131146
actor_id?: string;
132147
comment?: string;
148+
/** File references attached to this action (decision attachments, #3266). */
149+
attachments?: string[];
133150
created_at?: string;
134151
/** Display name of the actor (`sys_user.name`), when resolvable. */
135152
actor_name?: string;

0 commit comments

Comments
 (0)