From 68baad43a0a9df7e1b14f1f3227749ae27da3c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:09:14 +0000 Subject: [PATCH 1/2] test(approvals): pin the approval notification body at the producer and at the inbox door Two new pins, committed ahead of the fix so their red reading is taken on the unfixed service: - plugin-approvals: every call site that notifies, driven through the real ApprovalService on ObjectQL over SqlDriver; a comment, a request-info question and a send-back note are their recipient's body, and every payload carries a non-empty body and no undeclared field. - dogfood: a comment and a request-info question read back from the recipient's GET /api/v1/notifications on a booted app with the real approvals + messaging chain. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- ...oval-notification-body.integration.test.ts | 251 ++++++++++++++++++ ...approval-notification-body.dogfood.test.ts | 190 +++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts create mode 100644 packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts diff --git a/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts new file mode 100644 index 00000000000..f9d00415529 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every approval notification carries its text in `body` — the one field the + * `messaging` service's `EmitInput` documents for it, and the only one the + * delivered notification is built from. + * + * ## The defect + * + * Every approval notification reached its recipient as a title over an empty + * body. The service put its text in `payload.message`; messaging reads + * `payload.title` and `payload.body` (the inline fan-out, the outbox snapshot + * and the dispatcher alike), so the comment, the request-info question and + * every escalation line were dropped on the way to the inbox. The flow + * `notify` node and the @mention producer already send `body`. + * + * ## What this pins + * + * The fix is at the producer, with no alias in messaging. `notify()` types its + * payload, so a call site that spells the text any other way does not compile; + * this file pins the behaviour across EVERY call site the service has, driven + * through the real `ApprovalService` on a real `ObjectQL` over `SqlDriver` + * (better-sqlite3, in-memory, real DDL): + * + * - a comment and a request-info question ARE the body their recipient gets; + * - every notification carries a non-empty `body` and no field outside the + * payload `notify()` declares — so a second spelling of the text is red; + * - the battery reaches every call site that can notify anyone — eleven of + * the twelve — so neither pin passes over a site it never drove. + * + * The twelfth, the reminder to a slot literal (`position:

