Skip to content

Commit 544b8d7

Browse files
fix(plugin-approvals): the out-of-office notifications name the absent approver and the delegate by display name (#24146)
Fixes #24065 Clause-②: no ## What changed The out-of-office fan-out in `openNodeRequest` now names both people by display name in its two notification bodies. Path: `packages/plugins/plugin-approvals/src/approval-service.ts`, line numbers at this head. - `approval.ooo_substituted` (`:3640`) goes to the delegate and names the absent approver. `You are covering an approval on Deal D_ooo while u_ooo is out of office.` now reads `… while Olive Away is out of office.` - `approval.ooo_skipped` (`:3652`) goes to the absent approver and names the delegate. `An approval on Deal D_ooo was routed to u_bob while you are out of office.` now reads `… was routed to Bob while you are out of office.` Both names come from `resolveUserNames` (`:6680`), the plugin's one person-label chain. The approvals door uses the same resolver for `pending_approver_names` and for the names on the timeline. - The fan-out calls it once, at `:3622`, before the loop, with both people (`sub.from` and `sub.to`) of every hop. - The chain removes duplicates, so an A → B → C chain is one `sys_user` read for three people. - With no substitution, the call has no targets and makes no read: `readUserNames` returns before its `find`. No resolver was added. `resolveUserNames` itself, its fallback and its field-level-security note are unchanged. The record half (`nameSubjectRecord`, from #23978) is untouched. ### What the body shows when there is no name, and whether that can be an email `sub.from` and `sub.to` are the hop's `delegator_id` and `delegate_id` (`applyOooDelegation`). Both are `sys_user` lookups, so the stored value is a user id, never an email. For each person, the body shows: | what the chain reads | the body names the person as | |:--|:--| | an account row with a `name` | the name (`Olive Away`) | | an account row with no `name` but with an `email` | **the email address.** `readUserNames` labels such a row with `u.name ?? u.email`. This is the same label the approvals door already serves for that account, as `pending_approver_names` and on the timeline. `sys_user.name` is declared `required: true` (`packages/platform-objects/src/identity/sys-user.object.ts`), so only a row written without a name reaches this case. | | no account row for the id, or a failed `sys_user` read | the stored user id, which is the text the body carries today. This is the rule the spec's `ApprovalActionRow` states for every reader of this chain: when no name is returned, "render the address". | Each body goes to one party of the hop's delegation and names the other party. A body is a **stored** copy of the name: messaging stores it and the inbox serves it. The `resolveUserNames` docblock warns, under "STORED COPIES", that a later narrowing of names by `sys_user` field-level security does not reach the copies stored in rows. It lists two columns, and these two bodies are a third copy. The comment at the call site says so. Adding the bodies to the docblock's list is outside this claim's file surface, so it is an open question in the report (see Acceptance notes). ## The audit comment keeps the ids, by design The `ooo_substitute` row's `comment` (`:3629`, for example `u_ooo → u_bob — leave`) is unchanged. These read it: - **The approvals door.** `listActions` serves the comment as stored. It resolves names per read for `actor_id`, `reassign_from` and `reassign_to`. This row has no person in any of those fields (`actor_id` is null). - **The console.** The request timeline renders `a.comment` verbatim, in quotes: `ApprovalsInboxPage.tsx` and `RecordApprovalsPanel.tsx` at the console pin `f8267df93a`. - **The list views.** `sys_approval_action`'s `recent` and `all_actions` list views show the column, and an export of the object carries it. - **The platform checklist.** The OOO item in `docs/qa/platform-checklist/areas/approvals.json` verifies the row by its comment reading `A → B`, the two identities. This comment is the request's audit trail of identities. An id stays true when a user is renamed. A name written into this row would be one more copy that a later field-level-security narrowing cannot reach. Where the platform wanted a hand-off's parties to be readable, it chose structured fields with names resolved at read time: `reassign_from` / `reassign_to` and their `*_name` companions replaced the `"FROM → TO"` text that `reassign` used to write into its comment (spec `ApprovalActionRow`). A readable OOO timeline line is a job for that shape, not for this comment (see Acceptance notes). ## Premise check (`origin/main` `27d7c2aa1d`) The premise holds. - The bodies at `:3624` and `:3636` interpolated `sub.from` and `sub.to` as raw ids. - The audit comment is at `:3613`, and `resolveUserNames` is at `:6664`. - `notify()` passes `payload.body` to `messaging.emit` unchanged. The red run below shows the bodies as messaging received them. ## Pin `approval-notification-body.integration.test.ts` is the battery that drives every call site that notifies. It runs the real `ApprovalService` over a real `ObjectQL` on `SqlDriver` (better-sqlite3, in memory), and records what reaches the messaging surface. This PR adds one request and two tests: - **Request `ooo_gone`** is the control. Its approver `u_gone` has no `sys_user` row and an active delegation to `u_alice`. So in one fan-out, one person's name resolves and the other's cannot. - **Test: both OOO bodies name people by display name.** - It asserts the person's name in each body: `Olive Away` in `ooo_substituted@ooo`, `Bob` in `ooo_skipped@ooo`, and `Alice` in `ooo_skipped@ooo_gone`. It also asserts that the matching id is absent. It does not assert the rest of the sentence. - It then sweeps every OOO body the battery sent for any of the battery's user ids. - **Test: the fallback.** The `ooo_substituted@ooo_gone` body names `u_gone`, so the slot holds the id, not `undefined` and not an empty string. The battery's coverage list gains the two `ooo_gone` sites. Readings: - **Red against the unfixed source** (test-only commit `225281a720`). One test failed and six passed. The three offenders were the bodies as sent: `You are covering an approval on Deal D_ooo while u_ooo is out of office.`, `An approval on Deal D_ooo was routed to u_bob while you are out of office.` and `An approval on Deal D_ooo_gone was routed to u_alice while you are out of office.` The fallback pin was green before the fix, because the id was the whole text then. - **Green** at `26ca62ac57`: the battery passes 7/7. - **Ablation 1** (`scripts/ablation-replace.mjs`, WRAP mode): `oooPerson` returns the bare id, which puts the old text back in both bodies. The anchor went from 1 hit to 0 and the blob from `05d5b39c94e8` to `269142866144`. The name pin went red with exactly the three offenders above, and the other six tests stayed green. The restore was proven: the blob is `05d5b39c94e8`, equal to HEAD, and `git diff HEAD` is empty. - **Ablation 2**: `oooPerson` returns the chain's answer with no fallback. The blob went from `05d5b39c94e8` to `c88096bc793b`. The fallback pin went red with `You are covering an approval on Deal D_ooo_gone while undefined is out of office.`, and the other six tests stayed green. The restore was proven: the blob is `05d5b39c94e8`, equal to HEAD, and `git diff HEAD` is empty. - The test imports the subject relatively from `src/`, so neither ablation has a `dist/` leg. ## Tests and gates (head `de3023f846`, after merging `origin/main` `462d02f00a`) - **Package tests:** `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2` passed 81 files / 1107 tests. - **Typecheck:** `pnpm --filter @objectstack/plugin-approvals typecheck` exits 0. `check:test-typecheck` holds the ledger unchanged at 8 files / 324 errors. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands. I ran all 67 and recorded each exit before any pipe. 65 exited 0 on the first pass. - Two exited 3 (`PREREQUISITE NOT MET`): `pnpm check:dual-build-cjs-loads` (some package had no `dist/`) and `pnpm check:i18n` (the CLI build closure was missing). - I built both prerequisites with the commands each gate prints. Turbo answered from cache: 61/61 cached for the i18n closure, and 75/76 for the whole workspace. - On re-run, both exit 0. `check:i18n` reports all 10 packages' bundles in sync. `check:dual-build-cjs-loads` reports 111 require entry points across 69 packages load. - **`dispatch-gates --ran`** over the final record: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Over the first-pass record, it reports 65 run and 2 NOT-MEASURED, derived from the two exit 3 codes. - **Changeset level axis**, measured offline: `node scripts/check-changeset-no-major.mjs --base origin/main --event` with this body exits 0 (`patch` under `Clause-②: no`). - **CI-run families:** this PR's check-runs are the reading. ## Acceptance notes - **The `resolveUserNames` "STORED COPIES" docblock** lists `pending_approver_labels` and `acted_as_label` as the stored copies that a field-level-security narrowing must follow. These two bodies are now a third. The call site records this, and the docblock does not. The claim excluded `resolveUserNames` itself, so the one-line addition is left to the seat. - **The OOO row on the console timeline.** At the console pin, the timeline maps no label for `ooo_substitute`, so it shows the raw kind `ooo_substitute`, followed by the comment `"u_ooo → u_bob — leave"`. This is a reading of the source only, not measured in a running console. The structured-parties shape that `reassign` uses is the fix pattern that preserves the audit comment. Carrier: none. - **A delegation chain.** In an A → B → C chain, B is told they are covering for A, although B is also away and the slot went on to C. That is the existing per-hop design, and this PR does not change it. --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 265ef64 commit 544b8d7

3 files changed

Lines changed: 95 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The two out-of-office approval notifications name people by their display name instead of their raw user id. The delegate's `approval.ooo_substituted` body used to read `You are covering an approval on Deal D_123 while u_ooo is out of office.`, and now names the absent approver: `… while Olive Away is out of office.` The absent approver's `approval.ooo_skipped` body used to read `… was routed to u_bob while you are out of office.`, and now names the delegate: `… was routed to Bob …`.
8+
9+
The names come from the same user-name lookup the approvals inbox uses for pending approvers and the timeline, read once per opening for every person the out-of-office routing touched. An account row with no name is named by its email, as the inbox names it. A person with no readable name (no account row for the id, or a failed read) is still named by the user id, as before. The `ooo_substitute` entry on the request's audit trail is unchanged and still records the two user ids (`u_ooo → u_bob — reason`).

‎packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@
4949
* record of an object whose schema has no label is named by the object's API
5050
* name and the id, the helper's own fallback.
5151
*
52+
* ## And whom the out-of-office bodies name (#24065)
53+
*
54+
* The two out-of-office bodies also name a person: the delegate is told whom
55+
* they cover, and the absent approver is told who covers for them. Both used to
56+
* interpolate the raw `sys_user` id (`… while u_ooo is out of office.`). They
57+
* now name each person through `resolveUserNames`, the chain the approvals door
58+
* names people with. The battery pins each person's name in each body, and a
59+
* control: an approver id no account row answers to is named by that id, the
60+
* way every reader of the chain renders an unresolved address.
61+
*
5262
* ## And whose notification it is (#23284)
5363
*
5464
* The same battery pins the organization every call site hands messaging: the
@@ -80,6 +90,13 @@ const USERS = [
8090
{ id: 'u_boss', name: 'Boss', email: 'boss@example.com' },
8191
];
8292

93+
/**
94+
* [#24065] An approver id no `sys_user` row answers to, such as an account
95+
* removed after its out-of-office delegation was written. Deliberately not in
96+
* {@link USERS}.
97+
*/
98+
const GONE = 'u_gone';
99+
83100
/** The text each person writes — distinct, so no one can pass for another. */
84101
const COMMENT = 'The signed contract is attached to the record now.';
85102
const QUESTION = 'Which cost centre should this be booked against?';
@@ -238,6 +255,15 @@ describe('every approval notification carries its text in body', () => {
238255
}, { context: SYSTEM } as any);
239256
const ooo = await open('ooo', [{ type: 'user', value: 'u_ooo' }]);
240257
expect(ooo.pending_approvers).toEqual(['u_bob']);
258+
// [#24065] The control: the absent approver names no account, so no name
259+
// can be read for them; the delegate the same hop routes to does.
260+
await engine.insert('sys_approval_delegation', {
261+
id: 'dlg_2', delegator_id: GONE, delegate_id: 'u_alice', reason: 'leave',
262+
valid_from: '2026-09-01T00:00:00.000Z', valid_until: '2026-12-31T00:00:00.000Z',
263+
created_at: '2026-09-01T00:00:00.000Z', updated_at: '2026-09-01T00:00:00.000Z',
264+
}, { context: SYSTEM } as any);
265+
const oooGone = await open('ooo_gone', [{ type: 'user', value: GONE }]);
266+
expect(oooGone.pending_approvers).toEqual(['u_alice']);
241267

242268
// ── The SLA sweep: a notify (submitter told) and a reassign (submitter not) ──
243269
await open('sla_notify', [{ type: 'user', value: 'u_alice' }],
@@ -255,8 +281,8 @@ describe('every approval notification carries its text in body', () => {
255281
it('the battery reaches every call site that can notify anyone — twelve of the thirteen', () => {
256282
expect(new Set(emits.map(siteOf))).toEqual(new Set([
257283
// The opening tells each concrete approver on every request the battery
258-
// opens — all but `ooo`, whose one approver is the delegate, told once
259-
// by `approval.ooo_substituted` instead.
284+
// opens — all but `ooo` and `ooo_gone`, whose one approver is the
285+
// delegate, told once by `approval.ooo_substituted` instead.
260286
'approval.requested@thread',
261287
'approval.requested@revise',
262288
'approval.requested@budget',
@@ -266,6 +292,8 @@ describe('every approval notification carries its text in body', () => {
266292
'approval.requested@bare',
267293
'approval.ooo_substituted@ooo',
268294
'approval.ooo_skipped@ooo',
295+
'approval.ooo_substituted@ooo_gone',
296+
'approval.ooo_skipped@ooo_gone',
269297
'approval.returned@budget',
270298
'approval.returned@revise',
271299
'approval.returned@rework',
@@ -327,6 +355,44 @@ describe('every approval notification carries its text in body', () => {
327355
expect(slashed).toEqual([]);
328356
});
329357

358+
/** [#24065] Every body one call site sent. */
359+
const bodiesAt = (site: string): string[] =>
360+
emits.filter((e) => siteOf(e) === site).map((e) => String(e.payload?.body));
361+
362+
it('[#24065] both out-of-office bodies name the absent approver and the delegate by display name, never by user id', () => {
363+
// The pin is each person's name in each body, not the sentence around it.
364+
const NAMED: Record<string, { name: string; id: string }> = {
365+
// The delegate is told whom they cover …
366+
'approval.ooo_substituted@ooo': { name: 'Olive Away', id: 'u_ooo' },
367+
// … and the absent approver who covers for them.
368+
'approval.ooo_skipped@ooo': { name: 'Bob', id: 'u_bob' },
369+
// The control's resolvable half: the delegate on the same hop as an
370+
// approver no account answers to.
371+
'approval.ooo_skipped@ooo_gone': { name: 'Alice', id: 'u_alice' },
372+
};
373+
const offenders = Object.entries(NAMED).flatMap(([site, { name, id }]) => {
374+
const bodies = bodiesAt(site);
375+
if (!bodies.length) return [`${site}: sent nothing`];
376+
return bodies.filter((body) => !body.includes(name) || body.includes(id)).map((body) => `${site}: ${body}`);
377+
});
378+
expect(offenders).toEqual([]);
379+
380+
// And no out-of-office body anywhere in the battery names one of its
381+
// people by their user id.
382+
const byId = emits
383+
.filter((e) => e.topic === 'approval.ooo_substituted' || e.topic === 'approval.ooo_skipped')
384+
.flatMap((e) => USERS.filter((u) => String(e.payload?.body).includes(u.id))
385+
.map((u) => `${siteOf(e)}: names ${u.id} in ${String(e.payload?.body)}`));
386+
expect(byId).toEqual([]);
387+
});
388+
389+
it('[#24065] a person no name can be read for is named by their stored id, as every reader of the chain renders an unresolved address', () => {
390+
// `u_gone` names no `sys_user` row, so the chain answers no name for it.
391+
// The body still names the person, by the id the delegation stores: never
392+
// an empty slot, never `undefined`.
393+
expect(bodiesAt('approval.ooo_substituted@ooo_gone')).toEqual([expect.stringContaining(GONE)]);
394+
});
395+
330396
it('every notification carries a non-empty body, and no field the payload does not declare', () => {
331397
const offenders: string[] = [];
332398
for (const e of emits) {

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3605,6 +3605,22 @@ export class ApprovalService implements IApprovalService {
36053605
// audit trail (a system action, no human actor) and notified to both the
36063606
// delegate — who now owns the slot — and the skipped approver. [#23978]
36073607
// Both bodies name the record the way every approval notification does.
3608+
//
3609+
// [#24065] And each body names the person by display name, through the
3610+
// chain the approvals door names people with (`resolveUserNames`):
3611+
// both people of every hop in ONE read for the whole fan-out, and no read
3612+
// at all when nothing was substituted. A person the chain answers no name
3613+
// for (no account row, or the read failed) is named by the stored id, the
3614+
// way every reader of that chain renders an unresolved address. A body is
3615+
// a STORED copy of the name (messaging stores it, the inbox serves it),
3616+
// like the stored labels the `resolveUserNames` docblock lists.
3617+
//
3618+
// The audit `comment` keeps the ids by design: it is the request's audit
3619+
// trail of identities, served as written by the timeline and the action
3620+
// lists. Display names belong in read-time `*_name` companions (the
3621+
// `reassign` hand-off's shape), not frozen into an audit row.
3622+
const oooNames = await this.resolveUserNames(substitutions.flatMap((sub) => [sub.from, sub.to]));
3623+
const oooPerson = (userId: string): string => oooNames.get(userId) || userId;
36083624
for (const sub of substitutions) {
36093625
await this.engine.insert('sys_approval_action', await this.actionRow(row, {
36103626
id: uid('aact'), request_id: id, organization_id: requestOrg,
@@ -3621,7 +3637,7 @@ export class ApprovalService implements IApprovalService {
36213637
dedupKey: `approval-ooo-${id}-${sub.to}`,
36223638
payload: {
36233639
title: 'Approval routed to you (out-of-office cover)',
3624-
body: `You are covering an approval on ${this.nameSubjectRecord(input.object, input.recordId)} while ${sub.from} is out of office.`,
3640+
body: `You are covering an approval on ${this.nameSubjectRecord(input.object, input.recordId)} while ${oooPerson(sub.from)} is out of office.`,
36253641
actionUrl: '/system/approvals',
36263642
},
36273643
});
@@ -3633,7 +3649,7 @@ export class ApprovalService implements IApprovalService {
36333649
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
36343650
payload: {
36353651
title: 'Approval routed to your delegate',
3636-
body: `An approval on ${this.nameSubjectRecord(input.object, input.recordId)} was routed to ${sub.to} while you are out of office.`,
3652+
body: `An approval on ${this.nameSubjectRecord(input.object, input.recordId)} was routed to ${oooPerson(sub.to)} while you are out of office.`,
36373653
actionUrl: '/system/approvals',
36383654
},
36393655
});

0 commit comments

Comments
 (0)