Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/25015-digest-heading-locale.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 38 additions & 2 deletions packages/services/service-messaging/src/digest-render.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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 };
}
35 changes: 22 additions & 13 deletions packages/services/service-messaging/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ClaimedDeliveryRecord[]> {
): Promise<DigestRenderResult> {
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 {
Expand All @@ -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<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -142,10 +150,19 @@ async function boot(): Promise<ObjectQL> {
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<string, unknown>);
}
}
if (opts.defaultLocale) i18n.setDefaultLocale(opts.defaultLocale);
if (opts.fallbackLocale) i18n.setFallbackLocale(opts.fallbackLocale);
return i18n;
Expand Down Expand Up @@ -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<Array<{ title: string; body_md: string }>> {
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();
Expand Down
7 changes: 5 additions & 2 deletions packages/services/service-messaging/src/notification-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*/

Expand Down
Loading
Loading