diff --git a/.changeset/25015-digest-heading-locale.md b/.changeset/25015-digest-heading-locale.md new file mode 100644 index 00000000000..eb5ca7a7914 --- /dev/null +++ b/.changeset/25015-digest-heading-locale.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-messaging': patch +--- + +fix(service-messaging): the heading of a notification digest of two or more items is rendered in its recipient's language, not as the English "You have N notifications" for everyone + +Clause-②: no +Metadata-protocol: none + +- **Before.** A recipient whose notification preference digests `daily` or `weekly` received each window's digest headed "You have N notifications" in English, whatever their language, even when every item under it was rendered in their language. +- **Now.** The heading is keyed (`messages.notificationDigestTitle`, with a `{{count}}` hole) in `@objectstack/service-messaging`'s own translation bundle, in en, zh-CN, ja-JP and es-ES. It is rendered at delivery by the same keyed-text renderer as the digest's items (`EmitInput.keyedText`), in the same locale read: the recipient's `sys_user.locale`, then the deployment default. A zh-CN recipient reads "您有 3 条通知". +- **Unchanged.** The English sentence stays the literal fallback, byte for byte: with no `i18n` service, with no renderer composed, or when neither the recipient's language nor the deployment default has a pack. A one-item digest is still titled by its item. No export, option or type changes. +- **Cost.** With an `i18n` service registered, each digest of two or more items now reads its recipient's locale once, even when none of its items is keyed, because its heading always is. diff --git a/packages/services/service-messaging/src/digest-render.ts b/packages/services/service-messaging/src/digest-render.ts index f779319c539..635fcb3c5bd 100644 --- a/packages/services/service-messaging/src/digest-render.ts +++ b/packages/services/service-messaging/src/digest-render.ts @@ -1,6 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { NotificationDeliveryRecord } from './outbox.js'; +import type { NotificationTextItem } from './notification-text.js'; +import { interpolate } from './template-renderer.js'; +import { enMessages } from './translations/messages.js'; /** * One collapsed digest message assembled from a window's batched deliveries @@ -22,13 +25,46 @@ export interface DigestRenderResult { }>; } +/** + * [#25015] The catalog key of the heading a digest of two or more items + * carries: this service's own entry (`./translations/messages.ts`, loaded with + * `MessagingTranslations`), rendered by the one keyed-text renderer + * (`NotificationTextRenderer`, the `EmitInput.keyedText` path) in the digest's + * recipient's language — `sys_user.locale`, then the deployment default + * (#13881). ⛔ There is no second renderer for it. + */ +export const DIGEST_HEADING_KEY = 'messages.notificationDigestTitle'; + +/** + * [#25015] The heading of a digest of `count` items (two or more), as the + * keyed-text renderer takes it: {@link DIGEST_HEADING_KEY} with its + * `{{count}}`, beside the literal a recipient reads whenever the key cannot be + * rendered — the `en` entry interpolated, byte for byte the English sentence + * every digest carried before its heading was keyed. A digest has no body + * sentence to key; its body is the item list {@link renderDigest} builds. + */ +export function digestHeading(count: number): NotificationTextItem { + const n = String(count); + return { + keys: { titleKey: DIGEST_HEADING_KEY, params: { count: n } }, + literal: { title: interpolate(enMessages.notificationDigestTitle, { count: n }), body: '' }, + }; +} + /** * Collapse same-`(recipient, channel, window)` deliveries into a single message. * The caller guarantees `rows` is non-empty and shares a recipient + channel * (the digest group). Only non-`critical` notifications are ever batched, so the * digest severity is always `info`. + * + * `heading` is the title of a digest of two or more items as rendered for its + * recipient — {@link digestHeading} through the keyed-text renderer, which the + * dispatcher runs with the group's items in one locale read. Omitted, the + * title is that heading's literal, which is also what the renderer delivers + * when the key cannot render. A one-item digest is titled by its item, and + * `heading` is not read. */ -export function renderDigest(rows: NotificationDeliveryRecord[]): DigestRenderResult { +export function renderDigest(rows: NotificationDeliveryRecord[], heading?: string): DigestRenderResult { const items = rows.map((r) => { const p = r.payload ?? {}; return { @@ -40,7 +76,7 @@ export function renderDigest(rows: NotificationDeliveryRecord[]): DigestRenderRe }; }); const count = items.length; - const title = count === 1 ? items[0].title : `You have ${count} notifications`; + const title = count === 1 ? items[0].title : (heading ?? digestHeading(count).literal.title); const body = items.map((it) => `• ${it.title}`).join('\n'); return { title, body, severity: 'info', count, items }; } diff --git a/packages/services/service-messaging/src/dispatcher.ts b/packages/services/service-messaging/src/dispatcher.ts index 94367f1f99f..a404506aa18 100644 --- a/packages/services/service-messaging/src/dispatcher.ts +++ b/packages/services/service-messaging/src/dispatcher.ts @@ -3,7 +3,7 @@ import type { MessagingChannel, MessagingChannelContext, Notification, SendResult } from './channel.js'; import type { AckResult, ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js'; import { classifyDeliveryAttempt } from './backoff.js'; -import { renderDigest } from './digest-render.js'; +import { digestHeading, renderDigest, type DigestRenderResult } from './digest-render.js'; import { readNotificationTextKeys, type NotificationTextRenderer } from './notification-text.js'; import { DispatchLoop } from '@objectstack/core'; @@ -295,7 +295,7 @@ export class NotificationDispatcher { return; } - const digest = renderDigest(await this.localizedForRecipient(recipient, rows)); + const digest = await this.localizedDigest(recipient, rows); const notification: Notification = { notificationId: rows[0].notificationId, // representative event id organizationId: rows[0].organizationId, @@ -329,18 +329,27 @@ 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. + * A digest group's collapsed message, its text rendered in the group's ONE + * recipient's language, with one locale read for the whole group: + * + * - [#24978] each keyed row's title and body, so the items the digest + * lists read in that language — a row that carries no keys is listed as + * it is; + * - [#25015] for a group of two or more, the heading + * ({@link digestHeading}), which this service keys from its own + * translation bundle, so it is ALWAYS keyed: the group's items and its + * heading go to the renderer in one call. + * + * With no renderer composed, every row and the heading are their literal + * text. */ - private async localizedForRecipient( + private async localizedDigest( recipient: string, rows: ClaimedDeliveryRecord[], - ): Promise { + ): Promise { + const heading = rows.length > 1 ? digestHeading(rows.length) : undefined; const renderer = this.opts.textRenderer; - if (!renderer) return rows; + if (!renderer) return renderDigest(rows, heading?.literal.title); const items = rows.map((row) => { const p = row.payload ?? {}; return { @@ -351,11 +360,11 @@ export class NotificationDispatcher { }, }; }); - if (!items.some((item) => item.keys)) return rows; - const rendered = await renderer.renderAllFor(recipient, items); - return rows.map((row, i) => items[i].keys + const rendered = await renderer.renderAllFor(recipient, heading ? [...items, heading] : items); + const localized = rows.map((row, i) => items[i].keys ? { ...row, payload: { ...row.payload, title: rendered[i].title, body: rendered[i].body } } : row); + return renderDigest(localized, heading ? rendered[rows.length].title : undefined); } private async processRow(row: ClaimedDeliveryRecord): Promise { 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 ce9e253c521..3ea8891f964 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 @@ -26,7 +26,10 @@ * 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; + * - each item of a digest is rendered in its recipient's language, and so + * is the heading of a digest of two or more (#25015), from this service's + * own bundle: the recipient's locale, then the deployment default, then the + * English literal — while a one-item digest keeps its item's own title; * - the snapshot reserves `keyedText`: a producer payload member of that name * is never rendered, and a malformed `keyedText` is refused before anything * is written. @@ -47,6 +50,9 @@ 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 { DIGEST_HEADING_KEY } from './digest-render.js'; +import { MessagingTranslations } from './translations/index.js'; +import { enMessages as messagingEnMessages, messagingMessages } from './translations/messages.js'; import type { PreferenceResolver } from './preference-resolver.js'; import { InboxMessage, @@ -78,6 +84,8 @@ const USERS = [ { 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' }, + // A language no pack in this deployment holds. + { id: 'u_fr', name: 'Dupont', email: 'dupont@example.com', phone_number: '+33100000001', locale: 'fr-FR' }, ]; /** The catalog, as a producer's translation bundle would contribute it. */ @@ -142,10 +150,19 @@ async function boot(): Promise { return ql; } -/** The kernel's in-memory i18n service with {@link CATALOG} loaded. */ -function catalog(opts: { defaultLocale?: string; fallbackLocale?: string } = {}) { +/** + * The kernel's in-memory i18n service with {@link CATALOG} loaded — and, with + * `messagingBundle`, this service's own `MessagingTranslations` too, loaded the + * way the plugin's `kernel:ready` hook loads it. + */ +function catalog(opts: { defaultLocale?: string; fallbackLocale?: string; messagingBundle?: boolean } = {}) { const i18n = createMemoryI18n(); for (const [locale, data] of Object.entries(CATALOG)) i18n.loadTranslations(locale, data); + if (opts.messagingBundle) { + for (const [locale, data] of Object.entries(MessagingTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + } if (opts.defaultLocale) i18n.setDefaultLocale(opts.defaultLocale); if (opts.fallbackLocale) i18n.setFallbackLocale(opts.fallbackLocale); return i18n; @@ -407,6 +424,107 @@ describe('#24978 — keyed notification text, durable path (SQL outbox + dispatc }); }); +describe("#25015 — a digest's heading is rendered in its recipient's language", () => { + /** Every recipient batches every topic into one window, on the memory outbox. */ + const digestAll = { + async filter(recipients: string[], channels: string[]) { + return recipients.map((recipient) => ({ recipient, channels, digest: { window: '2026-10-20' } })); + }, + } as unknown as PreferenceResolver; + + /** Emit `n` keyed notifications to `recipient` on `channels`, then run one dispatcher tick. */ + async function digestOf( + ql: ObjectQL, + c: Composition, + recipient: string, + n: number, + channels: string[] = ['inbox'], + ): Promise> { + for (let i = 0; i < n; i++) { + await c.messaging.emit(emitTo([recipient], { dedupKey: `d${i}`, channels })); + } + await c.dispatcher!.tick(); + return inboxOf(ql, recipient); + } + + it('a zh-CN recipient reads the heading in zh-CN, over items in zh-CN, on every channel', async () => { + const ql = await boot(); + const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) }); + + const inbox = await digestOf(ql, c, 'u_zh', 3, ['inbox', 'email']); + + expect(inbox).toEqual([{ title: '您有 3 条通知', body_md: `• ${ZH.title}\n• ${ZH.title}\n• ${ZH.title}` }]); + expect(c.mail).toEqual([expect.objectContaining({ to: 'zhang@example.com', subject: '您有 3 条通知' })]); + }); + + it('the English recipient of the same digest reads the English sentence — the control', async () => { + const ql = await boot(); + const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) }); + + const [digest] = await digestOf(ql, c, 'u_en', 2); + + expect(digest.title).toBe('You have 2 notifications'); + }); + + it('a recipient with no locale reads the deployment default', async () => { + const ql = await boot(); + const c = compose(ql, { + outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'ja-JP', + i18n: catalog({ messagingBundle: true, defaultLocale: 'ja-JP' }), + }); + + const [digest] = await digestOf(ql, c, 'u_none', 2); + + expect(digest.title).toBe('2 件の通知があります'); + }); + + it("a recipient whose language no pack holds reads the deployment default's pack", async () => { + const ql = await boot(); + const c = compose(ql, { + outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'zh-CN', + i18n: catalog({ messagingBundle: true, defaultLocale: 'zh-CN' }), + }); + + const [digest] = await digestOf(ql, c, 'u_fr', 2); + + expect(digest.title).toBe('您有 2 条通知'); + }); + + it('when neither the recipient\'s language nor the deployment default has a pack, the English literal — said once', async () => { + const ql = await boot(); + const c = compose(ql, { + outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'fr-FR', + i18n: catalog({ messagingBundle: true, defaultLocale: 'fr-FR' }), + }); + + const [digest] = await digestOf(ql, c, 'u_fr', 2); + + expect(digest.title).toBe('You have 2 notifications'); + expect(c.warnings.filter((w) => w.includes(`'${DIGEST_HEADING_KEY}'`) && w.includes('fr-FR'))).toHaveLength(1); + }); + + it("a one-item digest keeps its item's own title, rendered in the recipient's language", async () => { + const ql = await boot(); + const c = compose(ql, { outbox: 'memory', preferenceResolver: digestAll, i18n: catalog({ messagingBundle: true }) }); + + const inbox = await digestOf(ql, c, 'u_zh', 1); + + expect(inbox).toEqual([{ title: ZH.title, body_md: `• ${ZH.title}` }]); + }); + + it('every shipped locale carries every messaging sentence, with the same holes, and the bundle serves them', async () => { + const holes = (text: string) => [...text.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]).sort(); + expect(Object.keys(messagingMessages).sort()).toEqual(Object.keys(MessagingTranslations).sort()); + for (const [locale, messages] of Object.entries(messagingMessages)) { + expect(Object.keys(messages).sort(), locale).toEqual(Object.keys(messagingEnMessages).sort()); + for (const [id, text] of Object.entries(messages)) { + expect(holes(text), `${locale} ${id}`).toEqual(holes(messagingEnMessages[id as keyof typeof messagingEnMessages])); + } + expect(MessagingTranslations[locale]?.messages, locale).toEqual(messages); + } + }); +}); + describe('#24978 — the contract is closed', () => { it('a malformed keyedText is refused before anything is written', async () => { const ql = await boot(); diff --git a/packages/services/service-messaging/src/notification-text.ts b/packages/services/service-messaging/src/notification-text.ts index 9c6e7ab53a9..1f3fc95e469 100644 --- a/packages/services/service-messaging/src/notification-text.ts +++ b/packages/services/service-messaging/src/notification-text.ts @@ -38,7 +38,8 @@ * * 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 + * row of a digest group, with that digest's heading) 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. @@ -53,7 +54,9 @@ * * 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; + * service. A digest of two or more items always carries a key — its heading, + * which this service keys from its own bundle (#25015, `digest-render.ts`) — + * so each such group costs its one read. 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. */ diff --git a/packages/services/service-messaging/src/translations/index.ts b/packages/services/service-messaging/src/translations/index.ts index 0291eba52cf..1f85b8399e2 100644 --- a/packages/services/service-messaging/src/translations/index.ts +++ b/packages/services/service-messaging/src/translations/index.ts @@ -7,8 +7,10 @@ * service owns (sys_inbox_message / sys_notification_receipt / * sys_notification_delivery / sys_notification_preference / * sys_notification_template / sys_http_delivery). Loaded at runtime via the plugin's `kernel:ready` - * hook (`i18n.loadTranslations`). Regenerate with `os i18n extract` - * against `scripts/i18n-extract.config.ts`. + * hook (`i18n.loadTranslations`). Regenerate the `objects` bundles with `os i18n extract` + * against `scripts/i18n-extract.config.ts`; the `messages` entries — the + * sentences this service writes into a notification itself, today the digest + * heading (#25015) — are hand-authored in `./messages.ts`. */ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; @@ -20,6 +22,7 @@ import { esESObjects } from './es-ES.objects.generated.js'; 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 { messagingMessages } from './messages.js'; /** * ## The provenance companions are READ here, not merely recorded @@ -40,7 +43,9 @@ import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; * * `recorded` (3rd argument) stays `undefined` on purpose: it judges the * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does - * not have — its bundles are entirely generated. The companion goes in the 4th + * not have — its `objects` bundles are generated, and its hand-authored + * `messages` face is not a section that argument judges (the approvals + * plugin's bundle carries its `messages` the same way). The companion goes in the 4th * slot, which judges the generated ones. This is the shape * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. * @@ -48,11 +53,26 @@ import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.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 }; +const enSource: TranslationData = { + objects: enObjects, + // [#25015] The sentences this service writes into a notification itself + // (`./messages.ts`), read by its own renderer at delivery in each + // recipient's language. + messages: messagingMessages.en, +}; export const MessagingTranslations: TranslationBundle = { en: enSource, - 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), - 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), - 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), + 'zh-CN': withSourceFallback( + { objects: zhCNObjects, messages: messagingMessages['zh-CN'] }, + enSource, undefined, zhCNGeneratedSourceHashes, + ), + 'ja-JP': withSourceFallback( + { objects: jaJPObjects, messages: messagingMessages['ja-JP'] }, + enSource, undefined, jaJPGeneratedSourceHashes, + ), + 'es-ES': withSourceFallback( + { objects: esESObjects, messages: messagingMessages['es-ES'] }, + enSource, undefined, esESGeneratedSourceHashes, + ), }; diff --git a/packages/services/service-messaging/src/translations/messages.ts b/packages/services/service-messaging/src/translations/messages.ts new file mode 100644 index 00000000000..5715018d2e6 --- /dev/null +++ b/packages/services/service-messaging/src/translations/messages.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25015] The sentences this service itself writes into a notification — the + * catalog entries its renderer (`NotificationTextRenderer`, the + * `EmitInput.keyedText` path) reads, per recipient and at delivery, in that + * recipient's language. + * + * HAND-AUTHORED, in the shape `@objectstack/plugin-approvals` and + * `@objectstack/plugin-audit` give their notification sentences: this bundle's + * `messages` face, loaded into the `i18n` service with the rest of + * `MessagingTranslations` (ADR-0029 D8), and read by + * `t('messages.', locale, params)`. + * + * Today that is one sentence: the heading of a digest of two or more items + * (`digest-render.ts`). A one-item digest carries that item's own title, so + * this heading is never rendered for fewer than two. + * + * ## 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. The + * `messages` face is shared by every bundle loaded into the service, so an id + * names its subject (`notificationDigest…`), never a bare `title`. + * - `en` IS the literal text. `digest-render.ts` builds the heading a recipient + * reads whenever the key cannot be rendered — no `i18n` service, no renderer + * composed, no entry in any locale it consults — by interpolating the `en` + * entry, so the English is one string whichever path delivered it, and it is + * byte for byte the heading every digest carried before it was keyed. + * - Every locale carries every id with the same `{{holes}}`: + * `keyed-notification-text.integration.test.ts` pins both. + * + * ## The holes, and why there is no plural form + * + * - `{{count}}` — the number of notifications the digest collapses, two or + * more. + * + * Neither `II18nService` implementation selects a plural form: `t()` + * interpolates `{{name}}` holes and nothing else. None is needed for the + * locales shipped here, because the count is always two or more, and in each + * of them every count from two up takes ONE form (en and es-ES: the plural; + * zh-CN and ja-JP: no plural inflection). A locale whose form varies above two + * (Polish, Russian, Arabic) would need plural selection in the catalog, which + * is the `i18n` service's to add, not this bundle's. + */ + +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 = { + notificationDigestTitle: 'You have {{count}} notifications', +} as const satisfies Messages; + +/** One messaging sentence's id. */ +export type MessagingMessageId = keyof typeof enMessages; + +type MessagingMessages = Readonly>; + +export const zhCNMessages: MessagingMessages = { + notificationDigestTitle: '您有 {{count}} 条通知', +}; + +export const jaJPMessages: MessagingMessages = { + notificationDigestTitle: '{{count}} 件の通知があります', +}; + +export const esESMessages: MessagingMessages = { + notificationDigestTitle: 'Tiene {{count}} notificaciones', +}; + +/** The four shipped locales, keyed as the bundle beside this file keys them. */ +export const messagingMessages: Readonly> = { + en: enMessages, + 'zh-CN': zhCNMessages, + 'ja-JP': jaJPMessages, + 'es-ES': esESMessages, +};