`), never reaches + * messaging at all: `notify()` drops every audience entry that is a slot + * address, and that reminder's audience is nothing else. Its payload is held + * to the same declared shape by the compiler, not by this battery. + * + * The end-to-end half — the text read back from the recipient's + * `GET /api/v1/notifications` through the real messaging service — is the + * dogfood pin `packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; +import { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string, positions: string[] = []) => + ({ userId, positions, permissions: [] }) as any; + +const USERS = [ + { id: 'u_sub', name: 'Sam Submitter', email: 'sam@example.com' }, + { id: 'u_legal', name: 'Lee Legal', email: 'lee@example.com' }, + { id: 'u_alice', name: 'Alice', email: 'alice@example.com' }, + { id: 'u_bob', name: 'Bob', email: 'bob@example.com' }, + { id: 'u_ooo', name: 'Olive Away', email: 'olive@example.com' }, + { id: 'u_boss', name: 'Boss', email: 'boss@example.com' }, +]; + +/** The text each person writes — distinct, so no one can pass for another. */ +const COMMENT = 'The signed contract is attached to the record now.'; +const QUESTION = 'Which cost centre should this be booked against?'; +const SEND_BACK = 'Split the total by quarter, please.'; + +/** The fields `notify()` declares for a notification's payload. */ +const PAYLOAD_FIELDS = new Set(['title', 'body', 'actionUrl', 'actions']); + +const sysUser = { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + email: { name: 'email', label: 'Email', type: 'text' as const }, + }, +}; +const deal = { + name: 'crm_deal', + label: 'Deal', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + }, +}; + +/** The flow the requests belong to: an approval node with a revise window. */ +const FLOW = { + name: 'deal_review', + nodes: [ + { id: 'review', type: 'approval' }, + { id: 'rework', type: APPROVAL_REVISE_NODE_TYPE }, + ], + edges: [{ id: 'e_revise', source: 'review', target: 'rework', label: 'revise' }], +}; + +/** The plugin's own object definitions, as its manifest registers them. */ +async function packageObjects(): Promise { + let manifest: any; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: any) => { manifest = m; } }; + throw new Error(`no service '${name}'`); + }, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }; + await new ApprovalsServicePlugin().init(ctx); + return manifest.objects; +} + +interface Emitted { + topic: string; + audience: string[]; + source?: { object: string; id: string }; + payload?: Record; +} + +describe('every approval notification carries its text in body', () => { + let engine: ObjectQL; + let svc: ApprovalService; + let now = Date.parse('2026-10-01T09:00:00.000Z'); + const emits: Emitted[] = []; + const ids: Record = {}; + + const open = async (key: string, approvers: any[], extra: Record = {}) => { + await engine.insert('crm_deal', { id: `D_${key}`, title: key }, { context: SYSTEM } as any); + const row: any = await svc.openNodeRequest({ + object: 'crm_deal', recordId: `D_${key}`, runId: `run_${key}`, nodeId: 'review', flowName: FLOW.name, + config: { approvers, behavior: 'first_response', ...extra } as any, + submitterId: 'u_sub', record: { id: `D_${key}`, title: key }, + }, asUser('u_sub')); + ids[key] = row.id; + return row; + }; + + /** Which call site an emit came from: its topic, its request, and what tells two sites on one request apart. */ + const siteOf = (e: Emitted): string => { + const key = Object.keys(ids).find((k) => ids[k] === e.source?.id) ?? '?'; + const links = Array.isArray(e.payload?.actions) ? '+links' : ''; + const toSubmitter = e.topic === 'approval.sla_breached' && e.audience.join() === 'u_sub' ? '>submitter' : ''; + return `${e.topic}@${key}${links}${toSubmitter}`; + }; + + beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + for (const def of [sysUser, deal, ...(await packageObjects())]) { + engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); + } + await engine.syncSchemas(); + for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); + + svc = new ApprovalService({ + engine: engine as any, + // Every reading moves a second on, so the action log has one order. + clock: { now: () => new Date(now += 1000) }, + automation: { + resume: async () => ({ status: 'completed' }), + cancelRun: async () => undefined, + getRun: async (runId: string) => ({ id: runId, status: 'paused' }), + getFlow: async () => FLOW, + } as any, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any, + }); + svc.attachMessaging({ async emit(input) { emits.push(input as Emitted); } }); + + // ── The thread: a position slot (a literal no account holds) and a person ── + const thread = await open('thread', [{ type: 'position', value: 'legal' }, { type: 'user', value: 'u_alice' }], + { behavior: 'unanimous' }); + expect(thread.pending_approvers).toEqual(['position:legal', 'u_alice']); + await svc.comment(ids.thread, { actorId: 'u_sub', comment: COMMENT }, asUser('u_sub')); + await svc.requestInfo(ids.thread, { actorId: 'u_alice', comment: QUESTION }, asUser('u_alice')); + // A remind reaches the person with their own action links. + await svc.remind(ids.thread, { actorId: 'u_sub' }, asUser('u_sub')); + await svc.reassign(ids.thread, { actorId: 'u_alice', to: 'u_bob' }, asUser('u_alice')); + + // ── Send back, within budget and past it ── + await open('revise', [{ type: 'user', value: 'u_alice' }]); + await svc.sendBack(ids.revise, { actorId: 'u_alice', comment: SEND_BACK } as any, asUser('u_alice')); + await open('budget', [{ type: 'user', value: 'u_alice' }], { maxRevisions: 0 }); + await svc.sendBack(ids.budget, { actorId: 'u_alice' } as any, asUser('u_alice')); + + // ── Out of office: the routing substitutes a delegate ── + await engine.insert('sys_approval_delegation', { + id: 'dlg_1', delegator_id: 'u_ooo', delegate_id: 'u_bob', reason: 'leave', + valid_from: '2026-09-01T00:00:00.000Z', valid_until: '2026-12-31T00:00:00.000Z', + created_at: '2026-09-01T00:00:00.000Z', updated_at: '2026-09-01T00:00:00.000Z', + }, { context: SYSTEM } as any); + const ooo = await open('ooo', [{ type: 'user', value: 'u_ooo' }]); + expect(ooo.pending_approvers).toEqual(['u_bob']); + + // ── The SLA sweep: a notify (submitter told) and a reassign (submitter not) ── + await open('sla_notify', [{ type: 'user', value: 'u_alice' }], + { escalation: { timeoutHours: 1, action: 'notify', escalateTo: 'u_boss', notifySubmitter: true } }); + await open('sla_reassign', [{ type: 'user', value: 'u_alice' }], + { escalation: { timeoutHours: 1, action: 'reassign', escalateTo: 'u_boss', notifySubmitter: false } }); + now += 2 * 3600_000; + expect(await svc.runEscalations()).toMatchObject({ escalated: 2 }); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the battery reaches every call site that can notify anyone — eleven of the twelve', () => { + expect(new Set(emits.map(siteOf))).toEqual(new Set([ + 'approval.ooo_substituted@ooo', + 'approval.ooo_skipped@ooo', + 'approval.returned@budget', + 'approval.returned@revise', + 'approval.reassigned@thread', + 'approval.reminder@thread+links', + 'approval.request_info@thread', + 'approval.comment@thread', + 'approval.escalated@sla_reassign', + 'approval.sla_breached@sla_notify', + 'approval.sla_breached@sla_notify>submitter', + ])); + }); + + it("a comment, a request-info question and a send-back note are their recipient's body", () => { + const one = (topic: string, key: string) => { + const hits = emits.filter((e) => e.topic === topic && e.source?.id === ids[key]); + expect(hits.length, `${topic} on ${key}`).toBe(1); + return hits[0]; + }; + const comment = one('approval.comment', 'thread'); + expect([comment.audience, comment.payload?.body]).toEqual([['u_alice'], COMMENT]); + const question = one('approval.request_info', 'thread'); + expect([question.audience, question.payload?.body]).toEqual([['u_sub'], QUESTION]); + const sentBack = one('approval.returned', 'revise'); + expect([sentBack.audience, sentBack.payload?.body]).toEqual([['u_sub'], SEND_BACK]); + }); + + it('every notification carries a non-empty body, and no field the payload does not declare', () => { + const offenders: string[] = []; + for (const e of emits) { + const payload = e.payload ?? {}; + if (typeof payload.body !== 'string' || payload.body.trim() === '') { + offenders.push(`${siteOf(e)}: body is ${JSON.stringify(payload.body)}`); + } + for (const field of Object.keys(payload)) { + if (!PAYLOAD_FIELDS.has(field)) offenders.push(`${siteOf(e)}: undeclared payload field '${field}'`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts b/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts new file mode 100644 index 00000000000..fdd18e49945 --- /dev/null +++ b/packages/qa/dogfood/test/approval-notification-body.dogfood.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// An approval notification reaches its recipient WITH its text. +// +// Every approval notification arrived as a title over an empty body. The +// approvals service put its text in `payload.message`, and the messaging +// service builds the delivered notification from `payload.title` and +// `payload.body` only — the field its `EmitInput` documents — so the comment, +// the request-info question and every escalation line were dropped on the way +// to the inbox: `GET /api/v1/notifications` answered `body: ""` under a +// correct title. The flow `notify` node and the @mention producer already send +// `body`; approvals was the one producer speaking another dialect. +// +// The fix is at the producer, with no alias in messaging: one field, as +// documented. This pins it at the door a recipient actually reads, on a booted +// app with the real approvals + messaging + REST chain: +// +// ⭐ a submitter's comment is read back as the BODY of the approver's +// `approval.comment` notification; +// ⭐ an approver's request-info question is read back as the BODY of the +// submitter's `approval.request_info` notification; +// ⭐ each is the ONE notification of its topic in that inbox and carries its +// title — so an empty body is a body that lost its text, never a +// notification that never arrived (the control the defect needed: its +// title was always there). +// +// The fixture is owned by this pin and kept inline: one object whose create +// opens a request routed to a `user` approver authored as an EMAIL, so the +// approver is a real account reached through the address an author writes. +// Messaging runs its inline fan-out (`reliableDelivery: false`) so the inbox +// row exists when the thread call returns; the outbox path builds the same +// notification from the same `payload.body` (`messaging-service.ts` / +// `dispatcher.ts`), and the producer-side pin across the service's call sites +// is `plugin-approvals/src/approval-notification-body.integration.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { bootStack } from '@objectstack/verify'; +import { defineStack, defineFlow } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; + +const OBJECT = 'anb_request'; +const SUBMITTER = 'anb-submitter@example.com'; +const REVIEWER = 'anb-reviewer@example.com'; + +/** The text each side writes — distinct, so neither can pass for the other. */ +const COMMENT = 'The signed contract is attached to the record now.'; +const QUESTION = 'Which cost centre should this be booked against?'; + +const AnbRequest = ObjectSchema.create({ + name: OBJECT, + label: 'Notification Body Request', + pluralLabel: 'Notification Body Requests', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +const AnbFlow = defineFlow({ + name: 'anb_flow', + label: 'Notification Body Flow', + description: 'Fires on insert and routes to one user approver, authored as an email.', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'On Create', config: { objectName: OBJECT, triggerType: 'record-after-create' } }, + { + id: 'review', + type: 'approval', + label: 'Review', + config: { approvers: [{ type: 'user', value: REVIEWER }], behavior: 'first_response' }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'review' }, + { id: 'e2', source: 'review', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'review', target: 'rejected', label: 'reject' }, + ], +}); + +const anbStack = defineStack({ + manifest: { + id: 'com.dogfood.approval-notification-body', + namespace: 'anb', + version: '0.0.0', + type: 'app', + name: 'Approval Notification Body Fixture', + description: 'One object, one flow routed to an email-authored user approver.', + }, + // ADR-0097: the `record-after-create` start node needs the trigger pair + // declared; the pin mounts both explicitly. + requires: ['automation', 'triggers'], + objects: [AnbRequest], + flows: [AnbFlow], +}); + +/** Every fresh member resolves to create + read on the request object only. */ +const anbMemberSet = PermissionSetSchema.parse({ + name: 'anb_member', + label: 'Notification Body Member — create + read on anb_request only', + objects: { + [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false }, + }, +}); + +interface InboxRow { + id: string; + type: string; + title: string; + body: string; +} + +describe('an approval notification reaches its recipient with its text in the body', () => { + it( + "a comment and a request-info question are read back from the recipient's GET /api/v1/notifications", + async () => { + const stack = await bootStack(anbStack as unknown as Parameters[0], { + automation: true, + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, anbMemberSet], + fallbackPermissionSet: anbMemberSet.name, + }), + extraPlugins: [ + new MessagingServicePlugin({ reliableDelivery: false }), + new RecordChangeTriggerPlugin(), + new ApprovalsServicePlugin(), + ], + }); + try { + const submitterToken = await stack.signUp(SUBMITTER); + const reviewerToken = await stack.signUp(REVIEWER); + + const created = await stack.apiAs(submitterToken, 'POST', `/data/${OBJECT}`, { name: 'contract' }); + expect(created.status).toBe(201); + + // The request opened, on the email slot — a missing request would + // otherwise read below as "no notification", not as the defect. + const pending = await stack.apiAs(reviewerToken, 'GET', `/approvals/requests?${new URLSearchParams({ + status: 'pending', approverId: REVIEWER, + })}`); + expect(pending.status).toBe(200); + const rows = ((await pending.json()) as { data: Array<{ id: string; pending_approvers?: string[] }> }).data; + expect(rows.map((r) => r.pending_approvers)).toEqual([[REVIEWER]]); + const requestId = rows[0].id; + + const inboxOf = async (token: string, topic: string): Promise => { + const res = await stack.apiAs(token, 'GET', `/notifications?${new URLSearchParams({ type: topic })}`); + expect(res.status, `GET /notifications?type=${topic}`).toBe(200); + const json = (await res.json()) as { data: { notifications: InboxRow[] } }; + return json.data.notifications; + }; + + // ⭐ The submitter comments; the approver's notification carries it. + const commented = await stack.apiAs(submitterToken, 'POST', `/approvals/requests/${requestId}/comment`, { + comment: COMMENT, + }); + expect(commented.status).toBe(200); + const toReviewer = await inboxOf(reviewerToken, 'approval.comment'); + expect(toReviewer.length, "the approver's inbox holds the one comment notification").toBe(1); + expect(toReviewer[0].title).not.toBe(''); + expect(toReviewer[0].body, "the comment is the notification's body").toBe(COMMENT); + + // ⭐ The approver asks; the submitter's notification carries the question. + const asked = await stack.apiAs(reviewerToken, 'POST', `/approvals/requests/${requestId}/request-info`, { + comment: QUESTION, + }); + expect(asked.status).toBe(200); + const toSubmitter = await inboxOf(submitterToken, 'approval.request_info'); + expect(toSubmitter.length, "the submitter's inbox holds the one request-info notification").toBe(1); + expect(toSubmitter[0].title).not.toBe(''); + expect(toSubmitter[0].body, "the question is the notification's body").toBe(QUESTION); + + // Each side reads only what was addressed to it: the submitter wrote + // the comment and is not its recipient, and the reverse. + expect(await inboxOf(submitterToken, 'approval.comment')).toEqual([]); + expect(await inboxOf(reviewerToken, 'approval.request_info')).toEqual([]); + } finally { + await stack.stop(); + } + }, + 120_000, + ); +}); From 7ca65ce74c1c49489492d687ad96f603257b502d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:29:00 +0000 Subject: [PATCH 2/2] fix(plugin-approvals): approval notifications carry their text in body, the field messaging reads Every notification the approvals service sent put its text in payload.message. The messaging service builds the delivered notification from payload.title and payload.body only (the inline fan-out, the outbox snapshot and the dispatcher alike), so GET /api/v1/notifications served each one as a title over an empty body. - The twelve call sites now send the text as body. The texts themselves are byte-identical; who is notified, and when, is unchanged. - notify() declares its payload (title, body, actionUrl, and a reminder's actions), so a call site that spells the text any other way does not compile. - No alias in service-messaging: one field, as EmitInput documents. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../21847-approval-notification-body.md | 12 +++++ .../plugin-approvals/src/approval-service.ts | 50 +++++++++++++------ 2 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 .changeset/21847-approval-notification-body.md diff --git a/.changeset/21847-approval-notification-body.md b/.changeset/21847-approval-notification-body.md new file mode 100644 index 00000000000..e21712a7704 --- /dev/null +++ b/.changeset/21847-approval-notification-body.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +Approval notifications reach their recipient with their text (#21847). The approvals service put each notification's text in `payload.message`. The messaging service builds the delivered notification from `payload.title` and `payload.body`, the fields its `EmitInput` documents, and no channel reads `message`. So `GET /api/v1/notifications` served every approval notification as a title over an empty `body`, and the inbox row's `body_md` was empty too. The lost texts were comments, request-info questions, send-back notes, reassignments, reminders, escalations, SLA breaches and out-of-office substitutions. Every one of them now travels in `payload.body`. + +Clause-②: no + +- The texts are unchanged. Only the field they travel in moved. Who is notified, and when, is unchanged. +- Approval notifications no longer carry `payload.message`. Nothing in the platform or the console read it. A tenant-authored `sys_notification_template` for an `approval.*` topic that wrote `{{ message }}` should write `{{ body }}`. +- The service's notify helper now declares its payload: `title`, `body`, `actionUrl`, and a reminder's `actions`. A call site that spells the text any other way no longer compiles. +- `@objectstack/service-messaging` is unchanged. There is one field, as documented, and no alias for the old one. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 6ea9ca90046..9a1822845a2 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -280,6 +280,28 @@ export interface ApprovalMessagingSurface { }): Promise; } +/** + * What an approval notification hands to the messaging service: the + * `EmitInput` payload fields the delivered notification is built from, plus a + * reminder's one-tap decision links. + * + * `body` is the text the recipient reads under the title. Messaging builds the + * inbox row's `body_md`, and each channel's fallback rendering, from + * `payload.body` and no other field, so the text is declared here rather than + * left to a free-form bag: a call site that spells it any other way does not + * compile. Spelled `message`, it once reached every recipient as an empty body. + */ +type ApprovalNotificationPayload = { + /** The headline. */ + readonly title: string; + /** The text under the headline. */ + readonly body: string; + /** Where opening it lands; `notify()` narrows the approvals inbox to the request. */ + readonly actionUrl: string; + /** Approve / Reject links on a reminder to a concrete approver (ADR-0043). */ + readonly actions?: ReadonlyArray<{ readonly label: string; readonly url: string }>; +}; + /** Minimum time between submitter reminders on one request. */ export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000; @@ -1373,7 +1395,7 @@ export class ApprovalService implements IApprovalService { private async notify(input: { topic: string; audience: string[]; - payload?: Record; + payload: ApprovalNotificationPayload; dedupKey?: string; source?: { object: string; id: string }; actorContext?: ExecutionContext; @@ -1388,7 +1410,7 @@ export class ApprovalService implements IApprovalService { // the console inbox to auto-open the drawer. let payload = event.payload; if ( - payload?.actionUrl === '/system/approvals' + payload.actionUrl === '/system/approvals' && event.source?.object === 'sys_approval_request' && event.source.id ) { @@ -3073,7 +3095,7 @@ export class ApprovalService implements IApprovalService { 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.`, + body: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`, actionUrl: '/system/approvals', }, }); @@ -3084,7 +3106,7 @@ export class ApprovalService implements IApprovalService { 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.`, + body: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`, actionUrl: '/system/approvals', }, }); @@ -4206,7 +4228,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: requestId }, payload: { title: 'Approval auto-rejected', - message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`, + body: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`, actionUrl: '/system/approvals', }, }); @@ -4249,7 +4271,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: requestId }, payload: { title: 'Sent back for revision', - message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`, + body: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`, actionUrl: '/system/approvals', }, }); @@ -4509,7 +4531,7 @@ export class ApprovalService implements IApprovalService { dedupKey: `approval-reassign-${requestId}-${to}`, payload: { title: 'Approval handed to you', - message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`, + body: `You are now an approver on ${raw.object_name}/${raw.record_id}.`, actionUrl: '/system/approvals', }, }); @@ -4568,7 +4590,7 @@ export class ApprovalService implements IApprovalService { dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`, payload: { title: 'Approval reminder', - message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`, + body: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`, actionUrl: '/system/approvals', actions: [ { label: 'Approve', url: this.actionLinkUrl(tokens.approve) }, @@ -4591,7 +4613,7 @@ export class ApprovalService implements IApprovalService { dedupKey: `approval-remind-${requestId}-${nowIso}`, payload: { title: 'Approval reminder', - message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`, + body: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`, actionUrl: '/system/approvals', }, }); @@ -4785,7 +4807,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: requestId }, payload: { title: 'More information requested', - message: input.comment.trim(), + body: input.comment.trim(), actionUrl: '/system/approvals', }, }); @@ -4832,7 +4854,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: requestId }, payload: { title: 'New comment on an approval', - message: input.comment.trim(), + body: input.comment.trim(), actionUrl: '/system/approvals', }, }); @@ -5877,7 +5899,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: raw.id }, payload: { title: 'Approval escalated to you', - message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`, + body: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`, actionUrl: '/system/approvals', }, }); @@ -5895,7 +5917,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: raw.id }, payload: { title: 'Approval SLA breached', - message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`, + body: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`, actionUrl: '/system/approvals', }, }); @@ -5908,7 +5930,7 @@ export class ApprovalService implements IApprovalService { source: { object: 'sys_approval_request', id: raw.id }, payload: { title: 'Your approval request breached its SLA', - message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`, + body: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`, actionUrl: '/system/approvals', }, });