From aac05ec9e79e03e1ce4e861aa247fb658f7453e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:51:20 +0000 Subject: [PATCH 1/5] wip(service-messaging,plugin-approvals): keyed notification text rendered per recipient at delivery Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/approval-notification-text.ts | 137 +++++++ .../plugin-approvals/src/approval-service.ts | 189 ++++++---- .../src/translations/index.ts | 18 +- .../src/translations/messages.ts | 178 +++++++++ .../service-messaging/src/dispatcher.ts | 60 ++- .../src/fan-out-system-context.ts | 4 + .../services/service-messaging/src/index.ts | 15 + .../src/messaging-service-plugin.ts | 35 +- .../src/messaging-service.ts | 88 ++++- .../src/notification-text.ts | 351 ++++++++++++++++++ .../services/service-messaging/src/outbox.ts | 9 + 11 files changed, 990 insertions(+), 94 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/approval-notification-text.ts create mode 100644 packages/plugins/plugin-approvals/src/translations/messages.ts create mode 100644 packages/services/service-messaging/src/notification-text.ts diff --git a/packages/plugins/plugin-approvals/src/approval-notification-text.ts b/packages/plugins/plugin-approvals/src/approval-notification-text.ts new file mode 100644 index 00000000000..db4cef79ac5 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-notification-text.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24978] An approval notification's text, as the messaging service takes it: + * the literal English every emit has always carried, and BESIDE it the catalog + * keys the service renders per recipient, at delivery, in that recipient's + * language (`EmitInput.keyedText`). + * + * ## What was wrong + * + * Each notification sentence was built here, at emit, as an English literal, + * and no catalog held it — so a zh-CN, ja-JP or es-ES approver read English. + * A notification reaches several recipients and the emit holds none of them, + * so the language cannot be picked here: the render belongs where one + * recipient exists, which is delivery, and the messaging service owns that + * (the `i18n` service's catalog, `sys_user.locale` then the deployment default + * per recipient, #13881). This module hands it the keys. + * + * ## One source for the English + * + * The literal `title` / `body` are the `en` catalog entries + * (`./translations/messages.ts`) interpolated here, ⛔ never a second spelling + * of the sentence: the English a recipient reads is one string whether the + * catalog rendered it or the literal fallback carried it, and it is the + * sentence each notification carried before it was keyed. + * + * ## The record a notification names + * + * `object` and `record` ({@link subjectParams}): the object's label and the + * record id, the #23556 / #23594 ruling B form, never a title. The label rides + * as a keyed param — `objects..label`, the key the reader-language + * labels (`reader-locale-labels.ts`) and objectql's refusals name an object + * by — so a recipient whose language pack holds it reads it in their language; + * the schema label (the API name when there is none) stands otherwise. + */ + +import { objectLabelKey } from '@objectstack/spec/system'; +import { enMessages, type ApprovalMessageId } from './translations/messages.js'; + +/** One `{{hole}}` value: the text itself, or a catalog key with the text it falls back to. */ +export type ApprovalTextParam = string | { readonly key: string; readonly fallback: string }; + +/** + * The keyed half — the structural mirror of the messaging service's + * `NotificationTextKeys` (`EmitInput.keyedText`). Declared here rather than + * imported because this plugin takes no dependency on the messaging package; + * it resolves whatever is registered as `messaging` (`ApprovalMessagingSurface`). + */ +export interface ApprovalKeyedText { + readonly titleKey: string; + readonly bodyKey?: string; + readonly params?: Readonly>; +} + +/** An approval notification's text: the literal fallback and its keys. */ +export interface ApprovalNotificationText { + /** The literal headline and body — the `en` entries, interpolated. */ + readonly title: string; + readonly body: string; + readonly keyedText: ApprovalKeyedText; +} + +/** The record an approval notification names: `{{object}} {{record}}`. */ +export interface ApprovalSubjectParams { + readonly object: { readonly key: string; readonly fallback: string }; + readonly record: string; +} + +/** The `II18nService` key of an approval sentence, `messages.`. */ +export function approvalMessageKey(id: ApprovalMessageId): string { + return `messages.${id}`; +} + +/** + * The record `objectName`/`recordId` names: the object's label — keyed by + * `objects..label`, falling back to `objectLabel` (the schema label), + * else the API name — and the record id. + */ +export function subjectParams( + objectName: string, + objectLabel: string | undefined, + recordId: string, +): ApprovalSubjectParams { + return { + object: { key: objectLabelKey(objectName), fallback: objectLabel ?? objectName }, + record: recordId, + }; +} + +const HOLE = /\{\{(\w+)\}\}/g; + +/** + * `{{name}}` interpolation with the convention both `II18nService.t()` + * implementations use — one pass, a value is never re-scanned, a hole with no + * value stays as written — over each param's literal text. + */ +function interpolate(template: string, params: Readonly>): string { + return template.replace(HOLE, (hole, name: string) => { + const param = params[name]; + if (param === undefined) return hole; + return typeof param === 'string' ? param : param.fallback; + }); +} + +/** + * An approval notification's text from its catalog ids. + * + * `body` names the body's sentence; `literalBody` is for the notices whose + * body is a person's own words (a comment, a request for information) — that + * text is delivered as written, and only the headline is keyed. + */ +export function approvalNotificationText(spec: { + readonly title: ApprovalMessageId; + readonly body: ApprovalMessageId; + readonly params?: Readonly>; +} | { + readonly title: ApprovalMessageId; + readonly literalBody: string; +}): ApprovalNotificationText { + if ('literalBody' in spec) { + return { + title: enMessages[spec.title], + body: spec.literalBody, + keyedText: { titleKey: approvalMessageKey(spec.title) }, + }; + } + const params = spec.params ?? {}; + return { + title: interpolate(enMessages[spec.title], params), + body: interpolate(enMessages[spec.body], params), + keyedText: { + titleKey: approvalMessageKey(spec.title), + bodyKey: approvalMessageKey(spec.body), + ...(spec.params ? { params: spec.params } : {}), + }, + }; +} diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 1cfa9e72bdf..e9515e9648a 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -109,6 +109,13 @@ import { APPROVAL_REQUEST_CHILD_OBJECTS, type RequestVisibilitySource } from './ // The ADR-0043 pages — the ONE copy of their text, rendered by the plugin's // self-hosted mount and by `handleActionPage` alike. import { renderConfirmPage, renderResultPage } from './action-link-pages.js'; +import { + approvalNotificationText, + subjectParams, + type ApprovalKeyedText, + type ApprovalNotificationText, + type ApprovalSubjectParams, +} from './approval-notification-text.js'; /** * Node-era approval runtime (ADR-0019). @@ -334,6 +341,13 @@ export interface ApprovalMessagingSurface { * stored with none. */ organizationId?: string; + /** + * [#24978] The text as catalog keys (`EmitInput.keyedText` of the + * messaging service), rendered per recipient at delivery in that + * recipient's language. `payload.title` / `payload.body` stay the literal + * fallback beside it. + */ + keyedText?: ApprovalKeyedText; }): Promise; } @@ -366,6 +380,22 @@ type ApprovalNotificationPayload = { readonly actionUrl: string; }; +/** + * [#24978] An approval notification's payload and its keys, from the ONE + * {@link approvalNotificationText} call that builds both: the literal `title` / + * `body` the payload has always carried, and beside it the catalog keys the + * messaging service renders per recipient at delivery. + */ +function approvalNotice(text: ApprovalNotificationText): { + payload: ApprovalNotificationPayload; + keyedText: ApprovalKeyedText; +} { + return { + payload: { title: text.title, body: text.body, actionUrl: '/system/approvals' }, + keyedText: text.keyedText, + }; +} + /** Minimum time between submitter reminders on one request. */ export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000; @@ -1785,6 +1815,14 @@ export class ApprovalService implements IApprovalService { * approver acting from another organization does not move the request. */ organizationId: string | null | undefined; + /** + * [#24978] The text as catalog keys, from {@link approvalNotificationText} + * — the same call that builds `payload.title` / `payload.body`, so the + * literal and its keys cannot name two different sentences. REQUIRED, so + * no call site can send an approval notification that only ever reads in + * English. + */ + keyedText: ApprovalKeyedText; dedupKey?: string; source?: { object: string; id: string }; actorContext?: ExecutionContext; @@ -3835,17 +3873,21 @@ export class ApprovalService implements IApprovalService { comment: `${sub.from} → ${sub.to}${sub.reason ? ` — ${sub.reason}` : ''}`, created_at: now, }), { context: SYSTEM_CTX }); + const covering = approvalNotificationText({ + title: 'approvalOooSubstitutedTitle', body: 'approvalOooSubstitutedBody', + params: { ...this.nameSubjectRecord(input.object, input.recordId), person: oooPerson(sub.from) }, + }); await this.notify({ topic: 'approval.ooo_substituted', organizationId: requestOrg, audience: [sub.to], source: { object: 'sys_approval_request', id }, dedupKey: `approval-ooo-${id}-${sub.to}`, - payload: { - title: 'Approval routed to you (out-of-office cover)', - body: `You are covering an approval on ${this.nameSubjectRecord(input.object, input.recordId)} while ${oooPerson(sub.from)} is out of office.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(covering), + }); + const skipped = approvalNotificationText({ + title: 'approvalOooSkippedTitle', body: 'approvalOooSkippedBody', + params: { ...this.nameSubjectRecord(input.object, input.recordId), person: oooPerson(sub.to) }, }); await this.notify({ topic: 'approval.ooo_skipped', @@ -3853,11 +3895,7 @@ export class ApprovalService implements IApprovalService { audience: [sub.from], source: { object: 'sys_approval_request', id }, dedupKey: `approval-ooo-skip-${id}-${sub.from}`, - payload: { - title: 'Approval routed to your delegate', - body: `An approval on ${this.nameSubjectRecord(input.object, input.recordId)} was routed to ${oooPerson(sub.to)} while you are out of office.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(skipped), }); } @@ -3910,7 +3948,13 @@ export class ApprovalService implements IApprovalService { // reader which kind of record is waiting. One name for every recipient. const toldByOoo = new Set(substitutions.map((sub) => sub.to)); const openedOn = new Set(approvers.filter((a) => a && !a.includes(':') && !toldByOoo.has(a))); - const subject = this.nameSubjectRecord(input.object, input.recordId); + // + // [#24978] And it rides as catalog keys beside that literal, so each + // approver reads it in their own language, rendered at delivery. + const requested = approvalNotificationText({ + title: 'approvalRequestedTitle', body: 'approvalRequestedBody', + params: this.nameSubjectRecord(input.object, input.recordId), + }); for (const approver of openedOn) { await this.notify({ topic: 'approval.requested', @@ -3919,11 +3963,7 @@ export class ApprovalService implements IApprovalService { actorContext: context, source: { object: 'sys_approval_request', id }, dedupKey: `approval-requested-${id}-${approver}`, - payload: { - title: 'Approval requested', - body: `A decision on ${subject} is waiting on you.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(requested), }); } @@ -4652,19 +4692,24 @@ export class ApprovalService implements IApprovalService { if (nextStepOpened) return; } const approved = decision === 'approve'; - const sentence = `Your ${this.nameSubjectRecord(request.object_name, request.record_id)} was ${approved ? 'approved' : 'rejected'}.`; + const subject = this.nameSubjectRecord(request.object_name, request.record_id); const note = comment?.trim(); + // [#24978] The decider's comment, when there is one, rides under the + // sentence as written — a hole of its own, never translated. + const outcome = approvalNotificationText({ + title: approved ? 'approvalApprovedTitle' : 'approvalRejectedTitle', + body: note + ? (approved ? 'approvalApprovedWithCommentBody' : 'approvalRejectedWithCommentBody') + : (approved ? 'approvalApprovedBody' : 'approvalRejectedBody'), + params: note ? { ...subject, comment: note } : subject, + }); await this.notify({ topic: approved ? 'approval.approved' : 'approval.rejected', organizationId: request.organization_id, audience: [submitter], actorContext: context, source: { object: 'sys_approval_request', id: request.id }, - payload: { - title: approved ? 'Request approved' : 'Request rejected', - body: note ? `${sentence}\n\n${note}` : sentence, - actionUrl: '/system/approvals', - }, + ...approvalNotice(outcome), }); } @@ -5139,17 +5184,17 @@ export class ApprovalService implements IApprovalService { resumeError = outcome.resumeError; } if (raw.submitter_id) { + const autoRejected = approvalNotificationText({ + title: 'approvalAutoRejectedTitle', body: 'approvalAutoRejectedBody', + params: { ...subject, limit: String(maxRevisions) }, + }); await this.notify({ topic: 'approval.returned', organizationId: raw.organization_id, audience: [String(raw.submitter_id)], actorContext: context, source: { object: 'sys_approval_request', id: requestId }, - payload: { - title: 'Approval auto-rejected', - body: `Your ${subject} exceeded the revision limit (${maxRevisions}) and was rejected.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(autoRejected), }); } const fresh = await this.readBackRequest(requestId, context); @@ -5183,17 +5228,18 @@ export class ApprovalService implements IApprovalService { } if (raw.submitter_id) { + // [#24978] The approver's own words, when they wrote some, are the body + // as written; only the headline is keyed then. + const sentBack = input.comment?.trim() + ? approvalNotificationText({ title: 'approvalReturnedTitle', literalBody: input.comment.trim() }) + : approvalNotificationText({ title: 'approvalReturnedTitle', body: 'approvalReturnedBody', params: subject }); await this.notify({ topic: 'approval.returned', organizationId: raw.organization_id, audience: [String(raw.submitter_id)], actorContext: context, source: { object: 'sys_approval_request', id: requestId }, - payload: { - title: 'Sent back for revision', - body: input.comment?.trim() || `Your ${subject} needs rework before it can be approved.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(sentBack), }); } @@ -5444,6 +5490,10 @@ export class ApprovalService implements IApprovalService { }, { context: SYSTEM_CTX }); await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now); + const handedOver = approvalNotificationText({ + title: 'approvalReassignedTitle', body: 'approvalReassignedBody', + params: this.nameSubjectRecord(raw.object_name, raw.record_id), + }); await this.notify({ topic: 'approval.reassigned', organizationId: raw.organization_id, @@ -5451,11 +5501,7 @@ export class ApprovalService implements IApprovalService { actorContext: context, source: { object: 'sys_approval_request', id: requestId }, dedupKey: `approval-reassign-${requestId}-${to}`, - payload: { - title: 'Approval handed to you', - body: `You are now an approver on ${this.nameSubjectRecord(raw.object_name, raw.record_id)}.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(handedOver), }); const fresh = await this.readBackRequest(requestId, context); @@ -5518,7 +5564,10 @@ export class ApprovalService implements IApprovalService { // reminders handed over: one per distinct address. A user id and the same // account's email are two addresses here, exactly as at the opening — two // slots the person takes separately, and two reminders delivered. - const subject = this.nameSubjectRecord(raw.object_name, raw.record_id); + const reminder = approvalNotificationText({ + title: 'approvalReminderTitle', body: 'approvalReminderBody', + params: this.nameSubjectRecord(raw.object_name, raw.record_id), + }); let notified = 0; const concrete = new Set(pending.filter(a => a && !a.includes(':'))); const literals = pending.filter(a => a && a.includes(':')); @@ -5530,11 +5579,7 @@ export class ApprovalService implements IApprovalService { actorContext: context, source: { object: 'sys_approval_request', id: requestId }, dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`, - payload: { - title: 'Approval reminder', - body: `A decision on ${subject} is still waiting on you.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(reminder), }); } if (literals.length) { @@ -5545,11 +5590,7 @@ export class ApprovalService implements IApprovalService { actorContext: context, source: { object: 'sys_approval_request', id: requestId }, dedupKey: `approval-remind-${requestId}-${nowIso}`, - payload: { - title: 'Approval reminder', - body: `A decision on ${subject} is still waiting on you.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(reminder), }); } @@ -5800,11 +5841,9 @@ export class ApprovalService implements IApprovalService { audience: [String(raw.submitter_id)], actorContext: context, source: { object: 'sys_approval_request', id: requestId }, - payload: { - title: 'More information requested', - body: input.comment.trim(), - actionUrl: '/system/approvals', - }, + ...approvalNotice(approvalNotificationText({ + title: 'approvalRequestInfoTitle', literalBody: input.comment.trim(), + })), }); } @@ -5848,11 +5887,9 @@ export class ApprovalService implements IApprovalService { audience, actorContext: context, source: { object: 'sys_approval_request', id: requestId }, - payload: { - title: 'New comment on an approval', - body: input.comment.trim(), - actionUrl: '/system/approvals', - }, + ...approvalNotice(approvalNotificationText({ + title: 'approvalCommentTitle', literalBody: input.comment.trim(), + })), }); const fresh = await this.readBackRequest(requestId, context); @@ -6905,11 +6942,9 @@ export class ApprovalService implements IApprovalService { organizationId: raw.organization_id, audience: escalatees, source: { object: 'sys_approval_request', id: raw.id }, - payload: { - title: 'Approval escalated to you', - body: `An overdue approval on ${subject} was escalated to you.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(approvalNotificationText({ + title: 'approvalEscalatedTitle', body: 'approvalEscalatedBody', params: subject, + })), }); } else if (action === 'auto_approve' || action === 'auto_reject') { // [#24753] The submitter hears this outcome from the submitter notice @@ -6927,11 +6962,9 @@ export class ApprovalService implements IApprovalService { organizationId: raw.organization_id, audience: [...pending, ...escalatees], source: { object: 'sys_approval_request', id: raw.id }, - payload: { - title: 'Approval SLA breached', - body: `A decision on ${subject} is overdue.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(approvalNotificationText({ + title: 'approvalSlaBreachedTitle', body: 'approvalSlaBreachedBody', params: subject, + })), }); } @@ -6941,11 +6974,10 @@ export class ApprovalService implements IApprovalService { organizationId: raw.organization_id, audience: [String(raw.submitter_id)], source: { object: 'sys_approval_request', id: raw.id }, - payload: { - title: 'Your approval request breached its SLA', - body: `${subject}: escalation action '${action}' was taken.`, - actionUrl: '/system/approvals', - }, + ...approvalNotice(approvalNotificationText({ + title: 'approvalSlaBreachedSubmitterTitle', body: 'approvalSlaBreachedSubmitterBody', + params: { ...subject, action }, + })), }); } } @@ -7411,11 +7443,18 @@ export class ApprovalService implements IApprovalService { * snapshot (which the inbox serves only after redacting it for its reader, * #10749). Notification text is stored and may be e-mailed, and it has no * reader to narrow for. + * + * [#24978] Answered as the two `{{object}} {{record}}` holes of the keyed + * sentence rather than one string: the label is keyed by + * `objects..label`, so a recipient whose language pack holds it + * reads it in their language at delivery, and the schema label (the API name + * when there is none) is the text it falls back to — the literal, which is + * exactly the `Deal D_123` this named before. */ - private nameSubjectRecord(object: string, recordId: string): string { + private nameSubjectRecord(object: string, recordId: string): ApprovalSubjectParams { // [#23594 / #23556 ruling B] The inbox serves the title narrowed per reader (resolveSubjectDisplays); this body is // written once at send time, with no per-recipient reader to narrow for, so it stays label-and-id. - return `${this.objectLabelOf(object) ?? object} ${recordId}`; + return subjectParams(object, this.objectLabelOf(object), recordId); } /** diff --git a/packages/plugins/plugin-approvals/src/translations/index.ts b/packages/plugins/plugin-approvals/src/translations/index.ts index 42f502e9350..c547bc4a546 100644 --- a/packages/plugins/plugin-approvals/src/translations/index.ts +++ b/packages/plugins/plugin-approvals/src/translations/index.ts @@ -8,7 +8,8 @@ * runtime via the plugin's `kernel:ready` hook (`i18n.loadTranslations`). * Regenerate the `objects` bundles with `os i18n extract` against * `scripts/i18n-extract.config.ts`; the `pages` entry is hand-authored in - * `./pages.ts`. + * `./pages.ts`, and the `messages` entries — the approval notifications' + * sentences (#24978) — in `./messages.ts`. */ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; @@ -21,6 +22,7 @@ import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; import { approvalsPageTranslations, pageSourceHashes } from './pages.js'; +import { approvalMessages } from './messages.js'; /** * ## The provenance companions are READ here, not merely recorded @@ -51,20 +53,26 @@ import { approvalsPageTranslations, pageSourceHashes } from './pages.js'; * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE * verdict fails the build if a committed companion stops being consulted here. */ -const enSource: TranslationData = { objects: enObjects, pages: approvalsPageTranslations.en }; +const enSource: TranslationData = { + objects: enObjects, + pages: approvalsPageTranslations.en, + // [#24978] The approval notifications' sentences (`./messages.ts`), read by + // the messaging service at delivery in each recipient's language. + messages: approvalMessages.en, +}; export const ApprovalsTranslations: TranslationBundle = { en: enSource, 'zh-CN': withSourceFallback( - { objects: zhCNObjects, pages: approvalsPageTranslations['zh-CN'] }, + { objects: zhCNObjects, pages: approvalsPageTranslations['zh-CN'], messages: approvalMessages['zh-CN'] }, enSource, pageSourceHashes['zh-CN'], zhCNGeneratedSourceHashes, ), 'ja-JP': withSourceFallback( - { objects: jaJPObjects, pages: approvalsPageTranslations['ja-JP'] }, + { objects: jaJPObjects, pages: approvalsPageTranslations['ja-JP'], messages: approvalMessages['ja-JP'] }, enSource, pageSourceHashes['ja-JP'], jaJPGeneratedSourceHashes, ), 'es-ES': withSourceFallback( - { objects: esESObjects, pages: approvalsPageTranslations['es-ES'] }, + { objects: esESObjects, pages: approvalsPageTranslations['es-ES'], messages: approvalMessages['es-ES'] }, enSource, pageSourceHashes['es-ES'], esESGeneratedSourceHashes, ), }; diff --git a/packages/plugins/plugin-approvals/src/translations/messages.ts b/packages/plugins/plugin-approvals/src/translations/messages.ts new file mode 100644 index 00000000000..2b332695c4c --- /dev/null +++ b/packages/plugins/plugin-approvals/src/translations/messages.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24978] The approval notifications' sentences — the catalog entries the + * messaging service renders, per recipient and at delivery, in that + * recipient's language (`EmitInput.keyedText`). + * + * HAND-AUTHORED, in the shape `@objectstack/plugin-audit`'s `messages.ts` gives + * its notification titles: this bundle's `messages` face, loaded into the + * `i18n` service with the rest of `ApprovalsTranslations` (ADR-0029 D8), and + * read by `t('messages.', locale, params)`. + * + * ## Keeping the copy honest + * + * - Ids are single-segment: both `II18nService` implementations resolve a key + * by walking NESTED objects, so an id holding a dot would never resolve. + * - `en` IS the literal text. `approval-notification-text.ts` builds the + * `payload.title` / `payload.body` every emit still carries — the fallback a + * recipient reads whenever a key cannot be rendered — by interpolating these + * `en` entries, so the English a recipient reads is one string whichever + * path delivered it, and it is the sentence each notification carried before + * it was keyed. + * - Every locale carries every id with the same `{{holes}}`: + * `approval-notification-text.test.ts` pins both. + * + * ## The holes + * + * - `{{object}} {{record}}` — the record the notification is about, named by + * its object's label and its id (`Deal D_123`, the #23556 / #23594 ruling B + * form). `object` is itself rendered in the recipient's language from + * `objects..label` when the app's language pack holds it, else the + * schema label; + * - `{{person}}` — a display name; `{{action}}` — the escalation action's + * configured token; `{{limit}}` — the revision limit; `{{comment}}` — the + * decider's comment, as they wrote it. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +type Messages = NonNullable; + +/** The ids, as `en` spells them — every other locale carries exactly these. */ +export const enMessages = { + approvalRequestedTitle: 'Approval requested', + approvalRequestedBody: 'A decision on {{object}} {{record}} is waiting on you.', + approvalReminderTitle: 'Approval reminder', + approvalReminderBody: 'A decision on {{object}} {{record}} is still waiting on you.', + approvalSlaBreachedTitle: 'Approval SLA breached', + approvalSlaBreachedBody: 'A decision on {{object}} {{record}} is overdue.', + approvalSlaBreachedSubmitterTitle: 'Your approval request breached its SLA', + approvalSlaBreachedSubmitterBody: "{{object}} {{record}}: escalation action '{{action}}' was taken.", + approvalApprovedTitle: 'Request approved', + approvalApprovedBody: 'Your {{object}} {{record}} was approved.', + approvalApprovedWithCommentBody: 'Your {{object}} {{record}} was approved.\n\n{{comment}}', + approvalRejectedTitle: 'Request rejected', + approvalRejectedBody: 'Your {{object}} {{record}} was rejected.', + approvalRejectedWithCommentBody: 'Your {{object}} {{record}} was rejected.\n\n{{comment}}', + approvalOooSubstitutedTitle: 'Approval routed to you (out-of-office cover)', + approvalOooSubstitutedBody: 'You are covering an approval on {{object}} {{record}} while {{person}} is out of office.', + approvalOooSkippedTitle: 'Approval routed to your delegate', + approvalOooSkippedBody: 'An approval on {{object}} {{record}} was routed to {{person}} while you are out of office.', + approvalEscalatedTitle: 'Approval escalated to you', + approvalEscalatedBody: 'An overdue approval on {{object}} {{record}} was escalated to you.', + approvalReassignedTitle: 'Approval handed to you', + approvalReassignedBody: 'You are now an approver on {{object}} {{record}}.', + approvalAutoRejectedTitle: 'Approval auto-rejected', + approvalAutoRejectedBody: 'Your {{object}} {{record}} exceeded the revision limit ({{limit}}) and was rejected.', + approvalReturnedTitle: 'Sent back for revision', + approvalReturnedBody: 'Your {{object}} {{record}} needs rework before it can be approved.', + approvalRequestInfoTitle: 'More information requested', + approvalCommentTitle: 'New comment on an approval', +} as const satisfies Messages; + +/** One approval notification sentence's id. */ +export type ApprovalMessageId = keyof typeof enMessages; + +type ApprovalMessages = Readonly>; + +export const zhCNMessages: ApprovalMessages = { + approvalRequestedTitle: '新的审批请求', + approvalRequestedBody: '{{object}} {{record}} 正在等待您的审批决定。', + approvalReminderTitle: '审批提醒', + approvalReminderBody: '{{object}} {{record}} 仍在等待您的审批决定。', + approvalSlaBreachedTitle: '审批已超出时限', + approvalSlaBreachedBody: '{{object}} {{record}} 的审批决定已逾期。', + approvalSlaBreachedSubmitterTitle: '您的审批请求已超出时限', + approvalSlaBreachedSubmitterBody: '{{object}} {{record}}:已执行升级操作「{{action}}」。', + approvalApprovedTitle: '请求已批准', + approvalApprovedBody: '您的{{object}} {{record}} 已获批准。', + approvalApprovedWithCommentBody: '您的{{object}} {{record}} 已获批准。\n\n{{comment}}', + approvalRejectedTitle: '请求已驳回', + approvalRejectedBody: '您的{{object}} {{record}} 已被驳回。', + approvalRejectedWithCommentBody: '您的{{object}} {{record}} 已被驳回。\n\n{{comment}}', + approvalOooSubstitutedTitle: '审批已转交给您(外出代理)', + approvalOooSubstitutedBody: '{{person}} 外出期间,由您代为处理 {{object}} {{record}} 的审批。', + approvalOooSkippedTitle: '审批已转交给您的代理人', + approvalOooSkippedBody: '您外出期间,{{object}} {{record}} 的审批已转交给 {{person}}。', + approvalEscalatedTitle: '审批已升级给您', + approvalEscalatedBody: '{{object}} {{record}} 的一项逾期审批已升级给您。', + approvalReassignedTitle: '审批已移交给您', + approvalReassignedBody: '您现在是 {{object}} {{record}} 的审批人。', + approvalAutoRejectedTitle: '审批已自动驳回', + approvalAutoRejectedBody: '您的{{object}} {{record}} 已超过退回修改次数上限({{limit}}),已被驳回。', + approvalReturnedTitle: '已退回修改', + approvalReturnedBody: '您的{{object}} {{record}} 需要修改后才能获批。', + approvalRequestInfoTitle: '需要补充信息', + approvalCommentTitle: '审批有新评论', +}; + +export const jaJPMessages: ApprovalMessages = { + approvalRequestedTitle: '承認依頼', + approvalRequestedBody: '{{object}} {{record}} について、あなたの承認判断が必要です。', + approvalReminderTitle: '承認のリマインダー', + approvalReminderBody: '{{object}} {{record}} について、引き続きあなたの承認判断をお待ちしています。', + approvalSlaBreachedTitle: '承認の期限超過', + approvalSlaBreachedBody: '{{object}} {{record}} の承認判断が期限を過ぎています。', + approvalSlaBreachedSubmitterTitle: 'あなたの承認申請が期限を超過しました', + approvalSlaBreachedSubmitterBody: '{{object}} {{record}}:エスカレーション操作「{{action}}」が実行されました。', + approvalApprovedTitle: '申請が承認されました', + approvalApprovedBody: 'あなたの{{object}} {{record}} は承認されました。', + approvalApprovedWithCommentBody: 'あなたの{{object}} {{record}} は承認されました。\n\n{{comment}}', + approvalRejectedTitle: '申請が却下されました', + approvalRejectedBody: 'あなたの{{object}} {{record}} は却下されました。', + approvalRejectedWithCommentBody: 'あなたの{{object}} {{record}} は却下されました。\n\n{{comment}}', + approvalOooSubstitutedTitle: '承認が割り当てられました(不在時の代理)', + approvalOooSubstitutedBody: '{{person}} さんの不在中、{{object}} {{record}} の承認をあなたが代理で担当します。', + approvalOooSkippedTitle: '承認が代理人に回されました', + approvalOooSkippedBody: 'あなたの不在中、{{object}} {{record}} の承認は {{person}} さんに回されました。', + approvalEscalatedTitle: '承認がエスカレーションされました', + approvalEscalatedBody: '期限を過ぎた {{object}} {{record}} の承認があなたにエスカレーションされました。', + approvalReassignedTitle: '承認が引き継がれました', + approvalReassignedBody: 'あなたは {{object}} {{record}} の承認者になりました。', + approvalAutoRejectedTitle: '承認が自動的に却下されました', + approvalAutoRejectedBody: 'あなたの{{object}} {{record}} は差し戻し回数の上限({{limit}})を超えたため却下されました。', + approvalReturnedTitle: '修正のため差し戻されました', + approvalReturnedBody: 'あなたの{{object}} {{record}} は、承認を受ける前に修正が必要です。', + approvalRequestInfoTitle: '追加情報が求められています', + approvalCommentTitle: '承認に新しいコメントがあります', +}; + +export const esESMessages: ApprovalMessages = { + approvalRequestedTitle: 'Aprobación solicitada', + approvalRequestedBody: 'Una decisión sobre {{object}} {{record}} está pendiente de usted.', + approvalReminderTitle: 'Recordatorio de aprobación', + approvalReminderBody: 'Una decisión sobre {{object}} {{record}} sigue pendiente de usted.', + approvalSlaBreachedTitle: 'Plazo de aprobación incumplido', + approvalSlaBreachedBody: 'La decisión sobre {{object}} {{record}} está vencida.', + approvalSlaBreachedSubmitterTitle: 'Su solicitud de aprobación ha superado su plazo', + approvalSlaBreachedSubmitterBody: "{{object}} {{record}}: se ha ejecutado la acción de escalado '{{action}}'.", + approvalApprovedTitle: 'Solicitud aprobada', + approvalApprovedBody: 'Se ha aprobado su {{object}} {{record}}.', + approvalApprovedWithCommentBody: 'Se ha aprobado su {{object}} {{record}}.\n\n{{comment}}', + approvalRejectedTitle: 'Solicitud rechazada', + approvalRejectedBody: 'Se ha rechazado su {{object}} {{record}}.', + approvalRejectedWithCommentBody: 'Se ha rechazado su {{object}} {{record}}.\n\n{{comment}}', + approvalOooSubstitutedTitle: 'Aprobación asignada a usted (suplencia por ausencia)', + approvalOooSubstitutedBody: 'Está cubriendo una aprobación de {{object}} {{record}} mientras {{person}} está ausente.', + approvalOooSkippedTitle: 'Aprobación derivada a su suplente', + approvalOooSkippedBody: 'Una aprobación de {{object}} {{record}} se ha derivado a {{person}} mientras usted está ausente.', + approvalEscalatedTitle: 'Aprobación escalada a usted', + approvalEscalatedBody: 'Se le ha escalado una aprobación vencida de {{object}} {{record}}.', + approvalReassignedTitle: 'Aprobación transferida a usted', + approvalReassignedBody: 'Ahora es aprobador de {{object}} {{record}}.', + approvalAutoRejectedTitle: 'Aprobación rechazada automáticamente', + approvalAutoRejectedBody: 'Su {{object}} {{record}} superó el límite de revisiones ({{limit}}) y se ha rechazado.', + approvalReturnedTitle: 'Devuelto para revisión', + approvalReturnedBody: 'Su {{object}} {{record}} necesita cambios antes de poder aprobarse.', + approvalRequestInfoTitle: 'Se solicita más información', + approvalCommentTitle: 'Nuevo comentario en una aprobación', +}; + +/** The four shipped locales, keyed as the bundle beside this file keys them. */ +export const approvalMessages: Readonly> = { + en: enMessages, + 'zh-CN': zhCNMessages, + 'ja-JP': jaJPMessages, + 'es-ES': esESMessages, +}; diff --git a/packages/services/service-messaging/src/dispatcher.ts b/packages/services/service-messaging/src/dispatcher.ts index 99e92e79612..94367f1f99f 100644 --- a/packages/services/service-messaging/src/dispatcher.ts +++ b/packages/services/service-messaging/src/dispatcher.ts @@ -4,6 +4,7 @@ import type { MessagingChannel, MessagingChannelContext, Notification, SendResul import type { AckResult, ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js'; import { classifyDeliveryAttempt } from './backoff.js'; import { renderDigest } from './digest-render.js'; +import { readNotificationTextKeys, type NotificationTextRenderer } from './notification-text.js'; import { DispatchLoop } from '@objectstack/core'; /** Minimal channel-registry surface the dispatcher needs (MessagingService satisfies it). */ @@ -85,6 +86,13 @@ export interface NotificationDispatcherOptions { logger?: NotificationDispatcherLogger; /** Observability hook fired after every attempt. */ onAttempt?: (delivery: NotificationDeliveryRecord, success: boolean) => void; + /** + * [#24978] Renders a row's snapshotted `keyedText` in its recipient's + * language before the channel is called — the same instance the messaging + * service renders the inline fan-out with. Absent, a keyed row is sent as + * its literal title/body. + */ + textRenderer?: NotificationTextRenderer; } /** @@ -114,9 +122,9 @@ export interface NotificationDispatcherOptions { */ export class NotificationDispatcher { private readonly opts: Required< - Omit + Omit > & - Pick & { cluster: DispatchCluster }; + Pick & { cluster: DispatchCluster }; /** [#17610, #17623] The timer loop — idle backoff, wake, stop — shared with `HttpDispatcher`. */ private readonly loop: DispatchLoop; @@ -138,6 +146,7 @@ export class NotificationDispatcher { now: options.now, logger: options.logger, onAttempt: options.onAttempt, + textRenderer: options.textRenderer, }; this.loop = new DispatchLoop({ intervalMs, @@ -286,7 +295,7 @@ export class NotificationDispatcher { return; } - const digest = renderDigest(rows); + const digest = renderDigest(await this.localizedForRecipient(recipient, rows)); const notification: Notification = { notificationId: rows[0].notificationId, // representative event id organizationId: rows[0].organizationId, @@ -319,6 +328,36 @@ export class NotificationDispatcher { } } + /** + * [#24978] A digest group's rows with each keyed row's title and body + * rendered in the group's ONE recipient's language (one locale read for + * the group), so the items the digest lists read in that language. Rows + * that carry no keys, and every row when no renderer is composed, are + * handed back as they are. + */ + private async localizedForRecipient( + recipient: string, + rows: ClaimedDeliveryRecord[], + ): Promise { + const renderer = this.opts.textRenderer; + if (!renderer) return rows; + const items = rows.map((row) => { + const p = row.payload ?? {}; + return { + keys: readNotificationTextKeys(p.keyedText), + literal: { + title: typeof p.title === 'string' ? p.title : '', + body: typeof p.body === 'string' ? p.body : '', + }, + }; + }); + if (!items.some((item) => item.keys)) return rows; + const rendered = await renderer.renderAllFor(recipient, items); + return rows.map((row, i) => items[i].keys + ? { ...row, payload: { ...row.payload, title: rendered[i].title, body: rendered[i].body } } + : row); + } + private async processRow(row: ClaimedDeliveryRecord): Promise { const channel = this.opts.channels.getChannel(row.channel); if (!channel) { @@ -333,12 +372,23 @@ export class NotificationDispatcher { } const p = row.payload ?? {}; + // [#24978] Rendered here, at delivery, in this row's recipient's + // language when the emit keyed its text; the literal snapshot + // otherwise, read exactly as before. + const literal = { + title: typeof p.title === 'string' ? p.title : row.topic ?? '', + body: typeof p.body === 'string' ? p.body : '', + }; + const keys = readNotificationTextKeys(p.keyedText); + const text = keys && this.opts.textRenderer + ? await this.opts.textRenderer.renderFor(row.recipientId, keys, literal) + : literal; const notification: Notification = { notificationId: row.notificationId, organizationId: row.organizationId, topic: row.topic, - title: typeof p.title === 'string' ? p.title : row.topic ?? '', - body: typeof p.body === 'string' ? p.body : '', + title: text.title, + body: text.body, severity: (p.severity as Notification['severity']) ?? 'info', recipients: [row.recipientId], channels: [row.channel], diff --git a/packages/services/service-messaging/src/fan-out-system-context.ts b/packages/services/service-messaging/src/fan-out-system-context.ts index a3b0ed02e05..93f75ac2490 100644 --- a/packages/services/service-messaging/src/fan-out-system-context.ts +++ b/packages/services/service-messaging/src/fan-out-system-context.ts @@ -18,6 +18,10 @@ * (`sys_notification_template`), and `emit()`'s dedup lookup * (`sys_notification` by `dedup_key`). * + * [#24978] And by `NotificationTextRenderer.recipientLocale` (`sys_user`, the + * locale alone), the read that picks the language a keyed notification text is + * rendered in, at delivery, for that recipient. + * * `emit()` takes no caller context. The door in front of an emitting caller * has already decided whether it may notify, and every row these calls read or * write belongs to a RECIPIENT, not to the emitter — so no caller's grants diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index a6045a9d051..8ac5b6d758e 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -71,6 +71,21 @@ export { } from './recipient-locale.js'; export type { SmsChannelOptions, SmsSenderSurface } from './sms-channel.js'; +// [#24978] Keyed notification text — `EmitInput.keyedText`, rendered per +// recipient at delivery from the `i18n` service's catalog. +export { + NotificationTextRenderer, + assertNotificationTextKeys, +} from './notification-text.js'; +export type { + NotificationTextKeys, + NotificationTextParam, + NotificationTextI18n, + NotificationTextItem, + NotificationTextRendererOptions, + RenderedNotificationText, +} from './notification-text.js'; + // Templates + renderer (ADR-0030 P3) export { NotificationTemplateStore, diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 83e111fe594..3f90522a8b3 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -13,6 +13,7 @@ import { HttpDispatcher } from './http-dispatcher.js'; import { createEmailChannel } from './email-channel.js'; import { createSmsChannel } from './sms-channel.js'; import { NotificationTemplateStore } from './template-renderer.js'; +import { NotificationTextRenderer, type NotificationTextI18n } from './notification-text.js'; import { InboxMessage, NotificationReceipt, @@ -221,12 +222,6 @@ export class MessagingServicePlugin implements Plugin { } }; - const service = new MessagingService({ - logger: ctx.logger, - getData, - mandatoryTopics: this.options.mandatoryTopics, - }); - // Lazy `email` service resolver — shared by the email channel (send + // sendTemplate) and the inbox channel's render-only template path // (#9225): one resolver in plugin-email, two channels. Probed at @@ -260,6 +255,33 @@ export class MessagingServicePlugin implements Plugin { } }; + // [#24978] The `i18n` service whose catalog a keyed notification text + // is rendered from — probed at delivery time, never captured at boot, + // for the reasons `getDefaultTemplateLocale` above is. + const getI18n = (): NotificationTextI18n | undefined => { + try { + return ctx.getService('i18n'); + } catch { + return undefined; + } + }; + // ONE renderer for both delivery paths: the service's inline fan-out + // and (below) the dispatcher, so a recipient's keyed text is rendered + // the same way whichever path delivers it. + const textRenderer = new NotificationTextRenderer({ + getData, + getI18n, + getDefaultLocale: getDefaultTemplateLocale, + logger: ctx.logger, + }); + + const service = new MessagingService({ + logger: ctx.logger, + getData, + mandatoryTopics: this.options.mandatoryTopics, + textRenderer, + }); + if (this.options.registerInbox) { service.registerChannel(createInboxChannel({ getData, getEmail, getDefaultTemplateLocale })); } @@ -429,6 +451,7 @@ export class MessagingServicePlugin implements Plugin { intervalMs: this.options.dispatchIntervalMs, maxIdleIntervalMs: this.options.dispatchMaxIdleIntervalMs, logger: ctx.logger, + textRenderer, }); this.dispatcher.start(); ctx.logger.info( diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 9c6ad4d2c70..6cc7d3ac42e 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -25,6 +25,11 @@ import { type InboxCaller, resolveInboxRecipient } from './inbox-caller.js'; import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; import { INBOX_SYSTEM_CONTEXT } from './inbox-system-context.js'; import { assertActorReferenceResolves } from './actor-reference.js'; +import { + assertNotificationTextKeys, + type NotificationTextKeys, + type NotificationTextRenderer, +} from './notification-text.js'; /** The L2 event object every `emit()` writes one row to (ADR-0030). */ export const NOTIFICATION_EVENT_OBJECT = 'sys_notification'; @@ -73,6 +78,24 @@ export interface EmitInput { readonly audience: Audience; /** Template inputs carried to channels (title/body/url/actor/source/…). */ readonly payload?: Record; + /** + * [#24978] The text as CATALOG KEYS, rendered per recipient at delivery in + * that recipient's language (`sys_user.locale`, else the deployment + * default — #13881), through the `i18n` service's catalog. See + * `notification-text.ts` for the contract. + * + * Beside the literal text, never in place of it: `payload.title` / + * `payload.body` stay what a recipient reads whenever a key cannot be + * rendered (no `i18n` service, no entry for the key, a member left + * unkeyed). Absent, the notification is delivered exactly as before this + * member existed, on every channel. A malformed value is refused by + * `emit()` before anything is written. + * + * It is not a payload member: the delivery snapshot carries it under its + * own reserved name, so a producer's free-form payload (a flow's `notify` + * node) cannot reach it. + */ + readonly keyedText?: NotificationTextKeys; /** Severity hint for rendering / filtering. */ readonly severity?: 'info' | 'warning' | 'critical'; /** Idempotency key within a topic window; a repeat `emit` is a no-op. */ @@ -184,6 +207,13 @@ export interface MessagingServiceContext extends MessagingChannelContext { * fans out inline (best-effort, no retry) — the P0 behavior. */ outbox?: INotificationOutbox; + /** + * [#24978] Renders {@link EmitInput.keyedText} per recipient on the inline + * fan-out. The plugin hands the dispatcher the same instance, so both paths + * answer one recipient one way. Absent, keyed text is delivered as its + * literal (said once). + */ + textRenderer?: NotificationTextRenderer; } /** @@ -231,6 +261,8 @@ export class MessagingService { private onHttpDeliveryEnqueued?: () => void; /** [#8069] Producer vetoes over redelivery, keyed by `HttpDelivery.source`. */ private readonly redeliverGuards = new Map(); + /** [#24978] Whether keyed text met a composition with no renderer — said once. */ + private keyedTextWithoutRendererAnnounced = false; constructor(private readonly ctx: MessagingServiceContext) { this.now = ctx.now ?? (() => new Date().toISOString()); @@ -1053,6 +1085,11 @@ export class MessagingService { * member — fans out exactly as before. */ async emit(input: EmitInput): Promise { + // [#24978] A malformed keyed text is a producer bug: refused before + // anything is written, never delivered as a literal that silently + // never renders. + if (input.keyedText !== undefined) assertNotificationTextKeys(input.keyedText); + const data = this.ctx.getData?.(); // 1) Idempotency — a prior event with the same dedup_key is a no-op. @@ -1198,7 +1235,7 @@ export class MessagingService { // Inline (P0): every channel has already answered, so `delivered` is a // real terminal count and nothing is left in flight. - const { deliveries, delivered, failed } = await this.fanOut(notification, targets); + const { deliveries, delivered, failed } = await this.fanOut(notification, targets, input.keyedText); return { notificationId, deduped: false, deliveries, delivered, enqueued: 0, failed, suppressed }; } @@ -1298,8 +1335,17 @@ export class MessagingService { ): Promise { // Snapshot the rendered content onto each delivery so a later event edit // can't rewrite an in-flight send. + // + // [#24978] `keyedText` is a name this snapshot RESERVES, the way it + // already owns `title` / `body` / `severity` / `actionUrl` / `actorId`: + // it carries `EmitInput.keyedText` and nothing else, so a producer's + // free-form payload member of that name is dropped here and can never + // be rendered as keys. Present only when the emit keyed its text — the + // literal-only snapshot is byte-for-byte what it was before. + const { keyedText: _reserved, ...producerPayload } = payload; const deliveryPayload = { - ...payload, + ...producerPayload, + ...(input.keyedText ? { keyedText: input.keyedText } : {}), title: str(payload.title) ?? input.topic, body: str(payload.body) ?? '', severity: input.severity ?? 'info', @@ -1459,10 +1505,18 @@ export class MessagingService { private async fanOut( notification: Notification, targets: PreferenceTarget[], + keyedText?: NotificationTextKeys, ): Promise<{ deliveries: DeliveryOutcome[]; delivered: number; failed: number }> { const deliveries: DeliveryOutcome[] = []; for (const { recipient, channels } of targets) { + // [#24978] The recipient's own text, rendered once for all of their + // channels — so one notification cannot reach one person in two + // languages. A literal-only emit hands every channel the very + // object it always did. + const unit = keyedText + ? await this.renderedFor(notification, recipient, keyedText) + : notification; for (const channelId of channels) { const channel = this.getChannel(channelId); if (!channel) { @@ -1476,7 +1530,7 @@ export class MessagingService { continue; } try { - const result = await channel.send(this.ctx, { notification, channel: channelId, recipient }); + const result = await channel.send(this.ctx, { notification: unit, channel: channelId, recipient }); deliveries.push({ channel: channelId, recipient, @@ -1498,6 +1552,34 @@ export class MessagingService { const delivered = deliveries.filter((d) => d.ok).length; return { deliveries, delivered, failed: deliveries.length - delivered }; } + + /** + * [#24978] `notification` with its title and body rendered from `keys` in + * `recipient`'s language, each member falling back to the literal. With + * no renderer composed, the literal itself — said once, because a + * producer that keyed its text is then reaching every recipient in the + * language it emitted in. + */ + private async renderedFor( + notification: Notification, + recipient: string, + keys: NotificationTextKeys, + ): Promise { + const renderer = this.ctx.textRenderer; + if (!renderer) { + if (!this.keyedTextWithoutRendererAnnounced) { + this.keyedTextWithoutRendererAnnounced = true; + this.ctx.logger.warn( + '[messaging] emit: keyed notification text arrived at a messaging service composed without a ' + + 'text renderer; recipients get the literal title/body. Compose MessagingServiceContext.textRenderer ' + + '(MessagingServicePlugin does) to render it in each recipient\'s language.', + ); + } + return notification; + } + const text = await renderer.renderFor(recipient, keys, { title: notification.title, body: notification.body }); + return { ...notification, title: text.title, body: text.body }; + } } /** Coerce a payload value to a non-empty string, else `undefined`. */ diff --git a/packages/services/service-messaging/src/notification-text.ts b/packages/services/service-messaging/src/notification-text.ts new file mode 100644 index 00000000000..9c6e7ab53a9 --- /dev/null +++ b/packages/services/service-messaging/src/notification-text.ts @@ -0,0 +1,351 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24978] A notification's text as CATALOG KEYS, rendered per recipient at + * delivery, in that recipient's language. + * + * ## What was wrong + * + * `emit()` accepted the text a recipient reads only as two literal strings, + * `payload.title` and `payload.body`, built by the producer at emit. A + * notification reaches several people, and the producer holds no recipient + * when it builds them, so every recipient read the producer's language: the + * approvals plugin's `approval.requested`, reminders, SLA notices, decision + * outcomes and out-of-office notices reached a zh-CN, ja-JP or es-ES approver + * as English sentences. + * + * ## The contract + * + * {@link NotificationTextKeys} rides beside the literal text, never in place of + * it (`EmitInput.keyedText`): + * + * - `titleKey` / `bodyKey` are `II18nService` keys — a face and an id, e.g. + * `messages.approvalRequestedTitle` — whose entries a producer contributes + * through its own translation bundle (ADR-0029 D8, `i18n.loadTranslations`), + * the catalog every server-rendered string in this platform already uses. + * ⛔ There is no second catalog here. + * - `params` fills the entries' `{{name}}` holes. A value is used as given, + * or, spelled `{ key, fallback }`, is itself the recipient-language text of + * `key` (an object's label, `objects..label`), else `fallback`. + * - The literal `payload.title` / `payload.body` stay the text delivered + * whenever a key cannot be rendered: no `i18n` service, a key with no entry + * in the recipient's locale or in the catalog's own fallback locale, a + * member the producer left unkeyed. A producer that passes no keys is + * delivered exactly what it was delivered before this existed, on every + * channel. + * + * ## Where it renders + * + * At delivery, per recipient, ahead of the channel: the inline fan-out renders + * before each `send()`, and the dispatcher renders each claimed row (and each + * row of a digest group) before it builds the channel's `Notification`. So + * every channel — inbox, email, SMS, and any channel a third party mounts — + * receives the rendered `title` / `body` it already reads, and no channel had + * to learn the contract. + * + * The recipient's language is the ruled chain (#13881), resolved through + * {@link resolveRecipientLocale}: the recipient's own `sys_user.locale`, else + * the deployment default. It is negotiated against the locales the `i18n` + * service holds with `resolveBundleLocale` (`@objectstack/spec/system`), the + * one negotiation rule, so a bare `zh` reaches a `zh-CN` pack. + * + * ## Cost + * + * One `sys_user` read per keyed delivery (one per digest group), and none at + * all for a delivery that carries no keys or a deployment with no `i18n` + * service. The email and SMS channels read the same row again for the address; + * the two reads resolve one chain, so they cannot disagree on a language. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { resolveBundleLocale } from '@objectstack/spec/system'; +import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; +import { DEFAULT_LOCALE } from './template-renderer.js'; + +/** + * One `{{name}}` value of {@link NotificationTextKeys.params}: the text itself, + * or a catalog key rendered in the recipient's language with `fallback` as the + * text when the key has no entry. + */ +export type NotificationTextParam = string | { readonly key: string; readonly fallback: string }; + +/** + * A notification's text as catalog keys (`EmitInput.keyedText`). At least one + * of `titleKey` / `bodyKey`; a member left out is delivered as the literal. + */ +export interface NotificationTextKeys { + /** `II18nService` key of the headline, e.g. `messages.approvalRequestedTitle`. */ + readonly titleKey?: string; + /** `II18nService` key of the body, e.g. `messages.approvalRequestedBody`. */ + readonly bodyKey?: string; + /** Values for the `{{name}}` holes of both entries. */ + readonly params?: Readonly>; +} + +/** A notification's rendered text — what a channel reads as `title` / `body`. */ +export interface RenderedNotificationText { + readonly title: string; + readonly body: string; +} + +/** The slice of `II18nService` the render asks: `t()`, and `getLocales()` to negotiate when offered. */ +export interface NotificationTextI18n { + t(key: string, locale: string, params?: Record): string; + getLocales?(): string[]; +} + +/** + * A catalog key: a face and an id, dot-separated (`messages.approvalRequestedTitle`, + * `objects.crm_deal.label`). One segment alone never names an entry, because + * `t()` walks the key from the root of a translation bundle and every bundle + * entry lives under a face; refusing it at emit turns a key that could never + * render into a loud error instead of a literal delivered forever. + */ +const CATALOG_KEY_SHAPE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/; + +/** A `{{name}}` hole name, the one both `II18nService` implementations interpolate. */ +const PARAM_NAME_SHAPE = /^\w+$/; + +const TEXT_MEMBERS: ReadonlySet = new Set(['titleKey', 'bodyKey', 'params']); +const KEYED_PARAM_MEMBERS: ReadonlySet = new Set(['key', 'fallback']); + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** The first thing wrong with `value` as {@link NotificationTextKeys}, or `undefined`. */ +function problemOf(value: unknown): string | undefined { + if (!isPlainObject(value)) return 'must be an object'; + for (const member of Object.keys(value)) { + if (!TEXT_MEMBERS.has(member)) { + return `has an undeclared member '${member}' (declared: titleKey, bodyKey, params)`; + } + } + for (const member of ['titleKey', 'bodyKey'] as const) { + const key = value[member]; + if (key !== undefined && (typeof key !== 'string' || !CATALOG_KEY_SHAPE.test(key))) { + return `.${member} must be a catalog key, a face and an id such as 'messages.myTitle' (got ${JSON.stringify(key)})`; + } + } + if (value.titleKey === undefined && value.bodyKey === undefined) { + return 'names neither titleKey nor bodyKey'; + } + const params = value.params; + if (params === undefined) return undefined; + if (!isPlainObject(params)) return '.params must be an object'; + for (const [name, param] of Object.entries(params)) { + if (!PARAM_NAME_SHAPE.test(name)) return `.params has a name '${name}' no {{hole}} can spell`; + if (typeof param === 'string') continue; + if (!isPlainObject(param)) return `.params.${name} must be a string or { key, fallback }`; + for (const member of Object.keys(param)) { + if (!KEYED_PARAM_MEMBERS.has(member)) return `.params.${name} has an undeclared member '${member}' (declared: key, fallback)`; + } + if (typeof param.key !== 'string' || !CATALOG_KEY_SHAPE.test(param.key)) { + return `.params.${name}.key must be a catalog key such as 'objects.my_object.label' (got ${JSON.stringify(param.key)})`; + } + if (typeof param.fallback !== 'string') return `.params.${name}.fallback must be a string`; + } + return undefined; +} + +/** + * Refuse a malformed {@link NotificationTextKeys} — `emit()` calls this before + * it writes anything. The shape is closed: an undeclared member (a `title` + * spelled for `titleKey`) is refused rather than ignored, so a producer that + * meant to key its text cannot ship one that silently never renders. + */ +export function assertNotificationTextKeys(value: unknown): asserts value is NotificationTextKeys { + const problem = problemOf(value); + if (problem !== undefined) { + throw new TypeError(`messaging: emit() keyedText ${problem}`); + } +} + +/** + * The keys a stored delivery row carries, or `undefined` when it carries none + * (or none that `emit()` could have written — the row is then delivered as its + * literal text, which is what it held all along). + */ +export function readNotificationTextKeys(value: unknown): NotificationTextKeys | undefined { + if (value === undefined || problemOf(value) !== undefined) return undefined; + return value as NotificationTextKeys; +} + +export interface NotificationTextRendererOptions { + /** Resolve the data engine — the recipient's `sys_user.locale` is read from it. */ + getData(): IDataEngine | undefined; + /** Resolve the `i18n` service, probed at render time. `undefined` ⇒ the literal text. */ + getI18n(): NotificationTextI18n | undefined; + /** The DEPLOYMENT DEFAULT locale — the second rung of the recipient chain (#13881). */ + getDefaultLocale?(): string | undefined; + /** User identity object the recipient's locale is read from (default `sys_user`). */ + userObject?: string; + logger?: { warn(...args: unknown[]): void }; +} + +/** One notification to render for a recipient: its keys (absent ⇒ literal) and its literal text. */ +export interface NotificationTextItem { + readonly keys?: NotificationTextKeys; + readonly literal: RenderedNotificationText; +} + +/** + * Renders {@link NotificationTextKeys} for one recipient. One instance per + * messaging composition: the plugin hands the same one to the service (inline + * fan-out) and to the dispatcher (durable path), so both paths answer one + * recipient one way. + */ +export class NotificationTextRenderer { + private readonly userObject: string; + /** `key|locale` pairs whose miss has been said — said once, the lookup is not cached. */ + private readonly announcedMisses = new Set(); + + constructor(private readonly opts: NotificationTextRendererOptions) { + this.userObject = opts.userObject ?? USER_OBJECT; + } + + /** + * The recipient's language: their own `sys_user.locale`, else the + * deployment default, through the one chain (`resolveRecipientLocale`). + * A failed read costs the language, never the delivery. + */ + async recipientLocale(recipient: string): Promise { + const data = this.opts.getData(); + let own: unknown; + if (data) { + try { + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: the + // recipient's language, read to render a delivery to them. + const user = await data.findOne( + this.userObject, + { where: { id: recipient }, fields: [RECIPIENT_LOCALE_FIELD] }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); + own = (user as Record | null | undefined)?.[RECIPIENT_LOCALE_FIELD]; + } catch (err) { + this.opts.logger?.warn( + `[messaging] locale lookup for '${recipient}' failed (${(err as Error)?.message ?? String(err)}); ` + + 'rendering its notification text in the deployment default', + ); + } + } + return resolveRecipientLocale(own, () => this.opts.getDefaultLocale?.()); + } + + /** + * Render every item for ONE recipient: one locale read for the lot, and + * none when no item carries keys or no `i18n` service is registered. + */ + async renderAllFor(recipient: string, items: readonly NotificationTextItem[]): Promise { + if (!items.some((item) => item.keys)) return items.map((item) => item.literal); + const i18n = this.i18n(); + if (!i18n) return items.map((item) => item.literal); + const locale = await this.recipientLocale(recipient); + return items.map((item) => (item.keys ? this.renderWith(i18n, item.keys, item.literal, locale) : item.literal)); + } + + /** Render one notification's text for `recipient`. */ + async renderFor( + recipient: string, + keys: NotificationTextKeys, + literal: RenderedNotificationText, + ): Promise { + const [rendered] = await this.renderAllFor(recipient, [{ keys, literal }]); + return rendered; + } + + /** Render `keys` in `locale` (`undefined` ⇒ {@link DEFAULT_LOCALE}), falling back to `literal` per member. */ + render( + keys: NotificationTextKeys, + literal: RenderedNotificationText, + locale: string | undefined, + ): RenderedNotificationText { + const i18n = this.i18n(); + return i18n ? this.renderWith(i18n, keys, literal, locale) : literal; + } + + private i18n(): NotificationTextI18n | undefined { + try { + const service = this.opts.getI18n(); + return service && typeof service.t === 'function' ? service : undefined; + } catch { + return undefined; + } + } + + private renderWith( + i18n: NotificationTextI18n, + keys: NotificationTextKeys, + literal: RenderedNotificationText, + requested: string | undefined, + ): RenderedNotificationText { + const locale = negotiatedLocale(i18n, requested ?? DEFAULT_LOCALE); + const params: Record = {}; + for (const [name, param] of Object.entries(keys.params ?? {})) { + params[name] = typeof param === 'string' ? param : (lookup(i18n, param.key, locale) ?? param.fallback); + } + return { + title: keys.titleKey ? (this.entry(i18n, keys.titleKey, locale, params) ?? literal.title) : literal.title, + body: keys.bodyKey ? (this.entry(i18n, keys.bodyKey, locale, params) ?? literal.body) : literal.body, + }; + } + + /** A title/body entry, or `undefined` (said once per key and locale) when the catalog holds none. */ + private entry( + i18n: NotificationTextI18n, + key: string, + locale: string, + params: Record, + ): string | undefined { + const text = lookup(i18n, key, locale, params); + if (text === undefined) { + const said = `${key}|${locale}`; + if (!this.announcedMisses.has(said)) { + this.announcedMisses.add(said); + this.opts.logger?.warn( + `[messaging] notification text key '${key}' has no catalog entry for '${locale}' ` + + "(nor in the catalog's fallback locale); the emitter's literal text was delivered", + ); + } + } + return text; + } +} + +/** `t()`'s answer, or `undefined` for a miss (`t()` echoes the key), an empty answer or a throw. */ +function lookup( + i18n: NotificationTextI18n, + key: string, + locale: string, + params?: Record, +): string | undefined { + try { + const text = params && Object.keys(params).length > 0 ? i18n.t(key, locale, params) : i18n.t(key, locale); + return typeof text === 'string' && text.trim().length > 0 && text !== key ? text : undefined; + } catch { + // A misbehaving i18n service costs the language, never the delivery. + return undefined; + } +} + +/** + * The locale `i18n` can ANSWER in, for the locale the recipient holds: + * `resolveBundleLocale` over what `getLocales()` reports. The requested tag + * passes through untouched when there is nothing to negotiate against. + */ +function negotiatedLocale(i18n: NotificationTextI18n, requested: string): string { + if (typeof i18n.getLocales !== 'function') return requested; + let available: unknown; + try { + available = i18n.getLocales(); + } catch { + return requested; + } + if (!Array.isArray(available) || available.length === 0) return requested; + const offered: Record = {}; + for (const code of available) { + if (typeof code === 'string' && code.length > 0) offered[code] = true; + } + return resolveBundleLocale(offered, requested) ?? requested; +} diff --git a/packages/services/service-messaging/src/outbox.ts b/packages/services/service-messaging/src/outbox.ts index d7ea5358f57..4dc73ae7e9b 100644 --- a/packages/services/service-messaging/src/outbox.ts +++ b/packages/services/service-messaging/src/outbox.ts @@ -10,6 +10,8 @@ * Mirrors the proven `plugin-webhooks` outbox. */ +import type { NotificationTextKeys } from './notification-text.js'; + export type DeliveryStatus = | 'pending' | 'in_flight' @@ -32,6 +34,13 @@ export interface DeliveryPayload { * read of it is then type-checked at both ends. */ actorId?: string; + /** + * [#24978] `EmitInput.keyedText`, snapshotted so the dispatcher renders the + * text in the recipient's language at delivery. A reserved name: the + * snapshot never carries a producer payload member under it. Absent on a + * literal-only emit. + */ + keyedText?: NotificationTextKeys; [k: string]: unknown; } From 039981c65d22eae3e5c99454765df730e849ac97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:00:49 +0000 Subject: [PATCH 2/5] wip: subject params as a type alias Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../plugin-approvals/src/approval-notification-text.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-notification-text.ts b/packages/plugins/plugin-approvals/src/approval-notification-text.ts index db4cef79ac5..46a4260ea7a 100644 --- a/packages/plugins/plugin-approvals/src/approval-notification-text.ts +++ b/packages/plugins/plugin-approvals/src/approval-notification-text.ts @@ -60,11 +60,14 @@ export interface ApprovalNotificationText { readonly keyedText: ApprovalKeyedText; } -/** The record an approval notification names: `{{object}} {{record}}`. */ -export interface ApprovalSubjectParams { +/** + * The record an approval notification names: `{{object}} {{record}}`. A type, + * not an interface, so it spreads into a sentence's params as it stands. + */ +export type ApprovalSubjectParams = { readonly object: { readonly key: string; readonly fallback: string }; readonly record: string; -} +}; /** The `II18nService` key of an approval sentence, `messages.`. */ export function approvalMessageKey(id: ApprovalMessageId): string { From 53941995745ee61358f16b43f7a790486edac6a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:15:32 +0000 Subject: [PATCH 3/5] wip: keyed-text pins in both packages Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../plugins/plugin-approvals/package.json | 1 + ...otification-keyed-text.integration.test.ts | 355 +++++++++++++++ .../plugins/plugin-approvals/vitest.config.ts | 9 + ...eyed-notification-text.integration.test.ts | 426 ++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 794 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts create mode 100644 packages/services/service-messaging/src/keyed-notification-text.integration.test.ts diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 62eb492e15f..08c283bb99a 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -33,6 +33,7 @@ "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/service-automation": "workspace:*", + "@objectstack/service-messaging": "workspace:*", "@objectstack/trigger-record-change": "workspace:*", "@types/node": "^26.6.3", "hono": "^4.13.9", diff --git a/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts new file mode 100644 index 00000000000..cbafa3de56a --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24978] Every approval notification carries its text as catalog keys, and + * a non-English approver reads it in their own language. + * + * ## The defect + * + * Each notification sentence — `approval.requested`, the reminders, the SLA + * notices, the decision outcomes, the out-of-office notices and the rest — was + * an English literal built at emit, and no catalog held it, so a zh-CN, ja-JP + * or es-ES approver read English (measured on HotCRM). + * + * ## What this pins + * + * Driven through the real `ApprovalService` on a real `ObjectQL` over + * `SqlDriver` (better-sqlite3, in-memory, real DDL): + * + * 1. the battery the body pins drive reaches every call site, and EACH emit + * carries `keyedText`: a `messages.` title key the catalog holds, and a + * body key exactly where the body is the service's own sentence — never for + * a person's own words (a comment, a question, a send-back note); + * 2. the keys render, through the REAL messaging renderer over the kernel's + * in-memory `i18n` service loaded with this plugin's own bundle, to the very + * literal the payload carries in `en` (one English, whichever path), and to + * a different sentence in each of zh-CN, ja-JP and es-ES — the object label + * included, from the app's `objects..label`; + * 3. end to end, a REAL `MessagingService` with its inbox channel: one + * opening reaches a zh-CN approver in Chinese, an English approver in + * English, and an approver with no locale in the deployment default's + * language; the decision outcome reaches the zh-CN submitter in Chinese + * with the decider's comment as written. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createMemoryI18n } from '@objectstack/core'; +import { SysNotification } from '@objectstack/platform-objects'; +import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; +import { + MessagingService, + NotificationTextRenderer, + createInboxChannel, + InboxMessage, + NotificationReceipt, + type NotificationTextKeys, +} from '@objectstack/service-messaging'; +import { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { ApprovalsTranslations } from './translations/index.js'; +import { approvalMessages, enMessages } from './translations/messages.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; +const silent = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any; + +const ORG = 'org_keyed'; + +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 }, + locale: { name: 'locale', label: 'Locale', 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 }, + }, +}; + +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 app's own object labels — what a real app's language pack would carry. */ +const DEAL_LABELS: Record = { 'zh-CN': '商机', 'ja-JP': '商談', 'es-ES': 'Oportunidad' }; + +/** The kernel's in-memory i18n service with this plugin's bundle and the app's labels loaded. */ +function i18nWithBundle(defaultLocale?: string) { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(ApprovalsTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + for (const [locale, label] of Object.entries(DEAL_LABELS)) { + i18n.loadTranslations(locale, { objects: { crm_deal: { label } } }); + } + if (defaultLocale) i18n.setDefaultLocale(defaultLocale); + return i18n; +} + +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: silent, + }; + await new ApprovalsServicePlugin().init(ctx); + return manifest.objects; +} + +async function bootEngine(users: Array>): Promise { + const 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, SysNotification, InboxMessage, NotificationReceipt, ...(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); + return engine; +} + +function approvalService(engine: ObjectQL, clock: () => Date): ApprovalService { + return new ApprovalService({ + engine: engine as any, + clock: { now: clock }, + automation: { + resume: async () => ({ status: 'completed' }), + cancelRun: async () => undefined, + getRun: async (runId: string) => ({ id: runId, status: 'paused' }), + getFlow: async () => FLOW, + } as any, + logger: silent, + }); +} + +interface Emitted { + topic: string; + audience: string[]; + source?: { object: string; id: string }; + payload?: { title?: string; body?: string }; + keyedText?: NotificationTextKeys; +} + +/** The `messages.` keys the catalog holds. */ +const CATALOG_KEYS = new Set(Object.keys(enMessages).map((id) => `messages.${id}`)); +/** The sites whose body is a person's own words — keyed title, literal body. */ +const OWN_WORDS = new Set(['approval.comment@thread', 'approval.request_info@thread', 'approval.returned@revise']); +/** A literal no catalog sentence spells — so a render that fell back is told apart from one that rendered. */ +const SENTINEL = { title: '<>', body: '<>' }; + +describe('#24978 — every approval notification carries its text as catalog keys', () => { + let engine: ObjectQL; + let now = Date.parse('2026-10-01T09:00:00.000Z'); + const emits: Emitted[] = []; + const ids: Record = {}; + const siteOf = (e: Emitted): string => { + const key = Object.keys(ids).find((k) => ids[k] === e.source?.id) ?? '?'; + const toSubmitter = e.topic === 'approval.sla_breached' && e.audience.join() === 'u_sub' ? '>submitter' : ''; + return `${e.topic}@${key}${toSubmitter}`; + }; + + beforeAll(async () => { + engine = await bootEngine([ + { id: 'u_sub', name: 'Sam Submitter', email: 'sam@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' }, + ]); + const svc = approvalService(engine, () => new Date(now += 1000)); + svc.attachMessaging({ async emit(input) { emits.push(input as Emitted); } }); + const open = async (key: string, approvers: any[], extra: Record = {}) => { + await engine.insert('crm_deal', { id: `D_${key}`, title: key, organization_id: ORG }, { 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; + }; + + await open('thread', [{ type: 'position', value: 'legal' }, { type: 'user', value: 'u_alice' }], { behavior: 'unanimous' }); + await svc.comment(ids.thread, { actorId: 'u_sub', comment: 'Attached the contract.' }, asUser('u_sub')); + await svc.requestInfo(ids.thread, { actorId: 'u_alice', comment: 'Which cost centre?' }, asUser('u_alice')); + 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')); + + await open('revise', [{ type: 'user', value: 'u_alice' }]); + await svc.sendBack(ids.revise, { actorId: 'u_alice', comment: 'Split it by quarter.' } as any, asUser('u_alice')); + await open('rework', [{ type: 'user', value: 'u_alice' }]); + await svc.sendBack(ids.rework, { actorId: 'u_alice' } 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')); + + await open('approved', [{ type: 'user', value: 'u_alice' }]); + await svc.decide(ids.approved, { decision: 'approve', actorId: 'u_alice' }, asUser('u_alice')); + await open('rejected', [{ type: 'user', value: 'u_alice' }]); + await svc.decide(ids.rejected, { decision: 'reject', actorId: 'u_alice', comment: 'Not this quarter.' }, asUser('u_alice')); + + 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); + await open('ooo', [{ type: 'user', value: 'u_ooo' }]); + + 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 topic an approval can notify', () => { + expect(new Set(emits.map((e) => e.topic))).toEqual(new Set([ + 'approval.requested', 'approval.reminder', 'approval.reassigned', 'approval.request_info', + 'approval.comment', 'approval.returned', 'approval.approved', 'approval.rejected', + 'approval.ooo_substituted', 'approval.ooo_skipped', 'approval.escalated', 'approval.sla_breached', + ])); + // Both SLA notices: the approvers' and the submitter's. + expect(emits.filter((e) => e.topic === 'approval.sla_breached').map(siteOf).sort()) + .toEqual(['approval.sla_breached@sla_notify', 'approval.sla_breached@sla_notify>submitter']); + }); + + it('each emit carries a title key the catalog holds, and a body key exactly where the body is the service\'s own sentence', () => { + const offenders: string[] = []; + for (const e of emits) { + const site = siteOf(e); + const keys = e.keyedText; + if (!keys?.titleKey || !CATALOG_KEYS.has(keys.titleKey)) offenders.push(`${site}: title key ${JSON.stringify(keys?.titleKey)}`); + if (OWN_WORDS.has(site)) { + if (keys?.bodyKey !== undefined) offenders.push(`${site}: a person's own words carry a body key`); + } else if (!keys?.bodyKey || !CATALOG_KEYS.has(keys.bodyKey)) { + offenders.push(`${site}: body key ${JSON.stringify(keys?.bodyKey)}`); + } + } + expect(offenders).toEqual([]); + // The control: the own-words sites are really in the battery. + expect(new Set(emits.map(siteOf).filter((s) => OWN_WORDS.has(s)))).toEqual(OWN_WORDS); + }); + + it('the keys render to the literal itself in en, and to another sentence in each shipped locale — the object label included', () => { + const i18n = i18nWithBundle(); + const renderer = new NotificationTextRenderer({ getData: () => undefined, getI18n: () => i18n }); + const offenders: string[] = []; + for (const e of emits) { + const site = siteOf(e); + const keys = e.keyedText!; + const en = renderer.render(keys, SENTINEL, 'en'); + if (en.title !== e.payload?.title) offenders.push(`${site} en title: ${en.title}`); + if (keys.bodyKey && en.body !== e.payload?.body) offenders.push(`${site} en body: ${en.body}`); + for (const locale of ['zh-CN', 'ja-JP', 'es-ES']) { + const out = renderer.render(keys, SENTINEL, locale); + if (out.title === SENTINEL.title || out.title === e.payload?.title) offenders.push(`${site} ${locale} title not rendered`); + if (!keys.bodyKey) continue; + if (out.body === SENTINEL.body || out.body === e.payload?.body || out.body.includes('{{')) { + offenders.push(`${site} ${locale} body: ${out.body}`); + } + // Every body sentence names the record; in this locale, by this locale's label. + if (!out.body.includes(`${DEAL_LABELS[locale]} D_`)) offenders.push(`${site} ${locale} body names no localized record: ${out.body}`); + } + } + expect(offenders).toEqual([]); + }); + + it('every shipped locale carries every sentence, with the same holes', () => { + const holes = (text: string) => [...text.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort(); + for (const [locale, messages] of Object.entries(approvalMessages)) { + expect(Object.keys(messages).sort(), locale).toEqual(Object.keys(enMessages).sort()); + for (const [id, text] of Object.entries(messages)) { + expect(holes(text), `${locale} ${id}`).toEqual(holes(enMessages[id as keyof typeof enMessages])); + } + } + }); +}); + +describe('#24978 — end to end: a real messaging service renders each approver\'s notification in their language', () => { + let engine: ObjectQL; + let now = Date.parse('2026-10-01T09:00:00.000Z'); + + beforeAll(async () => { + engine = await bootEngine([ + { id: 'u_sub', name: 'Sun Submitter', email: 'sun@example.com', locale: 'zh-CN' }, + { id: 'u_zh', name: 'Zhang', email: 'zhang@example.com', locale: 'zh-CN' }, + { id: 'u_en', name: 'Ada', email: 'ada@example.com', locale: 'en' }, + { id: 'u_none', name: 'Nobody', email: 'nobody@example.com' }, + ]); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + const inboxOf = async (userId: string) => { + const rows = (await engine.find('sys_inbox_message', { + where: { user_id: userId }, fields: ['topic', 'title', 'body_md'], + }, { context: SYSTEM } as any)) as any[]; + return rows.map((r) => ({ topic: r.topic, title: r.title, body: r.body_md })); + }; + + it('one opening: zh-CN in Chinese, en in English, no locale in the deployment default (ja-JP); the outcome reaches the zh-CN submitter in Chinese', async () => { + const i18n = i18nWithBundle('ja-JP'); + const getData = () => engine as any; + const messaging = new MessagingService({ + logger: silent, + getData, + textRenderer: new NotificationTextRenderer({ + getData, getI18n: () => i18n, getDefaultLocale: () => i18n.getDefaultLocale(), logger: silent, + }), + }); + messaging.registerChannel(createInboxChannel({ getData })); + const svc = approvalService(engine, () => new Date(now += 1000)); + svc.attachMessaging(messaging as any); + + await engine.insert('crm_deal', { id: 'D_1', title: 'Big deal', organization_id: ORG }, { context: SYSTEM } as any); + const request: any = await svc.openNodeRequest({ + object: 'crm_deal', recordId: 'D_1', runId: 'run_1', nodeId: 'review', flowName: FLOW.name, + config: { + approvers: [{ type: 'user', value: 'u_zh' }, { type: 'user', value: 'u_en' }, { type: 'user', value: 'u_none' }], + behavior: 'first_response', + } as any, + submitterId: 'u_sub', record: { id: 'D_1', title: 'Big deal' }, + }, asUser('u_sub')); + + expect(await inboxOf('u_zh')).toEqual([ + { topic: 'approval.requested', title: '新的审批请求', body: '商机 D_1 正在等待您的审批决定。' }, + ]); + expect(await inboxOf('u_en')).toEqual([ + { topic: 'approval.requested', title: 'Approval requested', body: 'A decision on Deal D_1 is waiting on you.' }, + ]); + expect(await inboxOf('u_none')).toEqual([ + { topic: 'approval.requested', title: '承認依頼', body: '商談 D_1 について、あなたの承認判断が必要です。' }, + ]); + + await svc.decide(request.id, { decision: 'approve', actorId: 'u_en', comment: 'Looks good — ship it.' }, asUser('u_en')); + expect(await inboxOf('u_sub')).toEqual([ + { topic: 'approval.approved', title: '请求已批准', body: '您的商机 D_1 已获批准。\n\nLooks good — ship it.' }, + ]); + }); +}); diff --git a/packages/plugins/plugin-approvals/vitest.config.ts b/packages/plugins/plugin-approvals/vitest.config.ts index 3276ecb5927..9722ccb1360 100644 --- a/packages/plugins/plugin-approvals/vitest.config.ts +++ b/packages/plugins/plugin-approvals/vitest.config.ts @@ -41,6 +41,15 @@ export default defineConfig({ find: /^@objectstack\/lint$/, replacement: path.resolve(__dirname, '../../lint/src/index.ts'), }, + // [#24978] `approval-notification-keyed-text.integration.test.ts` hands + // the REAL approval notifications to the REAL messaging service, which + // renders them per recipient at delivery — so the verdict must be the + // current renderer's, not a `service-messaging/dist` build's. Same + // #7668/#7778 reason as the entries above; anchored, as they are. + { + find: /^@objectstack\/service-messaging$/, + replacement: path.resolve(__dirname, '../../services/service-messaging/src/index.ts'), + }, ], }, test: { diff --git a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts new file mode 100644 index 00000000000..cee7affa9ab --- /dev/null +++ b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts @@ -0,0 +1,426 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24978] A notification whose text is keyed (`EmitInput.keyedText`) reaches + * each recipient in that recipient's language, rendered at delivery; one whose + * text is not keyed reaches them exactly as before. + * + * ## What was measured + * + * `emit()` took the text a recipient reads only as `payload.title` / + * `payload.body`, literals the producer built before any recipient existed. On + * HotCRM every approval notification reached a zh-CN approver in English. + * + * ## What this pins, on a real `ObjectQL` over `SqlDriver` (better-sqlite3) + * + * With the kernel's own in-memory `i18n` service holding the catalog, the real + * `MessagingService`, the real inbox, email and SMS channels (fake transports), + * the real SQL outbox and the real dispatcher: + * + * - a zh-CN recipient gets the zh-CN sentence, its object label included + * (a keyed param), on the inline fan-out and on every channel of the + * durable path — and a bare `zh` reaches the `zh-CN` pack; + * - a recipient with no locale gets the deployment default's sentence; + * - a key with no entry in the recipient's locale falls back as designed: to + * the catalog's declared fallback locale when the deployment declares one, + * else to the emitter's literal — and a key no locale holds is the literal; + * - a literal-only emit is byte-unchanged: the same inbox row, mail, text and + * delivery snapshot as a service with no renderer at all, and no locale read; + * - each item of a digest is rendered in its recipient's language; + * - the snapshot reserves `keyedText`: a producer payload member of that name + * is never rendered, and a malformed `keyedText` is refused before anything + * is written. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createMemoryI18n } from '@objectstack/core'; +import { SysNotification } from '@objectstack/platform-objects'; +import { MessagingService, NOTIFICATION_EVENT_OBJECT, type EmitInput } from './messaging-service.js'; +import { createInboxChannel, INBOX_OBJECT } from './inbox-channel.js'; +import { createEmailChannel } from './email-channel.js'; +import { createSmsChannel } from './sms-channel.js'; +import { NotificationTemplateStore } from './template-renderer.js'; +import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js'; +import { MemoryNotificationOutbox } from './memory-outbox.js'; +import { NotificationDispatcher } from './dispatcher.js'; +import { NotificationTextRenderer } from './notification-text.js'; +import type { PreferenceResolver } from './preference-resolver.js'; +import { + InboxMessage, + NotificationDelivery, + NotificationPreference, + NotificationReceipt, + NotificationTemplate, +} from './objects/index.js'; + +const SYSTEM = { context: { isSystem: true } as ExecutionContext }; +const ORG = 'org_1'; + +// Declared as the platform declares them (better-auth owns both tables), with +// the columns the channels read off a recipient: address, number, locale. +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; +const SYS_USER = { + name: 'sys_user', label: 'User', managedBy: 'better-auth', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + phone_number: { type: 'text', label: 'Phone' }, + locale: { type: 'text', label: 'Locale' }, + }, +}; + +const USERS = [ + { id: 'u_zh', name: 'Zhang', email: 'zhang@example.com', phone_number: '+8613800000001', locale: 'zh-CN' }, + { id: 'u_bare_zh', name: 'Zhou', email: 'zhou@example.com', phone_number: '+8613800000002', locale: 'zh' }, + { id: 'u_en', name: 'Ada', email: 'ada@example.com', phone_number: '+15550000001', locale: 'en' }, + { id: 'u_ja', name: 'Sato', email: 'sato@example.com', phone_number: '+81300000001', locale: 'ja-JP' }, + { id: 'u_none', name: 'Nobody', email: 'nobody@example.com', phone_number: '+15550000002' }, +]; + +/** The catalog, as a producer's translation bundle would contribute it. */ +const CATALOG: Record> = { + en: { + messages: { orderShippedTitle: 'Order shipped', orderShippedBody: 'Your {{object}} {{record}} is on its way.' }, + }, + 'zh-CN': { + messages: { orderShippedTitle: '订单已发货', orderShippedBody: '您的{{object}} {{record}} 已发出。' }, + objects: { crm_order: { label: '订单' } }, + }, + // ja-JP holds an object label and NO message entries — the "no entry in + // the recipient's locale" leg. + 'ja-JP': { + objects: { crm_order: { label: '注文' } }, + }, +}; + +const KEYED = { + titleKey: 'messages.orderShippedTitle', + bodyKey: 'messages.orderShippedBody', + params: { object: { key: 'objects.crm_order.label', fallback: 'Order' }, record: 'O_7' }, +} as const; + +/** The literal the emitter carries — what a recipient reads when nothing can be rendered. */ +const LITERAL = { title: 'Order shipped', body: 'Your Order O_7 is on its way.' }; +const ZH = { title: '订单已发货', body: '您的订单 O_7 已发出。' }; + +function recordingLogger() { + const warnings: string[] = []; + const logger: any = { + info: () => {}, error: () => {}, debug: () => {}, + warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }, + }; + logger.child = () => logger; + return { logger, warnings }; +} + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +/** A real engine on the `single` posture with one organization and the five recipients. */ +async function boot(): Promise { + const ql = new ObjectQL(); + ql.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await ql.init(); + for (const def of [ + SYS_ORGANIZATION, SYS_USER, SysNotification, InboxMessage, NotificationReceipt, + NotificationDelivery, NotificationPreference, NotificationTemplate, + ]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => 'single'); + await ql.insert('sys_organization', { id: ORG, name: ORG }, SYSTEM); + for (const u of USERS) await ql.insert('sys_user', u, SYSTEM); + engine = ql; + return ql; +} + +/** The kernel's in-memory i18n service with {@link CATALOG} loaded. */ +function catalog(opts: { defaultLocale?: string; fallbackLocale?: string } = {}) { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(CATALOG)) i18n.loadTranslations(locale, data); + if (opts.defaultLocale) i18n.setDefaultLocale(opts.defaultLocale); + if (opts.fallbackLocale) i18n.setFallbackLocale(opts.fallbackLocale); + return i18n; +} + +/** Counts the `sys_user` locale reads the renderer makes. */ +function countingData(ql: ObjectQL) { + let localeReads = 0; + const data = new Proxy(ql as any, { + get(target, prop, receiver) { + if (prop === 'findOne') { + return async (object: string, query: any, options?: any) => { + const fields: unknown = query?.fields; + if (object === 'sys_user' && Array.isArray(fields) && fields.length === 1 && fields[0] === 'locale') localeReads += 1; + return target.findOne(object, query, options); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return { data, localeReads: () => localeReads }; +} + +interface Composition { + messaging: MessagingService; + mail: any[]; + texts: any[]; + warnings: string[]; + localeReads: () => number; + dispatcher?: NotificationDispatcher; +} + +/** + * The plugin's composition, by hand: ONE renderer handed to the service and to + * the dispatcher, and the inbox / email / SMS channels on fake transports. + * `renderer: false` composes no renderer at all — the service as it was before + * keyed text existed, for the byte-unchanged control. + */ +function compose( + ql: ObjectQL, + opts: { + i18n?: ReturnType; + deploymentDefault?: string; + renderer?: boolean; + outbox?: 'sql' | 'memory'; + preferenceResolver?: PreferenceResolver; + } = {}, +): Composition { + const { logger, warnings } = recordingLogger(); + const { data, localeReads } = countingData(ql); + const getData = () => data; + const i18n = opts.i18n ?? catalog(); + const textRenderer = opts.renderer === false ? undefined : new NotificationTextRenderer({ + getData, + getI18n: () => i18n, + getDefaultLocale: () => opts.deploymentDefault, + logger, + }); + const outbox = opts.outbox === 'sql' + ? new SqlNotificationOutbox(ql as any, { partitionCount: 1 }) + : opts.outbox === 'memory' ? new MemoryNotificationOutbox(1) : undefined; + const messaging = new MessagingService({ + logger, getData, textRenderer, outbox, + ...(opts.preferenceResolver ? { preferenceResolver: opts.preferenceResolver } : {}), + }); + const mail: any[] = []; + const texts: any[] = []; + const store = new NotificationTemplateStore({ getData }); + messaging.registerChannel(createInboxChannel({ getData })); + messaging.registerChannel(createEmailChannel({ + getEmail: () => ({ async send(input: any) { mail.push(input); return { id: `mail_${mail.length}` }; } }), + getData, store, + })); + messaging.registerChannel(createSmsChannel({ + getSms: () => ({ async send(input: any) { texts.push(input); return { messageId: `sms_${texts.length}` }; } }), + getData, store, + })); + const dispatcher = outbox + ? new NotificationDispatcher({ + nodeId: 'node-test', outbox, channels: messaging, channelContext: { logger }, + partitionCount: 1, intervalMs: 10_000, textRenderer, + }) + : undefined; + return { messaging, mail, texts, warnings, localeReads, dispatcher }; +} + +async function inboxOf(ql: ObjectQL, userId: string): Promise> { + const rows = (await ql.find(INBOX_OBJECT, { where: { user_id: userId }, fields: ['title', 'body_md'] }, SYSTEM)) ?? []; + return rows.map((r: any) => ({ title: r.title, body_md: r.body_md })); +} + +const emitTo = (audience: string[], over: Partial = {}): EmitInput => ({ + topic: 'order.shipped', audience, organizationId: ORG, channels: ['inbox'], + payload: { ...LITERAL }, keyedText: KEYED, ...over, +}); + +describe('#24978 — keyed notification text, inline fan-out', () => { + it('a zh-CN recipient gets the zh-CN sentence, object label included; a bare `zh` reaches the zh-CN pack', async () => { + const ql = await boot(); + const { messaging, localeReads } = compose(ql); + + const result = await messaging.emit(emitTo(['u_zh', 'u_bare_zh', 'u_en'])); + + expect(result.delivered).toBe(3); + expect(await inboxOf(ql, 'u_zh')).toEqual([{ title: ZH.title, body_md: ZH.body }]); + expect(await inboxOf(ql, 'u_bare_zh')).toEqual([{ title: ZH.title, body_md: ZH.body }]); + // The control: the same emit reads in English for the English recipient. + expect(await inboxOf(ql, 'u_en')).toEqual([{ title: LITERAL.title, body_md: LITERAL.body }]); + // One locale read per recipient, not per channel. + expect(localeReads()).toBe(3); + }); + + it('a recipient with no locale gets the deployment default', async () => { + const ql = await boot(); + const { messaging } = compose(ql, { deploymentDefault: 'zh-CN' }); + + await messaging.emit(emitTo(['u_none', 'u_en'])); + + expect(await inboxOf(ql, 'u_none')).toEqual([{ title: ZH.title, body_md: ZH.body }]); + // The recipient's own locale outranks the deployment default. + expect(await inboxOf(ql, 'u_en')).toEqual([{ title: LITERAL.title, body_md: LITERAL.body }]); + }); + + it("a key with no entry in the recipient's locale, and no declared fallback, delivers the literal — said once", async () => { + const ql = await boot(); + const { messaging, warnings } = compose(ql); + + await messaging.emit(emitTo(['u_ja'])); + await messaging.emit(emitTo(['u_ja'], { dedupKey: 'again' })); + + // The object label HAS a ja-JP entry, but it fills a hole of a sentence + // that was not rendered: the literal is the producer's whole text. + expect(await inboxOf(ql, 'u_ja')).toEqual([ + { title: LITERAL.title, body_md: LITERAL.body }, + { title: LITERAL.title, body_md: LITERAL.body }, + ]); + // Said once per key and locale, however many deliveries miss it. + expect(warnings.filter((w) => w.includes('messages.orderShippedBody') && w.includes('ja-JP'))).toHaveLength(1); + }); + + it("a key with no entry in the recipient's locale falls to the catalog's declared fallback locale", async () => { + const ql = await boot(); + // The deployment declares `en` as its fallback: the catalog's own + // second leg answers, and the ja-JP object label still fills its hole. + const { messaging } = compose(ql, { i18n: catalog({ fallbackLocale: 'en' }) }); + + await messaging.emit(emitTo(['u_ja'])); + + expect(await inboxOf(ql, 'u_ja')).toEqual([{ title: 'Order shipped', body_md: 'Your 注文 O_7 is on its way.' }]); + }); + + it('a key no locale holds delivers the literal, member by member', async () => { + const ql = await boot(); + const { messaging, warnings } = compose(ql, { i18n: catalog({ fallbackLocale: 'en' }) }); + + await messaging.emit(emitTo(['u_zh'], { + keyedText: { titleKey: 'messages.orderShippedTitle', bodyKey: 'messages.noSuchSentence', params: KEYED.params }, + })); + + // The title renders; the body has no entry anywhere and is the literal. + expect(await inboxOf(ql, 'u_zh')).toEqual([{ title: ZH.title, body_md: LITERAL.body }]); + expect(warnings.some((w) => w.includes("'messages.noSuchSentence'"))).toBe(true); + }); + + it('a literal-only emit is byte-unchanged on every channel, and reads no locale', async () => { + const run = async (renderer: boolean) => { + const ql = await boot(); + const c = compose(ql, { renderer, deploymentDefault: 'zh-CN' }); + await c.messaging.emit({ + topic: 'order.shipped', audience: ['u_zh'], organizationId: ORG, + channels: ['inbox', 'email', 'sms'], payload: { ...LITERAL }, + }); + const inbox = await ql.find(INBOX_OBJECT, { fields: ['user_id', 'topic', 'title', 'body_md', 'severity', 'action_url'] }, SYSTEM); + const out = { inbox, mail: c.mail, texts: c.texts, localeReads: c.localeReads() }; + await ql.destroy(); + engine = undefined; + return out; + }; + const withRenderer = await run(true); + const withoutRenderer = await run(false); + + expect(withRenderer).toEqual(withoutRenderer); + expect(withRenderer.inbox).toEqual([expect.objectContaining({ title: LITERAL.title, body_md: LITERAL.body })]); + expect(withRenderer.mail).toEqual([expect.objectContaining({ subject: LITERAL.title, text: LITERAL.body })]); + expect(withRenderer.texts).toEqual([expect.objectContaining({ body: LITERAL.body })]); + expect(withRenderer.localeReads).toBe(0); + }); +}); + +describe('#24978 — keyed notification text, durable path (SQL outbox + dispatcher)', () => { + it('every channel delivers the zh-CN sentence; the stored snapshot carries the keys beside the literal', async () => { + const ql = await boot(); + const { messaging, dispatcher, mail, texts } = compose(ql, { outbox: 'sql' }); + + const result = await messaging.emit(emitTo(['u_zh', 'u_en'], { channels: ['inbox', 'email', 'sms'] })); + expect(result.enqueued).toBe(6); + + // Stored as the keys and the literal — nothing is rendered at emit. + const rows = (await ql.find(DELIVERY_OBJECT, { fields: ['recipient_id', 'payload'] }, SYSTEM)) as any[]; + expect(rows).toHaveLength(6); + for (const row of rows) { + const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; + expect(payload).toMatchObject({ ...LITERAL, keyedText: KEYED }); + } + + await dispatcher!.tick(); + + expect(await inboxOf(ql, 'u_zh')).toEqual([{ title: ZH.title, body_md: ZH.body }]); + expect(await inboxOf(ql, 'u_en')).toEqual([{ title: LITERAL.title, body_md: LITERAL.body }]); + const mailTo = (address: string) => mail.filter((m) => m.to === address); + expect(mailTo('zhang@example.com')).toEqual([expect.objectContaining({ subject: ZH.title, text: ZH.body })]); + expect(mailTo('ada@example.com')).toEqual([expect.objectContaining({ subject: LITERAL.title, text: LITERAL.body })]); + const textTo = (phone: string) => texts.filter((t) => t.to === phone); + expect(textTo('+8613800000001')).toEqual([expect.objectContaining({ body: ZH.body })]); + expect(textTo('+15550000001')).toEqual([expect.objectContaining({ body: LITERAL.body })]); + }); + + it('a literal-only snapshot carries no keyedText — and a producer payload member of that name is never rendered', async () => { + const ql = await boot(); + const { messaging, dispatcher } = compose(ql, { outbox: 'sql' }); + + await messaging.emit({ + topic: 'order.shipped', audience: ['u_zh'], organizationId: ORG, channels: ['inbox'], + // A flow author's free-form payload can spell anything; this name + // is the snapshot's, and the producer's value is dropped there. + payload: { ...LITERAL, keyedText: KEYED }, + }); + const [row] = (await ql.find(DELIVERY_OBJECT, { fields: ['payload'] }, SYSTEM)) as any[]; + const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; + expect(payload).not.toHaveProperty('keyedText'); + + await dispatcher!.tick(); + expect(await inboxOf(ql, 'u_zh')).toEqual([{ title: LITERAL.title, body_md: LITERAL.body }]); + }); + + it("each item of a digest is rendered in its recipient's language", async () => { + const ql = await boot(); + // Every recipient batches this topic into one window. + const preferenceResolver = { + async filter(recipients: string[], channels: string[]) { + return recipients.map((recipient) => ({ recipient, channels, digest: { window: '2026-10-20' } })); + }, + } as unknown as PreferenceResolver; + const { messaging, dispatcher } = compose(ql, { outbox: 'memory', preferenceResolver }); + + await messaging.emit(emitTo(['u_zh'], { dedupKey: 'a' })); + await messaging.emit(emitTo(['u_zh'], { + dedupKey: 'b', + keyedText: { ...KEYED, params: { ...KEYED.params, record: 'O_8' } }, + payload: { title: LITERAL.title, body: 'Your Order O_8 is on its way.' }, + })); + await dispatcher!.tick(); + + const [digest] = (await ql.find(INBOX_OBJECT, { where: { user_id: 'u_zh' }, fields: ['title', 'body_md'] }, SYSTEM)) as any[]; + // The items are listed by their titles, each rendered in zh-CN. + expect(digest.body_md).toBe(`• ${ZH.title}\n• ${ZH.title}`); + }); +}); + +describe('#24978 — the contract is closed', () => { + it('a malformed keyedText is refused before anything is written', async () => { + const ql = await boot(); + const { messaging } = compose(ql); + const bad: unknown[] = [ + { title: 'messages.orderShippedTitle' }, // undeclared member + { titleKey: 'orderShippedTitle' }, // one segment never names an entry + { titleKey: 'messages.orderShippedTitle', params: { 'a-b': 'x' } }, // a name no {{hole}} spells + { titleKey: 'messages.orderShippedTitle', params: { object: { key: 'objects.crm_order.label' } } }, + { params: { record: 'O_7' } }, // names no key at all + ]; + for (const keyedText of bad) { + await expect(messaging.emit(emitTo(['u_zh'], { keyedText: keyedText as any })), JSON.stringify(keyedText)) + .rejects.toBeInstanceOf(TypeError); + } + expect(await ql.find(NOTIFICATION_EVENT_OBJECT, { fields: ['id'] }, SYSTEM)).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6136050a3b..e1e99f0448f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1630,6 +1630,9 @@ importers: '@objectstack/service-automation': specifier: workspace:* version: link:../../services/service-automation + '@objectstack/service-messaging': + specifier: workspace:* + version: link:../../services/service-messaging '@objectstack/trigger-record-change': specifier: workspace:* version: link:../../triggers/trigger-record-change From 8149493c307d2dd06d295918579fbdeed61c7373 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:18:29 +0000 Subject: [PATCH 4/5] feat(service-messaging,plugin-approvals): changesets; recipient-locale names the keyed-text reader Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- ...approval-notifications-recipient-language.md | 12 ++++++++++++ .changeset/24978-keyed-notification-text.md | 17 +++++++++++++++++ .../service-messaging/src/recipient-locale.ts | 4 ++++ 3 files changed, 33 insertions(+) create mode 100644 .changeset/24978-approval-notifications-recipient-language.md create mode 100644 .changeset/24978-keyed-notification-text.md diff --git a/.changeset/24978-approval-notifications-recipient-language.md b/.changeset/24978-approval-notifications-recipient-language.md new file mode 100644 index 00000000000..b86f966f8ee --- /dev/null +++ b/.changeset/24978-approval-notifications-recipient-language.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +fix(plugin-approvals): approval notifications reach each recipient in their own language + +Every approval notification now hands the messaging service its sentence as catalog keys beside the English literal (`EmitInput.keyedText`). The messaging service renders it at delivery in each recipient's `sys_user.locale`, else the deployment default. This plugin's translation bundle gains the sentences in en, zh-CN, ja-JP and es-ES, in its `messages` face (`messages.approvalRequestedTitle` and the rest). + +- **Which notifications.** `approval.requested`, `approval.reminder`, `approval.sla_breached` (to the approvers and to the submitter), `approval.approved` and `approval.rejected`, the two out-of-office notices (`approval.ooo_substituted`, `approval.ooo_skipped`), `approval.escalated`, `approval.reassigned` and `approval.returned` (the send-back and the auto-rejection). For `approval.comment`, `approval.request_info` and a send-back that carries the approver's note, only the headline is keyed. The body is that person's own words, delivered as written. +- **The record is named as before:** the object's label and the record id (`Deal D_123`), never a title. The label is rendered from the app's `objects..label` when its language pack holds one, else the schema label. +- **The English is unchanged.** The payload's `title` and `body` are the `en` catalog entries, byte for byte the sentences each notification carried before. A messaging service that does not render keyed text delivers them as it always did. +- **Unchanged.** Who is notified, each notification's topic, payload fields, deep link, dedup key and organization, and every channel's delivery behaviour apart from the text. diff --git a/.changeset/24978-keyed-notification-text.md b/.changeset/24978-keyed-notification-text.md new file mode 100644 index 00000000000..a1bbda075d3 --- /dev/null +++ b/.changeset/24978-keyed-notification-text.md @@ -0,0 +1,17 @@ +--- +'@objectstack/service-messaging': minor +--- + +feat(service-messaging): a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language + +Clause-②: yes (widening): `EmitInput` gains an optional `keyedText` member, and the package's `.` entry gains `NotificationTextRenderer`, `assertNotificationTextKeys` and their types. An emit without `keyedText` is accepted and delivered exactly as before. + +- **What was wrong.** `emit()` took the text a recipient reads only as `payload.title` and `payload.body`, literals the producer builds before any recipient exists. So every recipient of a notification read the producer's language. The approvals plugin's notifications reached zh-CN, ja-JP and es-ES approvers in English. +- **The new member.** `EmitInput.keyedText` is `{ titleKey?, bodyKey?, params? }`. `titleKey` and `bodyKey` are `II18nService` keys made of a face and an id, such as `messages.approvalRequestedTitle`. A producer contributes their entries through its own translation bundle (`i18n.loadTranslations`, ADR-0029 D8). `params` fills the entries' `{{name}}` holes. A param is a string, or `{ key, fallback }`: the recipient-language text of `key` (such as `objects..label`), else `fallback`. +- **Where it renders.** At delivery, per recipient, before the channel is called: on the inline fan-out, on each row the dispatcher sends, and on each row of a digest group. The language is the recipient's own `sys_user.locale`, else the deployment default (the recipient-locale chain, `resolveRecipientLocale`), negotiated against the locales the `i18n` service holds. Every channel, a third party's included, receives the rendered `title` and `body` it already reads. +- **The fallback.** The literal `payload.title` and `payload.body` stay what a recipient reads whenever a key cannot be rendered: no `i18n` service, no renderer composed, or a key with no entry in the recipient's locale or in the catalog's declared fallback locale. A member the producer left unkeyed is also delivered as the literal. A key with no entry is said once per key and locale at `warn`. +- **Refused at emit.** A malformed `keyedText` throws a `TypeError` before anything is written: an undeclared member, a key without a face, a param name no `{{hole}}` can spell, or a keyed param without its `fallback`. +- **The delivery snapshot reserves `keyedText`,** the way it already reserves `title`, `body`, `severity`, `actionUrl` and `actorId`. The snapshot stores `EmitInput.keyedText` under that name only when the emit keyed its text. A producer's own payload member of that name (a flow `notify` node's free-form payload, say) is dropped from the snapshot and is never rendered. No producer in this repository writes one. +- **Cost.** One `sys_user` read per keyed delivery (one per digest group). A delivery without keys, or a deployment with no `i18n` service, makes no read. +- **Unchanged.** Every emit that passes no `keyedText` (and whose payload has no member of that name): the event row, the snapshot, and what the inbox, email and SMS channels deliver. The notify `template` path, `sys_notification_template` rendering (which now falls back to the rendered text), recipients, preferences and channel availability are also unchanged. +- **Composing it.** `MessagingServicePlugin` wires one `NotificationTextRenderer` into the service and the dispatcher. A hand-built `MessagingService` passes `textRenderer` in its context and a `NotificationDispatcher` passes it in its options. Without one, keyed text is delivered as its literal, and the service says so once. diff --git a/packages/services/service-messaging/src/recipient-locale.ts b/packages/services/service-messaging/src/recipient-locale.ts index c74cc7156ab..f6806023730 100644 --- a/packages/services/service-messaging/src/recipient-locale.ts +++ b/packages/services/service-messaging/src/recipient-locale.ts @@ -15,6 +15,10 @@ * has exactly one answer across email, inbox and SMS — a notification cannot * arrive in Chinese by mail and in English in the inbox. * + * [#24978] The same chain picks the language a KEYED notification text is + * rendered in (`EmitInput.keyedText`, `notification-text.ts`): rendered at + * delivery, ahead of every channel, through {@link resolveRecipientLocale}. + * * ## The chain * * 1. the recipient's own `sys_user.locale` ({@link RECIPIENT_LOCALE_FIELD}). From cbff799d0874395f8a472127f8f57a129bbe050c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:38:59 +0000 Subject: [PATCH 5/5] test: type the counting proxy's findOne, drop an any-erased find options Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/approval-notification-keyed-text.integration.test.ts | 2 +- .../src/keyed-notification-text.integration.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts index cbafa3de56a..f2c57b5c96a 100644 --- a/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts @@ -309,7 +309,7 @@ describe('#24978 — end to end: a real messaging service renders each approver\ const inboxOf = async (userId: string) => { const rows = (await engine.find('sys_inbox_message', { where: { user_id: userId }, fields: ['topic', 'title', 'body_md'], - }, { context: SYSTEM } as any)) as any[]; + }, { context: SYSTEM })) as any[]; return rows.map((r) => ({ topic: r.topic, title: r.title, body: r.body_md })); }; diff --git a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts index cee7affa9ab..ce9e253c521 100644 --- a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts +++ b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts @@ -157,10 +157,11 @@ function countingData(ql: ObjectQL) { const data = new Proxy(ql as any, { get(target, prop, receiver) { if (prop === 'findOne') { - return async (object: string, query: any, options?: any) => { + return async (...args: Parameters) => { + const [object, query] = args; const fields: unknown = query?.fields; if (object === 'sys_user' && Array.isArray(fields) && fields.length === 1 && fields[0] === 'locale') localeReads += 1; - return target.findOne(object, query, options); + return target.findOne(...args); }; } const value = Reflect.get(target, prop, receiver);