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-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/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', }, }); 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, + ); +});