Skip to content

Commit 2351c8d

Browse files
fix(plugin-approvals): an SLA reassign to a target that resolves to no one keeps the slate (#25600)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMdu6odAshaFZ1Ujr7q9LcW)_ Refs #25598 (the runtime leg; the registration-time leg is a `packages/spec` change, left for the `domain:spec` lane — claim 6379436604, "Not this claim") Clause-②: no Metadata-protocol: none ## What changed An approval node's `escalation: { action: 'reassign', escalateTo }` expands `escalateTo` as a position. When nobody holds it, the sweep falls back to the literal so that a config naming a user id keeps working. Nothing judged that literal, so an unstaffed or mistyped position, a user id with no account, or a `team:` literal moved the overdue request off its real approvers and onto a slate nobody could decide. That is the state #25592 closed for the reassign door. The literal is now judged by the door's own predicate: - **One predicate.** `assertReassignTargetResolves` (from #25592) is split into `reassignTargetRefusal(to, org)`, which returns the refusal sentence or `undefined`. The door's assert is now a thin throw over it, and its text and behaviour are unchanged (the #25485 pins pass as before). `escalateRequest` asks the same function. There is no second predicate. - **Only the literal fallback is judged.** When the position expansion finds holders, those user ids go onto the slate as before. | `escalateTo` with `action: 'reassign'` | Before | After | |---|---|---| | a position name with holders here | slate = holders | unchanged | | a user id an account carries (member here, or no memberships) | slate = id | unchanged | | an email an account carries | slate = email | unchanged | | `position:<p>` that someone here holds | slate = `position:<p>` | unchanged | | an unstaffed / mistyped name, a user id with no account | **slate = the literal, request stranded** | slate kept, approvers notified | | a user whose memberships are all in other organizations | **stranded** | slate kept, approvers notified | | `position:<p>` nobody holds here, a `team:` / `department:` / `org_membership_level:` / `role:` literal | **stranded** | slate kept, approvers notified | - **What happens when the target resolves to no one.** - `pending_approvers`, `pending_approver_labels` and the approver index are left as they were. - The sweep takes the `notify` branch: `approval.sla_breached` goes to the current slate, and the unresolvable literal is not in the audience. - No `approval.escalated` notice is sent. - The `escalate` audit row reads `reassign → <target> — not applied: <why> The pending approvers were kept and notified.` The row is still the single-shot marker, so the escalation fires once. - **The submitter notice names the action taken.** It used to report the configured action (`escalation action 'reassign' was taken`) even when no reassign happened. Now it reports `notify` in that case, and in the existing case where no `escalateTo` was set. - **A failed `sys_user` read** throws before the audit row. The sweep's per-request isolation logs it and leaves the request untouched and unmarked, and the next sweep retries it. - **`action: 'notify'` is unchanged**, including its audience, which still lists a literal `escalateTo`. So is every other escalation action. - **No new error code or export, and nothing in `packages/spec` or `packages/rest` changes.** Every `escalation` block that registers today still registers. ## Tests - `approval-service.test.ts` gains `SLA reassign target resolution (#25598)`, 6 cases. - Refusal cases. Each asserts the slate, labels and index unchanged, the audit comment verbatim, the `sla_breached` audience (the current slate only), no `approval.escalated`, the submitter notice saying `'notify'`, and that a second sweep is a no-op. The cases are: - a user id with no account; - a position name held only in another organization; - a user whose only membership is another organization; - a `team:` literal and a `position:ghost` nobody holds. - A failed `sys_user` read: nothing moves, no `escalate` row, no notice, and the next sweep with the directory back escalates. - Preservation: an account's user id, its email, and a held `position:finance`. The slate becomes the target, the audit comment is unchanged, and the submitter notice says `'reassign'`. The position's holder then decides with the default actor. - Two existing tests that escalated to `boss`, an id with no account, now give it one (`staffDirectory`): `runEscalations: reassign replaces the approver set with escalateTo` and `reassign and SLA-reassign rewrite the index rows`. - New `packages/qa/dogfood/test/approval-sla-reassign-target-resolves.dogfood.test.ts` runs on a booted stack with the REST door and the real ObjectQL. Two approval flows reassign after one hour, one to a position nobody holds and one to a member's email. Both requests are made overdue (`created_at` moved back), and the sweep runs through the registered `approvals` service. - The first keeps its approver, who still finds it in "My Pending". Its `escalate` row says why, and only `approval.sla_breached` is recorded for it. - The second moves to the member, and the member's "My Pending" lists it. ## Verification All on `4525d322`, whose tree is identical to the pushed head `d7a204a5` (the amend reworded the commit message only). That commit is one commit on `main` `a57643d3`, after #25592 merged. - `pnpm --filter @objectstack/plugin-approvals exec vitest run`: 95 files, 1250 tests, all passed. `pnpm --filter @objectstack/plugin-approvals typecheck` passed, `check:test-typecheck` included (the ledger held at 324). `pnpm --filter @objectstack/dogfood typecheck` passed. eslint on the 3 changed source and test files: clean. - Ablation, run before the rebase with `approval-service.ts` restored from #25592's head `00d5635a`: - All 5 refusal and failed-read pins in the new block went red. The preservation pin stayed green, as expected. - After the plugin's `dist` was rebuilt, the dogfood pin went red on its first assertion: `expected 'sla_reassign_nobody' to be 'sla-reassign-approver@example.com'`. - After the restore, the tree matched HEAD and `git status --porcelain` was empty. - Local gates (`dispatch-gates --commands`, 76 families): all 76 exited 0. - Full dogfood battery: 296 files, of which 292 passed, 1 failed and 3 were skipped. Tests: 2508 passed, 1 failed, 18 skipped. - The new pin passed. - The one failure is not this PR's: `showcase-d3-d4-capabilities.dogfood.test.ts:135` (`[#9237] the grant the rule reconciles…`) expects `/SHARING_NOT_ENABLED: 'showcase_project' is not under record-sharing enforcement/`. `main` answers `…has a public sharing model, so a share row on it is consulted only for an external member of the record's organization…`, the text #25591 introduced, and it reproduces with that file run alone. - This diff touches no sharing code. It was reported to the lane for routing. ## Acceptance notes - The branch is the harness-assigned `claude/project-thread-h5z02d`, restarted from `main` after #25592 merged. Claim 6379436604 on #25598 is the identity record. - `Refs`, not `Fixes`: the card's second leg is refusing a `team:` / `department:` / `org_membership_level:` / `role:` `escalateTo` at registration. Approval node config values are judged by the spec's `ApprovalEscalationSchema` through `FlowSchema.parse`, the route #21848 took, so that leg is `packages/spec` work. The services seat left the `domain:spec` seat a pointer on the card. - Unchanged and noted: `notify` drops `type:value` audiences, so a reassign to a held `position:<p>` literal announces itself to no one. This is pre-existing and pinned as it is today. --- _Generated by [Claude Code](https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HopxxRtFE6z3z91wUD54tp Co-authored-by: Claude <noreply@anthropic.com>
1 parent f192123 commit 2351c8d

4 files changed

Lines changed: 392 additions & 23 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): an SLA escalation that reassigns to a target resolving to no one keeps the request on its approvers and notifies them, instead of stranding it on a slate nobody can decide
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An approval node's `escalation: { action: 'reassign', escalateTo }` expands `escalateTo` to the holders of that position, and when nobody holds it falls back to the value itself, so a config naming a specific user id keeps working. That fallback is now judged by the same rule the reassign door applies: a user id or email an account carries (and not a user of other organizations only), or `position:<name>` for a position someone in the request's organization holds.
11+
12+
- **A target that resolves** reassigns exactly as before.
13+
- **A target that does not** (an unstaffed or mistyped position name, a user id with no account, a `team:` / `department:` / `org_membership_level:` / `role:` literal) no longer replaces the slate. The pending approvers are kept and receive `approval.sla_breached`, and the `escalate` audit row records `reassign → <target> — not applied: <why>`. The escalation still fires once.
14+
- **The submitter's escalation notice** now names the action actually taken (`notify` when the reassign was not applied, or when no `escalateTo` was set), not the configured one.
15+
- A directory read that fails during the sweep leaves the request untouched and unmarked, so the next sweep retries it.
16+
17+
Nothing an author writes is refused that was accepted before. To have an overdue request move, name a staffed position or a person in the request's organization in `escalateTo`.

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

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1945,7 +1945,7 @@ describe('ApprovalService (node era)', () => {
19451945
const req = await svc.openNodeRequest(
19461946
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
19471947
);
1948-
staffDirectory(engine, 'u7');
1948+
staffDirectory(engine, 'u7', 'boss');
19491949
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, SYS);
19501950
expect(indexRows().map(r => r.approver).sort()).toEqual(['u2', 'u7']);
19511951

@@ -2084,6 +2084,7 @@ describe('ApprovalService (node era)', () => {
20842084
});
20852085

20862086
it('runEscalations: reassign replaces the approver set with escalateTo', async () => {
2087+
staffDirectory(engine, 'boss');
20872088
const req = await svc.openNodeRequest(
20882089
openInput(['u9', 'u2'], {}, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'boss', notifySubmitter: false } }), CTX,
20892090
);
@@ -2132,6 +2133,137 @@ describe('ApprovalService (node era)', () => {
21322133
expect(emitted[0].audience).toEqual(['u9', 'u5']);
21332134
});
21342135

2136+
// ── SLA reassign target resolution (#25598) ─────────────────────
2137+
//
2138+
// An escalation `reassign` whose `escalateTo` expands to no position holder
2139+
// falls back to the literal. A literal that resolves to no one used to be
2140+
// written onto the slate, stranding the request the way the reassign door
2141+
// did (#25485). Now the door's predicate judges it: the slate stays, its
2142+
// approvers are told, and the audit row says why.
2143+
describe('SLA reassign target resolution (#25598)', () => {
2144+
let emitted: any[];
2145+
2146+
beforeEach(() => {
2147+
emitted = [];
2148+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
2149+
});
2150+
2151+
/** Open an overdue `reassign` escalation to `escalateTo` on `slate`, the opening's notices cleared. */
2152+
async function overdueReassign(escalateTo: string, recordId = 'opp1'): Promise<string> {
2153+
const { id } = (await svc.openNodeRequest(
2154+
openInput(['u9', 'u2'], { recordId, record: { id: recordId } }, { escalation: { timeoutHours: 1, action: 'reassign', escalateTo, notifySubmitter: true } }), CTX,
2155+
)) as { id: string };
2156+
expect(engine._tables['sys_approval_request'].find(r => r.id === id)?.organization_id).toBe('t1');
2157+
makeOverdue(id);
2158+
emitted.splice(0);
2159+
return id;
2160+
}
2161+
2162+
const requestRow = (id: string) => engine._tables['sys_approval_request'].find(r => r.id === id)!;
2163+
const indexedOf = (id: string) => (engine._tables['sys_approval_approver'] ?? [])
2164+
.filter(r => r.request_id === id).map(r => r.approver).sort();
2165+
const escalateRow = (id: string) => (engine._tables['sys_approval_action'] ?? [])
2166+
.find(r => r.request_id === id && r.action === 'escalate');
2167+
2168+
async function expectKeptAndNotified(id: string, escalateTo: string, why: string) {
2169+
const before = { ...requestRow(id) };
2170+
emitted.splice(0);
2171+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
2172+
const row = requestRow(id);
2173+
expect(row.status).toBe('pending');
2174+
expect(row.pending_approvers).toBe('u9,u2');
2175+
expect(row.pending_approver_labels).toEqual(before.pending_approver_labels);
2176+
expect(indexedOf(id)).toEqual(['u2', 'u9']);
2177+
expect(escalateRow(id)?.comment).toBe(
2178+
`reassign → ${escalateTo} — not applied: ${why} The pending approvers were kept and notified.`,
2179+
);
2180+
// The current slate hears the lapse; the target that resolves to no one
2181+
// is not in the audience, and nothing announces a move.
2182+
expect(emitted.map(e => e.topic)).toEqual(['approval.sla_breached', 'approval.sla_breached']);
2183+
expect(emitted[0].audience).toEqual(['u9', 'u2']);
2184+
expect(emitted.some(e => e.topic === 'approval.escalated')).toBe(false);
2185+
// The submitter is told the action that was TAKEN.
2186+
expect(emitted[1].audience).toEqual(['u1']);
2187+
expect(emitted[1].payload.body).toContain("escalation action 'notify' was taken");
2188+
// Still single-shot: the escalate row is the marker.
2189+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
2190+
}
2191+
2192+
it('keeps the slate when escalateTo names no account (a departed user, a mistyped id)', async () => {
2193+
const id = await overdueReassign('ghost_user');
2194+
await expectKeptAndNotified(id, 'ghost_user', 'it names no user in this request\'s organization.');
2195+
});
2196+
2197+
it('keeps the slate when escalateTo is a position name nobody holds here', async () => {
2198+
engine._tables['sys_user_position'] = [
2199+
{ id: 'up1', user_id: 'u5', position: 'approvals_supervisor', organization_id: 't2' }, // other tenant only
2200+
];
2201+
const id = await overdueReassign('approvals_supervisor');
2202+
await expectKeptAndNotified(id, 'approvals_supervisor', 'it names no user in this request\'s organization.');
2203+
});
2204+
2205+
it('keeps the slate when escalateTo is a user whose only membership is another organization', async () => {
2206+
engine._tables['sys_user'] = [{ id: 'u_far' }];
2207+
engine._tables['sys_member'] = [{ id: 'm1', user_id: 'u_far', organization_id: 't2' }];
2208+
const id = await overdueReassign('u_far');
2209+
await expectKeptAndNotified(id, 'u_far', 'it names no user in this request\'s organization.');
2210+
});
2211+
2212+
it('keeps the slate when escalateTo is a slot literal no approver acts under, or a position:<p> nobody holds', async () => {
2213+
const team = await overdueReassign('team:night_shift');
2214+
await expectKeptAndNotified(team, 'team:night_shift', 'it is a slot literal no approver acts under, so no one could ever decide the request.');
2215+
const ghost = await overdueReassign('position:ghost', 'opp2');
2216+
await expectKeptAndNotified(ghost, 'position:ghost', 'no one holds position \'ghost\' in this request\'s organization.');
2217+
});
2218+
2219+
it('a failed sys_user read moves nothing and records nothing, so the next sweep escalates', async () => {
2220+
staffDirectory(engine, 'boss');
2221+
const id = await overdueReassign('boss');
2222+
const find = engine.find.bind(engine);
2223+
(engine as any).find = async (object: string, q: any) => {
2224+
if (object === 'sys_user') throw new Error('directory down');
2225+
return find(object, q);
2226+
};
2227+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
2228+
expect(requestRow(id).pending_approvers).toBe('u9,u2');
2229+
expect(escalateRow(id)).toBeUndefined();
2230+
expect(emitted).toEqual([]);
2231+
(engine as any).find = find;
2232+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
2233+
expect(requestRow(id).pending_approvers).toBe('boss');
2234+
});
2235+
2236+
it('still reassigns to an account\'s user id, its email, and a position:<p> someone holds here', async () => {
2237+
engine._tables['sys_user'] = [{ id: 'boss', email: 'boss@example.com' }];
2238+
engine._tables['sys_member'] = [{ id: 'm1', user_id: 'boss', organization_id: 't1' }];
2239+
engine._tables['sys_user_position'] = [
2240+
{ id: 'up1', user_id: 'u5', position: 'finance', organization_id: 't1' },
2241+
];
2242+
const byId = await overdueReassign('boss');
2243+
const byEmail = await overdueReassign('boss@example.com', 'opp2');
2244+
const byPosition = await overdueReassign('position:finance', 'opp3');
2245+
expect(await svc.runEscalations()).toMatchObject({ escalated: 3 });
2246+
expect(requestRow(byId).pending_approvers).toBe('boss');
2247+
expect(requestRow(byEmail).pending_approvers).toBe('boss@example.com');
2248+
expect(requestRow(byPosition).pending_approvers).toBe('position:finance');
2249+
expect(indexedOf(byPosition)).toEqual(['position:finance']);
2250+
for (const [id, to] of [[byId, 'boss'], [byEmail, 'boss@example.com'], [byPosition, 'position:finance']]) {
2251+
expect(escalateRow(id)?.comment).toBe(`reassign → ${to}`);
2252+
}
2253+
// A `type:value` slot is no messaging address (`notify` drops it), so the
2254+
// position's hand-off announces itself to no one; that is today's notice.
2255+
expect(emitted.filter(e => e.topic === 'approval.escalated').map(e => e.audience))
2256+
.toEqual([['boss'], ['boss@example.com']]);
2257+
const submitterNotices = emitted.filter(e => e.topic === 'approval.sla_breached');
2258+
expect(submitterNotices).toHaveLength(3);
2259+
expect(submitterNotices.every(e => e.payload.body.includes("escalation action 'reassign' was taken"))).toBe(true);
2260+
// …and the holder of the position then decides with the default actor.
2261+
await svc.decideNode(byPosition, { decision: 'approve', actorId: 'u5' },
2262+
{ userId: 'u5', tenantId: 't1', positions: ['finance'], permissions: [] } as any);
2263+
expect(requestRow(byPosition).status).toBe('approved');
2264+
});
2265+
});
2266+
21352267
it('runEscalations: skips requests that are not yet due or have no SLA', async () => {
21362268
await svc.openNodeRequest(
21372269
openInput(['u9'], {}, { escalation: { timeoutHours: 1000, action: 'auto_approve' } }), CTX,

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

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5604,38 +5604,47 @@ export class ApprovalService implements IApprovalService {
56045604
* screens.
56055605
*/
56065606
private async assertReassignTargetResolves(to: string, organizationId: string | null): Promise<void> {
5607-
const refuse = (why: string): never => {
5608-
throw new Error(
5609-
`VALIDATION_FAILED: cannot reassign to '${to}' — ${why} The request and its approvers are unchanged. `
5610-
+ `Reassign to the user id or email of a person in this request's organization, or to `
5611-
+ `'position:<name>' for a position someone in it holds.`,
5612-
);
5613-
};
5607+
const why = await this.reassignTargetRefusal(to, organizationId);
5608+
if (why === undefined) return;
5609+
throw new Error(
5610+
`VALIDATION_FAILED: cannot reassign to '${to}' — ${why} The request and its approvers are unchanged. `
5611+
+ `Reassign to the user id or email of a person in this request's organization, or to `
5612+
+ `'position:<name>' for a position someone in it holds.`,
5613+
);
5614+
}
5615+
5616+
/**
5617+
* Why `to` resolves to no one in `organizationId` — the sentence a refusal
5618+
* carries — or `undefined` when someone can take a slot under it. The ONE
5619+
* predicate behind {@link assertReassignTargetResolves} (the door) and
5620+
* {@link escalateRequest}'s `reassign` (the SLA sweep, #25598); its contract
5621+
* is that docblock's.
5622+
*/
5623+
private async reassignTargetRefusal(to: string, organizationId: string | null): Promise<string | undefined> {
56145624
const position = addressedPosition(to);
56155625
if (position !== undefined) {
5616-
if ((await this.expandPositionUsers(position, organizationId)).length) return;
5617-
refuse(`no one holds position '${position}' in this request's organization.`);
5626+
if ((await this.expandPositionUsers(position, organizationId)).length) return undefined;
5627+
return `no one holds position '${position}' in this request's organization.`;
56185628
}
56195629
if (to.includes(':')) {
5620-
refuse('it is a slot literal no approver acts under, so no one could ever decide the request.');
5630+
return 'it is a slot literal no approver acts under, so no one could ever decide the request.';
56215631
}
5632+
const noUser = 'it names no user in this request\'s organization.';
56225633
const rows = await this.engine.find('sys_user', {
56235634
where: to.includes('@') ? { email: to } : { id: to },
56245635
fields: ['id'], limit: 1, context: SYSTEM_CTX,
56255636
});
56265637
const userId = Array.isArray(rows) && rows[0]?.id != null ? String(rows[0].id) : '';
5627-
if (!userId) refuse('it names no user in this request\'s organization.');
5628-
if (!organizationId) return;
5638+
if (!userId) return noUser;
5639+
if (!organizationId) return undefined;
56295640
let memberships: any[] = [];
56305641
try {
56315642
memberships = await this.engine.find('sys_member', {
56325643
where: { user_id: userId }, fields: ['organization_id'], limit: 1000, context: SYSTEM_CTX,
56335644
});
5634-
} catch { return; } // membership unreadable ⇒ no tenancy fact, as in the sibling screens
5645+
} catch { return undefined; } // membership unreadable ⇒ no tenancy fact, as in the sibling screens
56355646
const orgs = (memberships ?? []).map((r: any) => String(r?.organization_id ?? '')).filter(Boolean);
5636-
if (orgs.length && !orgs.includes(String(organizationId))) {
5637-
refuse('it names no user in this request\'s organization.');
5638-
}
5647+
return orgs.length && !orgs.includes(String(organizationId)) ? noUser : undefined;
56395648
}
56405649

56415650
/**
@@ -7040,12 +7049,26 @@ export class ApprovalService implements IApprovalService {
70407049
// empty expansion falls back to the literal, so a config naming a
70417050
// specific user id keeps working unchanged.
70427051
let escalatees: string[] = [];
7052+
let literalTarget = false;
70437053
if (escalateTo) {
70447054
try {
70457055
escalatees = await this.expandPositionUsers(escalateTo, raw.organization_id ?? null);
70467056
} catch { escalatees = []; }
7047-
if (!escalatees.length) escalatees = [escalateTo];
7048-
}
7057+
if (!escalatees.length) { escalatees = [escalateTo]; literalTarget = true; }
7058+
}
7059+
// [#25598] …but a literal must still name someone who can take the slot,
7060+
// by the door's own predicate (#25485). One that resolves to no one (an
7061+
// unstaffed position, a mistyped name, a departed user, a `team:` literal)
7062+
// would move the request off its real approvers onto a slate nobody can
7063+
// decide. So the slate stays, its approvers are told the SLA lapsed (the
7064+
// `notify` branch), and the audit row says why. A failed `sys_user` read
7065+
// throws before the audit row, so the next sweep retries the request.
7066+
const unresolved = action === 'reassign' && literalTarget
7067+
? await this.reassignTargetRefusal(escalateTo!, raw.organization_id ?? null)
7068+
: undefined;
7069+
if (unresolved !== undefined) escalatees = [];
7070+
// The action this sweep TAKES: a reassign with no one to hand to notifies.
7071+
const taken = action === 'reassign' && !escalatees.length ? 'notify' : action;
70497072

70507073
// Audit first — this row IS the idempotency marker (ADR-0042 §1), and the
70517074
// marker is its KIND (`escalate`), not its actor. A machine has no
@@ -7057,11 +7080,12 @@ export class ApprovalService implements IApprovalService {
70577080
id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
70587081
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
70597082
actor_id: null,
7060-
comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
7083+
comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`
7084+
+ (unresolved !== undefined ? ` — not applied: ${unresolved} The pending approvers were kept and notified.` : ''),
70617085
created_at: now,
70627086
}), { context: SYSTEM_CTX });
70637087

7064-
if (action === 'reassign' && escalatees.length) {
7088+
if (taken === 'reassign') {
70657089
await this.engine.update('sys_approval_request', {
70667090
id: raw.id, pending_approvers: escalatees.join(','), updated_at: now,
70677091
pending_approver_labels: await this.slateLabels(escalatees),
@@ -7086,7 +7110,8 @@ export class ApprovalService implements IApprovalService {
70867110
comment: 'SLA escalation',
70877111
}, SYSTEM_CTX, 'escalation');
70887112
} else {
7089-
// 'notify' (and the reassign-without-target fallback)
7113+
// 'notify' (and a reassign with no one to hand to: no target, or one
7114+
// that resolves to no one)
70907115
await this.notify({
70917116
topic: 'approval.sla_breached',
70927117
organizationId: raw.organization_id,
@@ -7106,7 +7131,7 @@ export class ApprovalService implements IApprovalService {
71067131
source: { object: 'sys_approval_request', id: raw.id },
71077132
...approvalNotice(approvalNotificationText({
71087133
title: 'approvalSlaBreachedSubmitterTitle', body: 'approvalSlaBreachedSubmitterBody',
7109-
params: { ...subject, action },
7134+
params: { ...subject, action: taken },
71107135
})),
71117136
});
71127137
}

0 commit comments

Comments
 (0)