diff --git a/.changeset/26167-activity-summary-reader-locale.md b/.changeset/26167-activity-summary-reader-locale.md new file mode 100644 index 00000000000..07b3304e247 --- /dev/null +++ b/.changeset/26167-activity-summary-reader-locale.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): an activity summary reads in the reader's language, not the language of the write + +Clause-②: no (no exported type, schema field or spec key changes; the code rides in the existing `sys_activity.metadata` JSON, and `summary` keeps its type and meaning) +Metadata-protocol: none + +The CRUD mirror now stores two things for each summary: `summary` holds the English sentence, as the fallback text for any reader, search and export, and `metadata.summary_message` holds the message key plus its params (object, record label, field names, from/to values with option values). A new `sys_activity` read middleware composes the summary again in the caller's negotiated locale (from `Accept-Language`, else the workspace locale) with the object, field and option labels of that reader's language pack. A locale with no template, or a code this version cannot compose, keeps the stored sentence. The field redaction still judges the composed text, and the code is served only to system callers. + +The write no longer asks for a locale for the summary, so a workspace set to Chinese stores the English sentence: full-text search over `summary` matches the English words. `getLocale` / the localization settings still pick the language of @mention notification titles. + +Rows written before this change carry no code: they keep the sentence they were written with, for every reader. There is no migration or backfill, and no row is dropped. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e893a0499a8..d47853e81b8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -171,7 +171,7 @@ One of the largest consumers of the flag; | 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` | | 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` | | 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` | -| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | +| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), the activity summary's message code (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, and the activity row's stored summary code (`metadata.summary_message`), on `find` / `findOne` — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on `count` and `aggregate` too, so a total agrees with the rows), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/activity-summary-message.ts#installActivitySummaryLocale`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | | 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` | | 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` | | 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest`, `#authorizeRematch` | diff --git a/packages/plugins/plugin-audit/src/__tests__/summary-as-read.ts b/packages/plugins/plugin-audit/src/__tests__/summary-as-read.ts new file mode 100644 index 00000000000..21a39fb78c9 --- /dev/null +++ b/packages/plugins/plugin-audit/src/__tests__/summary-as-read.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26167] A stored `sys_activity` row's `summary` as a reader in `locale` is + * served it, for suites that write through the audit writer and read the row + * straight from their store (no engine read, so no middleware ran). It applies + * the read side's own overlay (`localizeActivityRow`) to a copy of the row, so + * what it answers is what `installActivitySummaryLocale` serves that reader. + */ + +import { + localizeActivityRow, + readerSummaryLocale, + type ActivitySummaryI18n, +} from '../activity-summary-message.js'; + +/** The engine slice a summary's labels are read from. */ +interface DefinitionSource { + getSchema?(objectName: string): unknown; +} + +export function summaryAsRead( + row: Record | undefined, + i18n: unknown, + locale: string | undefined, + engine: DefinitionSource, +): unknown { + if (!row) return undefined; + const copy: Record = { ...row }; + const reader = readerSummaryLocale(i18n as ActivitySummaryI18n | undefined, locale, (objectName) => + typeof engine.getSchema === 'function' ? engine.getSchema(objectName) : null, + ); + localizeActivityRow(copy, reader, false); + return copy.summary; +} diff --git a/packages/plugins/plugin-audit/src/activity-summary-message.test.ts b/packages/plugins/plugin-audit/src/activity-summary-message.test.ts new file mode 100644 index 00000000000..a00b064f8d6 --- /dev/null +++ b/packages/plugins/plugin-audit/src/activity-summary-message.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26167] The summary code's composer, and the read-side overlay, against a + * REAL locale: `createMemoryI18n` (what the kernel registers as the `i18n` + * fallback) loaded with this plugin's shipped bundle plus an app's labels, so + * the key shapes asked are the bundles' own. The real read path is pinned in + * `activity-summary-reader-locale.integration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; + +import { AuditTranslations } from './translations/index.js'; +import { + ACTIVITY_SUMMARY_MESSAGE_KEY, + composeActivitySummary, + composeStoredSummary, + localizeActivityRow, + readActivitySummaryMessage, + readerSummaryLocale, + type ActivitySummaryMessage, +} from './activity-summary-message.js'; + +const DEFS: Record = { + biz_deal: { + name: 'biz_deal', + label: 'Deal', + fields: { + stage: { label: 'Stage', options: [{ value: 'won', label: 'Won' }] }, + amount: { label: 'Amount' }, + owner: { label: 'Owner' }, + }, + }, +}; +const getObjectDef = (name: string) => DEFS[name] ?? null; + +function makeI18n() { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(AuditTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + i18n.loadTranslations('zh-CN', { + objects: { + biz_deal: { + label: '商机', + fields: { stage: { label: '阶段', options: { won: '赢单' } }, amount: { label: '金额' } }, + }, + }, + }); + return i18n; +} + +const zh = () => readerSummaryLocale(makeI18n(), 'zh-CN', getObjectDef)!; +const ja = () => readerSummaryLocale(makeI18n(), 'ja-JP', getObjectDef)!; + +const CREATED: ActivitySummaryMessage = { key: 'activityCreated', object: 'biz_deal', label: 'Acme' }; +const NAMED: ActivitySummaryMessage = { + key: 'activityUpdatedFields', + object: 'biz_deal', + label: 'Acme', + fields: ['amount', 'owner'], + more: 2, +}; +const CHANGES: ActivitySummaryMessage = { + key: 'fieldChanges', + object: 'biz_deal', + changes: [ + { field: 'stage', from: { text: '∅' }, to: { text: 'Won', option: 'won' } }, + { field: 'owner', from: { text: '张伟' }, to: { text: 'Ana' } }, + ], +}; + +describe('composeActivitySummary — one code, each reader\'s language (#26167)', () => { + it('composes every template in the reader locale, labels resolved per reader', () => { + expect(composeActivitySummary(CREATED, zh())).toBe('创建了 商机 "Acme"'); + expect(composeActivitySummary({ ...CREATED, key: 'activityDeleted' }, zh())).toBe('删除了 商机 "Acme"'); + expect(composeActivitySummary({ ...CREATED, key: 'activityUpdated' }, zh())).toBe('更新了 商机 "Acme"'); + // `owner` has no zh-CN label: the authored one answers; the list is zh-CN's. + expect(composeActivitySummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段'); + // An option value is the reader's option label; a title is the record's own datum. + expect(composeActivitySummary(CHANGES, zh())).toBe('阶段: ∅ → 赢单; Owner: 张伟 → Ana'); + }); + + it('falls back to the authored labels where the reader\'s pack has none', () => { + expect(composeActivitySummary(CREATED, ja())).toBe('Deal「Acme」を作成しました'); + expect(composeActivitySummary(CHANGES, ja())).toBe('Stage: ∅ → Won; Owner: 张伟 → Ana'); + }); + + it('answers undefined for a locale with no template, and the field-less frame where only that exists', () => { + const fr = createMemoryI18n(); + const none = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!; + expect(composeActivitySummary(CREATED, none)).toBeUndefined(); + fr.loadTranslations('fr-FR', { messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' } }); + const older = readerSummaryLocale(fr, 'fr-FR', getObjectDef)!; + expect(composeActivitySummary(NAMED, older)).toBe('A mis à jour Deal « Acme »'); + }); + + it('the stored sentence is the write locale\'s, else the English catalog\'s — never a second spelling', () => { + expect(composeStoredSummary(NAMED, zh())).toBe('更新了 商机 "Acme":金额、Owner和另外 2 个字段'); + const none = readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)!; + expect(composeStoredSummary(CREATED, none)).toBe('Created Deal "Acme"'); + expect(composeStoredSummary(NAMED, none)).toBe('Updated Deal "Acme": Amount, Owner, and 2 more'); + }); +}); + +describe('readerSummaryLocale — the reader\'s negotiated locale', () => { + it('negotiates against the held packs: a bare `zh` reads the zh-CN pack', () => { + expect(composeActivitySummary(CREATED, readerSummaryLocale(makeI18n(), 'zh', getObjectDef)!)).toBe( + '创建了 商机 "Acme"', + ); + }); + + it('composes nothing without a locale or a `t()`', () => { + expect(readerSummaryLocale(makeI18n(), undefined, getObjectDef)).toBeUndefined(); + expect(readerSummaryLocale(makeI18n(), '', getObjectDef)).toBeUndefined(); + expect(readerSummaryLocale(undefined, 'zh-CN', getObjectDef)).toBeUndefined(); + }); + + it('a throwing service is a miss, never a failed read', () => { + const throwing = readerSummaryLocale( + { + t: () => { + throw new Error('boom'); + }, + }, + 'zh-CN', + getObjectDef, + )!; + expect(composeActivitySummary(CREATED, throwing)).toBeUndefined(); + }); +}); + +describe('readActivitySummaryMessage — only a code this version can compose', () => { + it('accepts each stored shape and refuses anything else', () => { + for (const m of [CREATED, NAMED, CHANGES]) expect(readActivitySummaryMessage(m)).toEqual(m); + expect(readActivitySummaryMessage({ key: 'activityArchived', object: 'biz_deal', label: 'x' })).toBeNull(); + expect(readActivitySummaryMessage({ key: 'activityCreated', object: 'biz_deal' })).toBeNull(); + expect(readActivitySummaryMessage({ ...NAMED, fields: [1] })).toBeNull(); + expect(readActivitySummaryMessage({ key: 'fieldChanges', object: 'biz_deal', changes: [{ field: 'x' }] })).toBeNull(); + expect(readActivitySummaryMessage('activityCreated')).toBeNull(); + }); +}); + +describe('localizeActivityRow — the read-side overlay', () => { + const row = (metadata: unknown, summary = 'Created Deal "Acme"') => ({ summary, metadata }); + const stored = (message: unknown) => JSON.stringify({ old: null, new: { name: 'Acme' }, [ACTIVITY_SUMMARY_MESSAGE_KEY]: message }); + + it('overlays the summary and drops the code from what a caller is served', () => { + const r: Record = row(stored(CREATED)); + localizeActivityRow(r, zh(), true); + expect(r.summary).toBe('创建了 商机 "Acme"'); + expect(JSON.parse(r.metadata as string)).toEqual({ old: null, new: { name: 'Acme' } }); + }); + + it('keeps the code for the system, and works on a parsed `metadata` object too', () => { + const r: Record = row({ old: null, new: {}, [ACTIVITY_SUMMARY_MESSAGE_KEY]: CREATED }); + localizeActivityRow(r, zh(), false); + expect(r.summary).toBe('创建了 商机 "Acme"'); + expect((r.metadata as Record)[ACTIVITY_SUMMARY_MESSAGE_KEY]).toEqual(CREATED); + }); + + it('keeps the stored sentence with no locale, an unknown code, or a missing template — and still drops the code', () => { + for (const [message, locale] of [ + [CREATED, undefined], + [{ key: 'activityArchived', object: 'biz_deal', label: 'Acme' }, zh()], + [CREATED, readerSummaryLocale(createMemoryI18n(), 'fr-FR', getObjectDef)], + ] as const) { + const r: Record = row(stored(message)); + localizeActivityRow(r, locale, true); + expect(r.summary).toBe('Created Deal "Acme"'); + expect(JSON.parse(r.metadata as string)).not.toHaveProperty(ACTIVITY_SUMMARY_MESSAGE_KEY); + } + }); + + it('a row written before the change (no code) and a row whose summary was not projected are left alone', () => { + const legacy: Record = row(JSON.stringify({ old: null, new: { name: 'Acme' } })); + localizeActivityRow(legacy, zh(), true); + expect(legacy).toEqual(row(JSON.stringify({ old: null, new: { name: 'Acme' } }))); + + const unprojected: Record = { metadata: stored(CREATED) }; + localizeActivityRow(unprojected, zh(), true); + expect(unprojected).not.toHaveProperty('summary'); + }); +}); diff --git a/packages/plugins/plugin-audit/src/activity-summary-message.ts b/packages/plugins/plugin-audit/src/activity-summary-message.ts new file mode 100644 index 00000000000..ab9c74bbf69 --- /dev/null +++ b/packages/plugins/plugin-audit/src/activity-summary-message.ts @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26167] An activity summary in the READER's language. + * + * ## What was wrong + * + * The CRUD mirror (`audit-writers.ts`) composes `sys_activity.summary` once, + * at write time, in the workspace default locale (framework#3039), and the + * object and field labels inside it are translated at that same moment. Every + * reader was then served that one sentence: an English-default workspace + * showed `Created Contract "…"` to a zh-CN reader forever, a workspace that + * switched language kept every older row in the old one, and two colleagues + * with different languages read the same row in one of them. + * + * ## What a row stores now + * + * Beside the sentence, the mirror records the sentence as a CODE: which + * template it is and its parameters ({@link ActivitySummaryMessage}), in the + * row's `metadata` under {@link ACTIVITY_SUMMARY_MESSAGE_KEY}. The object is + * stored by NAME and each field by KEY, so their labels are resolved for each + * reader. A value inside a field-change summary is stored as the text the + * writer rendered (a referenced record's title is the record's own datum, the + * same in every language) plus, for a select value, the option it matched, so + * its option label is resolved for each reader too. + * + * `summary` itself is unchanged: the sentence the writer has always stored, + * for every reader of the column that is not served through the read below. + * The `metadata` slot follows the approvals precedent (the `admin_rescue` + * note's code rides on the request's snapshot), so no column, object schema or + * spec contract moves. + * + * ## Who renders it + * + * {@link installActivitySummaryLocale}: an engine middleware on `sys_activity` + * that, on a read whose `ExecutionContext.locale` is set, re-composes each + * row's `summary` from its stored code in that locale. The locale is the + * door's already-resolved one (the request's `Accept-Language`, else the + * `localization` settings), negotiated against the locales the `i18n` service + * holds by `resolveBundleLocale`, the one negotiation rule; the text is + * `II18nService.t()`. ⛔ Nothing here is a second way to do that: it is the + * sequence `plugin-approvals`'s `reader-locale-labels.ts` uses. + * + * The writer composes the stored sentence through the SAME function + * ({@link composeActivitySummary}), bound to the write locale, with the English + * catalog as its fallback, so the stored text and a reader's text can never be + * two spellings of one template. + * + * ## What stays as stored + * + * Nothing is re-composed when the read carries no locale, when no `i18n` + * service is registered, when the reader's locale has no template for the code, + * or when composing throws — the stored sentence stands. A row written before + * this change carries no code and is served as written (no migration, no + * backfill). A fired milestone's summary carries no code either: its template + * is the author's sentence and is never translated (#7290). + */ + +import { resolveBundleLocale } from '@objectstack/spec/system'; + +import { enMessages } from './translations/messages.js'; +import { ensureJudgedColumnsProjected, isRecord } from './served-fields.js'; + +/** The `metadata` key a row's summary code rides in. Read and stripped by {@link installActivitySummaryLocale}. */ +export const ACTIVITY_SUMMARY_MESSAGE_KEY = 'summary_message'; + +/** One side of a field change: the writer's text, and the option it matched (a select value). */ +export interface ActivitySummaryValue { + text: string; + option?: string; +} + +/** A field change inside a `fieldChanges` summary. */ +export interface ActivitySummaryFieldChange { + field: string; + from: ActivitySummaryValue; + to: ActivitySummaryValue; +} + +/** + * The code of a summary. `key` names a `messages.*` template of this plugin's + * catalog, except `fieldChanges`, the tracked-change diff (ADR-0052 §5b.1), + * which has no template of its own. `object` is the object's NAME. + */ +export type ActivitySummaryMessage = + | { key: 'activityCreated' | 'activityDeleted' | 'activityUpdated'; object: string; label: string } + | { key: 'activityUpdatedFields'; object: string; label: string; fields: string[]; more: number } + | { key: 'fieldChanges'; object: string; changes: ActivitySummaryFieldChange[] }; + +/** One language a summary is composed in. */ +export interface ActivitySummaryLocale { + /** A bundle key in this locale, interpolated; `undefined` on any miss. */ + translate(key: string, params?: Record): string | undefined; + /** The locale a list is joined in; `undefined` joins in English. */ + locale: string | undefined; + /** The object's registered definition, for its authored labels; `null` when there is none. */ + getObjectDef(objectName: string): any; +} + +/** Interpolate one `{{param}}` template, the shared convention (`createMemoryI18n`, FileI18nAdapter). */ +function interpolate(template: string, params: Record = {}): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(params[name] ?? `{{${name}}}`)); +} + +/** + * `locale` with this plugin's ENGLISH catalog answering every `messages.*` + * key: what the stored sentence falls back to when the write locale has no + * template. Every other key (object, field and option labels) is still asked + * of `locale`, as the writer always has. + */ +export function withEnglishMessages(locale: ActivitySummaryLocale): ActivitySummaryLocale { + return { + translate: (key, params) => { + if (!key.startsWith('messages.')) return locale.translate(key, params); + const template = (enMessages as Record)[key.slice('messages.'.length)]; + return typeof template === 'string' ? interpolate(template, params) : undefined; + }, + locale: undefined, + getObjectDef: locale.getObjectDef, + }; +} + +/** An object's label: the locale bundle's, then the authored def label, then the name. */ +export function objectLabelIn(locale: ActivitySummaryLocale, objectName: string): string { + const def = locale.getObjectDef(objectName); + return ( + locale.translate(`objects.${objectName}.label`) ?? + (typeof def?.label === 'string' && def.label.length > 0 ? def.label : objectName) + ); +} + +/** + * A field's label inside an activity summary: the locale bundle's + * (`objects..fields..label`), then the authored def label, then + * the machine key. One answer for every branch that names a field. + */ +export function fieldLabelForSummary( + objectName: string, + key: string, + field: any, + translate: (key: string, params?: Record) => string | undefined, +): string { + return ( + translate(`objects.${objectName}.fields.${key}.label`) ?? + (typeof field?.label === 'string' && field.label.length > 0 ? field.label : key) + ); +} + +/** A locale's conjunction list ("A, B, and C" / "A、B和C"); `", "` where `Intl` cannot answer. */ +export function listJoinerFor(locale: string | undefined): (items: string[]) => string { + // `Intl.ListFormat` is ES2021; this package compiles against the ES2020 lib, + // so the constructor is probed rather than assumed (as `plugin-auth`'s + // invitation role label does). + const ListFormat = (Intl as unknown as { ListFormat?: IntlListFormatConstructor }).ListFormat; + try { + if (ListFormat) { + const format = new ListFormat(locale ?? 'en', { type: 'conjunction' }); + return (items) => format.format(items); + } + } catch { + // A tag `Intl` refuses (a tenant's own locale spelling) still gets a list. + } + return (items) => items.join(', '); +} + +type IntlListFormatConstructor = new ( + locale: string, + options: { type: 'conjunction' }, +) => { format(list: readonly string[]): string }; + +/** + * The summary `message` reads as in `locale`, or `undefined` when that locale + * has no template for it. A locale whose bundle answers only the field-less + * `activityUpdated` (an app's own locale, written before the named-field + * template existed) gets that localized frame for a named-field code, never an + * English one with its field names. + */ +export function composeActivitySummary( + message: ActivitySummaryMessage, + locale: ActivitySummaryLocale, +): string | undefined { + const { translate } = locale; + switch (message.key) { + case 'activityCreated': + case 'activityDeleted': + case 'activityUpdated': + return translate(`messages.${message.key}`, { + object: objectLabelIn(locale, message.object), + label: message.label, + }); + case 'activityUpdatedFields': { + const frame = { object: objectLabelIn(locale, message.object), label: message.label }; + if (translate('messages.activityUpdatedFields', { ...frame, fields: '' }) === undefined) { + return translate('messages.activityUpdated', frame); + } + const fields = locale.getObjectDef(message.object)?.fields; + const items = message.fields.map((key) => fieldLabelForSummary(message.object, key, fields?.[key], translate)); + if (message.more > 0) { + items.push( + translate('messages.activityMoreFields', { count: message.more }) ?? + interpolate(enMessages.activityMoreFields as string, { count: message.more }), + ); + } + return translate('messages.activityUpdatedFields', { ...frame, fields: listJoinerFor(locale.locale)(items) }); + } + case 'fieldChanges': { + const fields = locale.getObjectDef(message.object)?.fields; + const valueIn = (field: string, value: ActivitySummaryValue): string => + value.option !== undefined + ? (translate(`objects.${message.object}.fields.${field}.options.${value.option}`) ?? value.text) + : value.text; + return message.changes + .map( + (c) => + `${fieldLabelForSummary(message.object, c.field, fields?.[c.field], translate)}: ` + + `${valueIn(c.field, c.from)} → ${valueIn(c.field, c.to)}`, + ) + .join('; '); + } + } +} + +/** The stored sentence for `message`: the write locale's, else the English catalog's. */ +export function composeStoredSummary(message: ActivitySummaryMessage, locale: ActivitySummaryLocale): string { + return composeActivitySummary(message, locale) ?? composeActivitySummary(message, withEnglishMessages(locale)) ?? ''; +} + +const SINGLE_TEMPLATE_KEYS: ReadonlySet = new Set(['activityCreated', 'activityDeleted', 'activityUpdated']); +const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === 'string'); +const isValue = (v: unknown): v is ActivitySummaryValue => + isRecord(v) && typeof v.text === 'string' && (v.option === undefined || typeof v.option === 'string'); + +/** A stored code, or `null` for anything this version cannot compose (it then stays as stored). */ +export function readActivitySummaryMessage(value: unknown): ActivitySummaryMessage | null { + if (!isRecord(value) || typeof value.key !== 'string' || typeof value.object !== 'string') return null; + if (SINGLE_TEMPLATE_KEYS.has(value.key)) { + return typeof value.label === 'string' ? (value as ActivitySummaryMessage) : null; + } + if (value.key === 'activityUpdatedFields') { + return typeof value.label === 'string' && isStringList(value.fields) && typeof value.more === 'number' + ? (value as ActivitySummaryMessage) + : null; + } + if (value.key === 'fieldChanges') { + const changes = value.changes; + return Array.isArray(changes) && + changes.every((c) => isRecord(c) && typeof c.field === 'string' && isValue(c.from) && isValue(c.to)) + ? (value as ActivitySummaryMessage) + : null; + } + return null; +} + +/** The slice of `II18nService` a read asks: `t()`, and `getLocales()` to negotiate when offered. */ +export interface ActivitySummaryI18n { + t(key: string, locale: string, params?: Record): string; + getLocales?(): unknown; +} + +/** + * The locale `service` can ANSWER in, for the locale the reader asked for: + * `resolveBundleLocale` over what `getLocales()` reports. The requested tag + * passes through untouched when there is nothing to negotiate against. + */ +function negotiatedLocale(service: ActivitySummaryI18n, requested: string): string { + if (typeof service.getLocales !== 'function') return requested; + let available: unknown; + try { + available = service.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; +} + +/** + * The language a reader whose `ExecutionContext.locale` is `requested` reads + * in, or `undefined` when this read can compose nothing: no locale, or no + * service with a `t()`. + */ +export function readerSummaryLocale( + service: ActivitySummaryI18n | undefined, + requested: unknown, + getObjectDef: (objectName: string) => any, +): ActivitySummaryLocale | undefined { + if (typeof requested !== 'string' || requested.length === 0) return undefined; + if (!service || typeof service.t !== 'function') return undefined; + const locale = negotiatedLocale(service, requested); + return { + translate: (key, params) => { + try { + const text = service.t(key, locale, params); + // `II18nService.t` echoes the key back on a miss. + return typeof text === 'string' && text.length > 0 && text !== key ? text : undefined; + } catch { + // A misbehaving i18n service must not fail the read: the stored text stands. + return undefined; + } + }, + locale, + getObjectDef, + }; +} + +/** Parse a stored `metadata` value: a JSON object, or `null` for anything else. */ +function parseMetadata(raw: unknown): Record | null { + if (isRecord(raw)) return raw; + if (typeof raw !== 'string' || raw.trim() === '') return null; + try { + const value = JSON.parse(raw); + return isRecord(value) ? value : null; + } catch { + return null; + } +} + +/** + * Overlay `row.summary` with its stored code composed in `locale`, in place, + * and — for a caller who is not the system — drop the code from the served + * `metadata`: its parameters are parent field values (the record label, a + * changed value), and the field redaction judges the text columns, not this + * slot. A row whose `summary` the projection did not ask for is not given one. + */ +export function localizeActivityRow( + row: Record, + locale: ActivitySummaryLocale | undefined, + stripCode: boolean, +): void { + const metadata = parseMetadata(row.metadata); + if (!metadata || !(ACTIVITY_SUMMARY_MESSAGE_KEY in metadata)) return; + if (locale && 'summary' in row) { + const message = readActivitySummaryMessage(metadata[ACTIVITY_SUMMARY_MESSAGE_KEY]); + if (message) { + try { + const text = composeActivitySummary(message, locale); + if (text) row.summary = text; + } catch { + // Composing must never fail the read: the stored sentence stands. + } + } + } + if (stripCode) { + delete metadata[ACTIVITY_SUMMARY_MESSAGE_KEY]; + row.metadata = isRecord(row.metadata) ? metadata : JSON.stringify(metadata); + } +} + +/** The engine slice the summary middleware registers on. */ +export interface ActivitySummaryEngine { + registerMiddleware?( + fn: (ctx: any, next: () => Promise) => Promise, + options?: { object?: string }, + ): void; + getSchema?(objectName: string): unknown; +} + +/** + * Install the `sys_activity` summary middleware. `getI18n` is resolved on every + * read: the i18n service may register after this plugin. Register it AFTER the + * field redaction (`installActivityFieldRedaction`), so it runs inside it: the + * summary is composed from the row as stored, and the redaction then judges + * the composed text by the same declared sources the stored one had. + */ +export function installActivitySummaryLocale( + engine: ActivitySummaryEngine, + getI18n: () => ActivitySummaryI18n | undefined, +): void { + if (typeof engine.registerMiddleware !== 'function') return; + const getObjectDef = (objectName: string): any => { + try { + return typeof engine.getSchema === 'function' ? engine.getSchema(objectName) : null; + } catch { + return null; + } + }; + engine.registerMiddleware( + async (ctx, next) => { + if (ctx.operation !== 'find' && ctx.operation !== 'findOne') return next(); + const added = ensureJudgedColumnsProjected(ctx.ast, ['summary'], ['metadata']); + await next(); + const list = (Array.isArray(ctx.result) ? ctx.result : ctx.result ? [ctx.result] : []) as unknown[]; + const locale = readerSummaryLocale(getI18n(), ctx.context?.locale, getObjectDef); + const stripCode = !!ctx.context && !ctx.context.isSystem; + for (const row of list) { + if (!isRecord(row)) continue; + localizeActivityRow(row, locale, stripCode); + for (const col of added) delete row[col]; + } + }, + { object: 'sys_activity' }, + ); +} diff --git a/packages/plugins/plugin-audit/src/activity-summary-reader-locale.integration.test.ts b/packages/plugins/plugin-audit/src/activity-summary-reader-locale.integration.test.ts new file mode 100644 index 00000000000..bac65d9d414 --- /dev/null +++ b/packages/plugins/plugin-audit/src/activity-summary-reader-locale.integration.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26167] An activity summary reads in the READER's language, on the plugin's + * read path. + * + * The workspace locale is left at its default (`en`), so the CRUD mirror stores + * English sentences — the hotclm measurement: a zh-CN reader's 讨论 tab showed + * `Created Contract "…"` although the plugin ships `创建了 {{object}} "{{label}}"`. + * The rows are written by the real audit writer, stored by a real SQLite + * driver, and read back through the real engine middleware chain under a + * caller context whose `locale` is what the REST door resolves from + * `Accept-Language`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import type { II18nService } from '@objectstack/spec/contracts'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const HARNESS_PACKAGE = 'com.objectstack.audit.test.summary-reader-locale'; +const CONTRACT = 'biz_contract'; + +const contract = { + name: CONTRACT, + label: 'Contract', + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + stage: { + name: 'stage', + label: 'Stage', + type: 'select' as const, + trackHistory: true, + options: [ + { value: 'draft', label: 'Draft' }, + { value: 'signed', label: 'Signed' }, + ], + }, + note: { name: 'note', label: 'Note', type: 'text' as const }, + }, +}; + +const SYS = { context: { isSystem: true } }; +const READER = { userId: 'u_reader', tenantId: 'org_1', positions: ['org_member'] }; +/** A reader the security service does not serve the contract's `note` field. */ +const NOTE_HIDDEN = { userId: 'u_note_hidden', tenantId: 'org_1', positions: ['org_member'] }; +const reader = (locale?: string, who: Record = READER) => ({ + context: { ...who, ...(locale ? { locale } : {}) }, +}); + +type Row = Record; + +describe('sys_activity summary — rendered in the reader\'s language (#26167)', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + let recordId: string; + + /** The record's activity rows as `opts`'s caller reads them, by activity row id. */ + async function read(opts: Record, extra: Record = {}): Promise { + return (await engine.find('sys_activity', { + where: { object_name: CONTRACT, record_id: recordId }, + ...extra, + ...opts, + })) as Row[]; + } + /** The three mirror rows' summaries — created, the tracked change, the named-field update — as `opts` reads them. */ + async function summaries(opts: Record): Promise> { + const rows = await read(opts); + return [rowIds.created, rowIds.stage, rowIds.note].map((id) => rows.find((r) => r.id === id)?.summary); + } + const rowIds: Record<'created' | 'stage' | 'note', string> = {} as never; + + /** The double: a field the `NOTE_HIDDEN` reader is not served; everything else is. */ + const security = { + async getReadableFields(object: string, context?: any): Promise { + const all = Object.keys(((engine as any).getSchema(object)?.fields ?? {}) as Record); + return context?.userId === NOTE_HIDDEN.userId && object === CONTRACT ? all.filter((f) => f !== 'note') : all; + }, + async getQueryableFields(object: string, context?: any): Promise { + return security.getReadableFields(object, context); + }, + }; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'test.security-double', + version: '0.0.0', + init: async (ctx: any) => ctx.registerService('security', security), + start: async () => {}, + } as any); + await kernel.use(singlePostureTenancy()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + engine.registry.registerObject(contract as any, HARNESS_PACKAGE); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + + // The app's language pack: its object, field and option labels in zh-CN. + // The plugin's own `messages.*` templates are loaded by AuditPlugin. + kernel.getService('i18n').loadTranslations('zh-CN', { + objects: { + [CONTRACT]: { + label: '合同', + fields: { + stage: { label: '阶段', options: { draft: '草稿', signed: '已签署' } }, + note: { label: '备注' }, + }, + }, + }, + }); + + // Written as the system: the workspace locale is the default, `en`. + const created = (await engine.insert(CONTRACT, { name: 'NDA', stage: 'draft' }, SYS)) as Row; + recordId = String(created.id); + await engine.update(CONTRACT, { stage: 'signed' }, { where: { id: recordId }, ...SYS }); + await engine.update(CONTRACT, { note: 'countersigned' }, { where: { id: recordId }, ...SYS }); + const atRest = await read(SYS); + expect(atRest).toHaveLength(3); + for (const row of atRest) { + const meta = JSON.parse(row.metadata); + if (row.type === 'created') rowIds.created = row.id; + else if ('stage' in (meta.new ?? {})) rowIds.stage = row.id; + else rowIds.note = row.id; + } + }, 120_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); + } + }, 30_000); + + it('red first: a zh-CN reader reads every row the en workspace wrote in zh-CN', async () => { + expect(await summaries(reader('zh-CN'))).toEqual([ + '创建了 合同 "NDA"', + '阶段: 草稿 → 已签署', + '更新了 合同 "NDA":备注', + ]); + }); + + it('stores the English sentence, and serves it to an en reader and to a read with no locale', async () => { + const english = ['Created Contract "NDA"', 'Stage: Draft → Signed', 'Updated Contract "NDA": Note']; + expect(await summaries(SYS)).toEqual(english); + expect(await summaries(reader('en-US'))).toEqual(english); + expect(await summaries(reader())).toEqual(english); + }); + + it('negotiates the reader locale against the held packs (a bare `zh` reads the zh-CN pack)', async () => { + expect((await summaries(reader('zh')))[0]).toBe('创建了 合同 "NDA"'); + }); + + it('a reader locale with no template keeps the stored sentence', async () => { + expect((await summaries(reader('fr-FR')))[0]).toBe('Created Contract "NDA"'); + }); + + it('serves the code to no caller but the system, and composes when only `summary` is projected', async () => { + const served = (await read(reader('zh-CN'))).find((r) => r.id === rowIds.created)!; + expect(JSON.parse(served.metadata)).not.toHaveProperty('summary_message'); + const atRest = (await read(SYS)).find((r) => r.id === rowIds.created)!; + expect(JSON.parse(atRest.metadata).summary_message).toEqual({ + key: 'activityCreated', + object: CONTRACT, + label: 'NDA', + }); + + const projected = await read(reader('zh-CN'), { where: { id: rowIds.created }, fields: ['summary'] }); + expect(projected).toEqual([{ summary: '创建了 合同 "NDA"' }]); + }); + + it('the field redaction still judges the composed text: a reader not served a named field gets no summary', async () => { + expect(await summaries(reader('zh-CN', NOTE_HIDDEN))).toEqual([ + '创建了 合同 "NDA"', + '阶段: 草稿 → 已签署', + undefined, + ]); + }); + + it('a row written before the change keeps its stored text for every reader', async () => { + // The mirror's earlier shape: `metadata` exactly `{ old, new }`, no code. + await engine.insert( + 'sys_activity', + { + type: 'created', + timestamp: '2020-01-01T00:00:00.000Z', + summary: 'Created Contract "NDA"', + object_name: CONTRACT, + record_id: recordId, + record_label: 'NDA', + metadata: JSON.stringify({ old: null, new: { name: 'NDA' } }), + }, + SYS, + ); + const legacy = (await read(reader('zh-CN'))).filter((r) => r.timestamp === '2020-01-01T00:00:00.000Z'); + expect(legacy.map((r) => r.summary)).toEqual(['Created Contract "NDA"']); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts index 24372d24926..d2e46f1770c 100644 --- a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts @@ -52,6 +52,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; +import { summaryAsRead } from './__tests__/summary-as-read.js'; // --------------------------------------------------------------------------- // Fixtures @@ -274,6 +275,20 @@ const lastSummary = (storeFor: (o: string) => Map 0 ? all[all.length - 1] : undefined; }; +/** + * [#26167] …and as a reader in `locale` is served it: the summary is stored in + * English, and its field labels are localized per reader. + */ +const lastSummaryAsRead = ( + storeFor: (o: string) => Map>, + i18n: unknown, + locale: string, + engine: unknown, +) => { + const rows = Array.from(storeFor('sys_activity').values()); + return summaryAsRead(rows[rows.length - 1], i18n, locale, engine as any); +}; + /** * Seed the two accounts, the region, the user, and TWO deals: * @@ -372,24 +387,30 @@ describe('[#7230] value half — a tracked reference renders a title, not a raw // --------------------------------------------------------------------------- describe('[#7230] label half — the tracked-change branch gets the locale-bound translator', () => { - it('localizes the tracked field label to the workspace locale (zh-CN)', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + // [#26167] The label is localized for the READER: the row stores the English + // sentence and the field's key, and a zh-CN reader is served the zh-CN label. + it('localizes the tracked field label to the reader locale (zh-CN)', async () => { + const i18n = makeI18n(); + const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n }); await seed(engine); await engine.update('biz_deal', { owner: 'usr_1' }, { where: { id: 'deal_1' } } as any); // Both halves at once, which is the reported string's actual shape: // localized label + resolved title, where it read `Owner: ∅ → usr_1`. - expect(lastSummary(storeFor)).toBe('负责人: ∅ → 张伟'); + expect(lastSummary(storeFor)).toBe('Owner: ∅ → 张伟'); + expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('负责人: ∅ → 张伟'); }); it('localizes a non-reference tracked label too (the label half is not lookup-only)', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const i18n = makeI18n(); + const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n }); await seed(engine); await engine.update('biz_deal', { stage: 'won' }, { where: { id: 'deal_1' } } as any); - expect(lastSummary(storeFor)).toBe('阶段: open → won'); + expect(lastSummary(storeFor)).toBe('Stage: open → won'); + expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('阶段: open → won'); }); it('falls back to the authored field label when the bundle has no entry for it', async () => { @@ -400,6 +421,7 @@ describe('[#7230] label half — the tracked-change branch gets the locale-bound await engine.update('biz_deal', { partner_id: 'acc_2' }, { where: { id: 'deal_1' } } as any); expect(lastSummary(storeFor)).toBe('Partner: ∅ → Globex'); + expect(lastSummaryAsRead(storeFor, makeI18n(), 'zh-CN', engine)).toBe('Partner: ∅ → Globex'); }); it('keeps the authored label when no i18n service is resolvable (status quo)', async () => { diff --git a/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts b/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts index ce0f87da23a..af4f6196991 100644 --- a/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts @@ -57,6 +57,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; +import { summaryAsRead } from './__tests__/summary-as-read.js'; // --------------------------------------------------------------------------- // Fixtures @@ -343,6 +344,17 @@ const lastSummary = (storeFor: (o: string) => Map 0 ? String(all[all.length - 1].summary) : undefined; }; +/** [#26167] The last summary as a reader in `locale` is served it (`summary-as-read.ts`). */ +const lastSummaryAsRead = ( + storeFor: (o: string) => Map>, + i18n: unknown, + locale: string, + engine: unknown, +) => { + const all = Array.from(storeFor('sys_activity').values()); + return summaryAsRead(all[all.length - 1], i18n, locale, engine as any); +}; + const lastType = (storeFor: (o: string) => Map>) => { const all = Array.from(storeFor('sys_activity').values()); return all.length > 0 ? String(all[all.length - 1].type) : undefined; @@ -534,7 +546,8 @@ describe('[#7290] precedence and locale behaviour are unchanged', () => { }); it('a milestone template is the author’s sentence and is NOT translated', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const i18n = makeI18n(); + const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n }); await seed(engine); await moveStage(engine, 'deal_2', 'closed_won'); @@ -543,18 +556,23 @@ describe('[#7290] precedence and locale behaviour are unchanged', () => { // and it must NOT leak into the milestone sentence: milestone summaries have // never been localized, and this card does not change what a template says. expect(lastSummary(storeFor)).toBe('Deal won by 张伟'); + // [#26167] Nor for a zh-CN READER: a milestone row stores no summary code. + expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('Deal won by 张伟'); }); it('the generic verb branch still localizes around it (the bundle really is live)', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const i18n = makeI18n(); + const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n }); await seed(engine); // A write that fires no milestone and moves no tracked field falls through - // to `messages.activityUpdated`. If this stayed English the bundle above - // would be inert and the previous case would prove nothing. + // to the generic update summary. If a zh-CN reader were served English the + // bundle above would be inert and the previous case would prove nothing. + // [#26167] This bundle has only the field-less `messages.activityUpdated`, + // so the reader is served that frame. await engine.update('biz_deal', { priority: 'p2' }, { where: { id: 'deal_2' } } as any); - expect(lastSummary(storeFor)).toBe('已更新商机"Already linked"'); + expect(lastSummaryAsRead(storeFor, i18n, 'zh-CN', engine)).toBe('已更新商机"Already linked"'); }); }); diff --git a/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts b/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts index 95aa480f43f..df74d2ad48a 100644 --- a/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts @@ -103,6 +103,7 @@ import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; import { zhCNObjects } from './translations/zh-CN.objects.generated.js'; import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; +import { summaryAsRead } from './__tests__/summary-as-read.js'; // --------------------------------------------------------------------------- // Fixtures @@ -347,7 +348,16 @@ async function boot(opts: { locale?: string; i18n?: unknown } = {}) { getI18n: () => opts.i18n as any, getLocale: async () => opts.locale, }); - return { engine, ...stub }; + // [#26167] The summaries as a reader in `opts.locale` is served them: the + // row stores the English sentence and its code, and the field and option + // labels are localized per READER (`summary-as-read.ts`). + const readerSummaries = () => + Array.from(stub.storeFor('sys_activity').values()).map((r) => summaryAsRead(r, opts.i18n, opts.locale, engine)); + const readerSummary = () => { + const all = readerSummaries(); + return all[all.length - 1]; + }; + return { engine, readerSummaries, readerSummary, ...stub }; } const summariesOf = (storeFor: (o: string) => Map>) => @@ -387,17 +397,19 @@ const setStage = (engine: any, id: string, stage: string) => describe('[#7289] tracked-change branch — select option labels resolve through the bundle', () => { it('localizes the option label on BOTH sides of the diff (the reported string)', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, storeFor, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); await setStage(engine, 'deal_1', 'closed_won'); // Was `阶段: Proposal → Closed Won` — localized frame, authored values. - expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单'); + // [#26167] Stored in English with the option VALUES; read in zh-CN. + expect(lastSummary(storeFor)).toBe('Stage: Proposal → Closed Won'); + expect(readerSummary()).toBe('阶段: 提案中 → 已赢单'); }); it('falls back to the AUTHORED option label per VALUE, not per field', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); // `stage.options.on_hold` is absent from the bundle; `proposal` is present. @@ -405,22 +417,22 @@ describe('[#7289] tracked-change branch — select option labels resolve through // the translate limb, which is the trap #7291 measured on its raw-id case. await setStage(engine, 'deal_1', 'on_hold'); - expect(lastSummary(storeFor)).toBe('阶段: 提案中 → On Hold'); + expect(readerSummary()).toBe('阶段: 提案中 → On Hold'); }); it('localizes the BARE-STRING option shape, whose authored label is the value', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); // `options: ['p1', 'p2']` → the authored label chain has only `ov` in it. // `p1` is in the bundle, `p2` is not, so both directions are pinned here. await engine.update('biz_deal', { priority: 'p1' }, { where: { id: 'deal_1' } } as any); - expect(lastSummary(storeFor)).toBe('优先级: p2 → 最高'); + expect(readerSummary()).toBe('优先级: p2 → 最高'); }); it('renders String(value) for a value matching NO declared option, bundle key or not', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); // The bundle DOES declare `channel.options.phone_call = '电话'`, and @@ -431,11 +443,11 @@ describe('[#7289] tracked-change branch — select option labels resolve through // by the translate limb being dead. await engine.update('biz_deal', { channel: 'phone_call' }, { where: { id: 'deal_1' } } as any); - expect(lastSummary(storeFor)).toBe('渠道: 网页 → phone_call'); + expect(readerSummary()).toBe('渠道: 网页 → phone_call'); }); it('leaves the reference branch (#7230 / #7291) rendering titles beside the options', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); await engine.update( @@ -446,7 +458,7 @@ describe('[#7289] tracked-change branch — select option labels resolve through // Option label from the bundle, record title from the read: the two // resolutions are independent and both must be present in one summary. - expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单; 负责人: ∅ → 张伟'); + expect(readerSummary()).toBe('阶段: 提案中 → 已赢单; 负责人: ∅ → 张伟'); }); it('keeps the authored option label when no i18n service is resolvable', async () => { @@ -474,7 +486,7 @@ describe('[#7289] tracked-change branch — select option labels resolve through describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on purpose', () => { it('renders the authored option label inside the untranslated author sentence', async () => { - const { engine, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, storeFor, readerSummaries } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); // Two writes, and the first one is the CONTROL: it proves the locale is @@ -486,13 +498,15 @@ describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on pur await setStage(engine, 'deal_2', 'archived'); const all = summariesOf(storeFor); - expect(all[all.length - 2]).toBe('阶段: 提案中 → 已赢单'); + expect(readerSummaries()[all.length - 2]).toBe('阶段: 提案中 → 已赢单'); // `{stage}` → the AUTHORED `Archived`, although the bundle declares // `stage.options.archived = '已归档'`. `{owner}` → `张伟`, because #7290's // title resolution is locale-INDEPENDENT data, not a translation. The // author's sentence and the author's option label stay in one language. expect(all[all.length - 1]).toBe('Deal moved to Archived by 张伟'); + // [#26167] …and a zh-CN READER is served it as written: no summary code. + expect(readerSummaries()[all.length - 1]).toBe('Deal moved to Archived by 张伟'); }); // ⚠️ Status-quo pin, not coverage of this change: the locale is OFF here, so @@ -517,7 +531,7 @@ describe('[#7289] fired-milestone branch — option labels stay AUTHORED, on pur describe('[#7289] localizing an option label is a bundle lookup and adds no reads', () => { it('a tracked option-only change pays ZERO reads, on a row that HOLDS references', async () => { - const { engine, reads, storeFor } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); + const { engine, reads, readerSummary } = await boot({ locale: 'zh-CN', i18n: makeI18n() }); await seed(engine); const before = { user: reads.findOn['sys_user'] ?? 0, acct: reads.findOn['crm_account'] ?? 0 }; @@ -530,7 +544,7 @@ describe('[#7289] localizing an option label is a bundle lookup and adds no read expect((reads.findOn['sys_user'] ?? 0) - before.user).toBe(0); expect((reads.findOn['crm_account'] ?? 0) - before.acct).toBe(0); - expect(lastSummary(storeFor)).toBe('阶段: 提案中 → 已赢单'); + expect(readerSummary()).toBe('阶段: 提案中 → 已赢单'); }); // ⚠️ Retained as a forward guard, not as coverage of this change: a create diff --git a/packages/plugins/plugin-audit/src/audit-plugin-host-locale.test.ts b/packages/plugins/plugin-audit/src/audit-plugin-host-locale.test.ts index f62da59df40..3f09ede587c 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin-host-locale.test.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin-host-locale.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; // STATIC on purpose, like audit-writers.test.ts: the plugin's own kernel:ready // hook imports the translation bundle lazily, and a static import here moves // that cold-start cost to collection, which no test's timeout is charged for. @@ -17,7 +17,32 @@ import { AuditPlugin, type AuditPluginOptions } from './audit-plugin.js'; * The settings service answers `ja-JP` throughout, so every outcome is * distinguishable: the host's locale (zh-CN), the settings fallback (ja-JP), * and the English literal a locale-less write degrades to. + * + * [#26167] What the write locale localizes is the @mention notification title: + * `sys_activity.summary` is stored in English and served in each READER's + * locale, so it is the title these cases read. The plugin wires the real + * recipient gate, which this fake engine cannot answer, so the writers are + * handed a messaging double and an admitting gate — every other option is the + * plugin's own. */ +const mentionTitles = vi.hoisted(() => [] as string[]); +vi.mock('./audit-writers.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + installAuditWriters: (engine: any, packageId: string, opts: any) => + mod.installAuditWriters(engine, packageId, { + ...opts, + getMessaging: () => ({ + emit: async (e: any) => { + if (e.topic === 'collab.mention') mentionTitles.push(String(e.payload?.title)); + return {}; + }, + }), + admitMentionRecipients: async ({ mentions }: { mentions: string[] }) => [...mentions], + }), + }; +}); interface CapturedRow { object: string; @@ -93,6 +118,7 @@ function makeSettings(locale = 'ja-JP') { } async function boot(options?: AuditPluginOptions, services: { settings?: unknown } = { settings: makeSettings() }) { + mentionTitles.length = 0; const { engine, fire, created } = makeEngine(); const i18n = createMemoryI18n(); for (const [locale, data] of Object.entries(AuditTranslations)) { @@ -141,87 +167,107 @@ async function boot(options?: AuditPluginOptions, services: { settings?: unknown result: { id: `q-${name}`, name }, session: { organizationId: tenant, userId: user }, }); + // An @mention of `recipient` in an organization, by `Alice` — the write whose title the locale picks. + const mention = (tenant = 'org-1', recipient = 'user-2', id = 'c-1') => + fire('afterInsert', { + object: 'sys_comment', + input: {}, + result: { + id, + thread_id: 'person_qualification:q-1', + author_id: 'user-1', + author_name: 'Alice', + body: 'hello', + mentions: JSON.stringify([recipient]), + }, + session: { organizationId: tenant, userId: 'user-1' }, + }); + const titles = () => [...mentionTitles]; const summaries = () => created.filter((c) => c.object === 'sys_activity').map((c) => c.row.summary); const localeWarns = () => warns.filter((w) => w.msg.includes('getLocale')); - return { insert, summaries, created, localeWarns }; + return { insert, mention, titles, summaries, created, localeWarns }; } -const ZH = '创建了 Qualification "OC-00001"'; -const JA = 'Qualification「OC-00001」を作成しました'; -const EN = 'Created Qualification "OC-00001"'; +const ZH = 'Alice 提到了你'; +const JA = 'Aliceさんがあなたをメンションしました'; +const EN = 'Alice mentioned you'; describe('AuditPlugin — host locale resolver (AuditPluginOptions.getLocale)', () => { - it('pin: a create on a tracked object writes sys_activity.summary in the resolver\'s locale', async () => { + it('pin: an @mention title is written in the resolver\'s locale; the activity summary asks it nothing', async () => { const calls: Array<[string | undefined, string | undefined]> = []; - const { insert, summaries, localeWarns } = await boot({ + const { insert, mention, titles, summaries, localeWarns } = await boot({ getLocale: async (tenantId, userId) => { calls.push([tenantId, userId]); return 'zh-CN'; }, }); + // [#26167] The summary is stored in English, whatever the resolver says… await insert(); - expect(summaries()).toEqual([ZH]); - // Asked with the write's organization and user, ahead of the settings read. - expect(calls).toEqual([['org-1', 'user-1']]); + expect(summaries()).toEqual(['Created Qualification "OC-00001"']); + expect(calls).toEqual([]); + // …and the title is the resolver's language. + await mention(); + expect(titles()).toEqual([ZH]); + // Asked with the write's organization and the mentioned recipient, ahead of the settings read. + expect(calls).toEqual([['org-1', 'user-2']]); expect(localeWarns()).toEqual([]); }); it('a synchronous answer counts, and is used in its canonical form', async () => { - const { insert, summaries } = await boot({ getLocale: () => ' zh-cn ' }); - await insert(); - expect(summaries()).toEqual([ZH]); + const { mention, titles } = await boot({ getLocale: () => ' zh-cn ' }); + await mention(); + expect(titles()).toEqual([ZH]); }); it('fallback: a resolver answering undefined gives today\'s settings-derived locale', async () => { - const { insert, summaries, localeWarns } = await boot({ getLocale: async () => undefined }); - await insert(); - expect(summaries()).toEqual([JA]); + const { mention, titles, localeWarns } = await boot({ getLocale: async () => undefined }); + await mention(); + expect(titles()).toEqual([JA]); expect(localeWarns()).toEqual([]); }); it('fallback: null and a blank string are "no answer" too, silently', async () => { const asNull = await boot({ getLocale: (async () => null) as unknown as AuditPluginOptions['getLocale'] }); - await asNull.insert(); - expect(asNull.summaries()).toEqual([JA]); + await asNull.mention(); + expect(asNull.titles()).toEqual([JA]); expect(asNull.localeWarns()).toEqual([]); const blank = await boot({ getLocale: () => ' ' }); - await blank.insert(); - expect(blank.summaries()).toEqual([JA]); + await blank.mention(); + expect(blank.titles()).toEqual([JA]); expect(blank.localeWarns()).toEqual([]); }); - it('absent: with no option the summary is the settings-derived one (the existing-behaviour control)', async () => { + it('absent: with no option the title is the settings-derived one (the existing-behaviour control)', async () => { const withSettings = await boot(); - await withSettings.insert(); - expect(withSettings.summaries()).toEqual([JA]); + await withSettings.mention(); + expect(withSettings.titles()).toEqual([JA]); // An options bag without the member is the same as no bag at all. const emptyBag = await boot({}); - await emptyBag.insert(); - expect(emptyBag.summaries()).toEqual([JA]); + await emptyBag.mention(); + expect(emptyBag.titles()).toEqual([JA]); // And no settings service either: the built-in en-US default — English. const bare = await boot(undefined, {}); - await bare.insert(); - expect(bare.summaries()).toEqual([EN]); + await bare.mention(); + expect(bare.titles()).toEqual([EN]); }); it('throw: falls through to the settings-derived locale, logged once at warn with the error, and the write lands', async () => { let calls = 0; - const { insert, summaries, created, localeWarns } = await boot({ + const { mention, titles, localeWarns } = await boot({ getLocale: () => { calls += 1; throw new Error('locale store unreachable'); }, }); - await insert('org-1', 'user-1', 'OC-00001'); + await mention('org-1', 'user-2', 'c-1'); // A second scope asks again (the writer memoizes per tenant/user) and throws again. - await insert('org-2', 'user-2', 'OC-00002'); + await mention('org-2', 'user-3', 'c-2'); expect(calls).toBe(2); - expect(summaries()).toEqual([JA, 'Qualification「OC-00002」を作成しました']); - // The audited write is not failed by the host's fault: both ledger rows landed. - expect(created.filter((c) => c.object === 'sys_audit_log')).toHaveLength(2); + // The notification is not failed by the host's fault: both titles landed. + expect(titles()).toEqual([JA, JA]); const warned = localeWarns(); expect(warned).toHaveLength(1); expect(warned[0].msg).toMatch(/host getLocale resolver threw/); @@ -229,20 +275,20 @@ describe('AuditPlugin — host locale resolver (AuditPluginOptions.getLocale)', }); it('throw: a rejected promise falls through the same way', async () => { - const { insert, summaries, localeWarns } = await boot({ + const { mention, titles, localeWarns } = await boot({ getLocale: () => Promise.reject(new Error('timeout')), }); - await insert(); - expect(summaries()).toEqual([JA]); + await mention(); + expect(titles()).toEqual([JA]); expect(localeWarns()).toHaveLength(1); expect(localeWarns()[0].meta).toEqual({ err: 'timeout' }); }); it('an answer that is not a well-formed BCP-47 tag falls back, logged once at warn', async () => { - const { insert, summaries, localeWarns } = await boot({ getLocale: () => 'zh_CN' }); - await insert('org-1', 'user-1', 'OC-00001'); - await insert('org-2', 'user-2', 'OC-00002'); - expect(summaries()).toEqual([JA, 'Qualification「OC-00002」を作成しました']); + const { mention, titles, localeWarns } = await boot({ getLocale: () => 'zh_CN' }); + await mention('org-1', 'user-2', 'c-1'); + await mention('org-2', 'user-3', 'c-2'); + expect(titles()).toEqual([JA, JA]); const warned = localeWarns(); expect(warned).toHaveLength(1); expect(warned[0].msg).toContain('"zh_CN"'); diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index b03abf01094..ad14d3cc33c 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -20,6 +20,7 @@ import { installCommentAccessHooks, installCommentReadVisibility } from './comme import { admitMentionRecipients } from './mention-recipient-gate.js'; import { installActivityReadVisibility } from './activity-read-visibility.js'; import { installActivityFieldRedaction } from './activity-field-redaction.js'; +import { installActivitySummaryLocale } from './activity-summary-message.js'; import { installAuditLogFieldRedaction } from './audit-log-field-redaction.js'; import { installAuditLogReadVisibility } from './audit-log-read-visibility.js'; import type { FieldVisibilitySource } from './served-fields.js'; @@ -59,10 +60,11 @@ export interface AuditPluginOptions { /** [#8992] Record-view auditing. Off unless objects are named. */ readAudit?: AuditPluginReadAuditOptions; /** - * Host locale resolver: the language this plugin writes its reader-facing - * text in — `sys_activity.summary`, and the title of the @mention - * notification it emits. Called with the write's organization (`tenantId`) - * and user (`userId`; for an @mention title, the mentioned recipient). + * Host locale resolver: the language this plugin writes the title of the + * @mention notification it emits in. Called with the write's organization + * (`tenantId`) and the mentioned recipient (`userId`). [#26167] + * `sys_activity.summary` is not written in it: the summary is stored in + * English and served in each reader's own locale. * * **Precedence: host first, settings second.** When given, this resolver is * asked first. When it answers nothing (`undefined`, `null` or a blank @@ -357,9 +359,10 @@ export class AuditPlugin implements Plugin { return undefined; } }; - // framework#3039 — localize activity summaries to the workspace default - // locale (ADR-0053 `localization.locale`). Both seams resolve lazily and - // tolerate absence: no i18n / no settings degrades to English summaries. + // framework#3039 — localize the collaboration notification titles to the + // workspace default locale (ADR-0053 `localization.locale`), and + // [#26167] activity summaries to each reader's. Both seams resolve + // lazily and tolerate absence: no i18n / no settings degrades to English. const getI18n = (): AuditI18nSurface | undefined => { try { return ctx.getService('i18n'); @@ -566,6 +569,12 @@ export class AuditPlugin implements Plugin { }; installActivityFieldRedaction(engine as any, getSecurity, ctx.logger); ctx.logger.info('AuditPlugin: sys_activity field redaction installed'); + // [#26167] …and each kept row's summary is composed in the READER's + // language from the code the writer stored beside it. Registered after + // the redaction, so it runs inside it: the redaction judges the + // composed text by the same declared sources as the stored one. + installActivitySummaryLocale(engine as any, getI18n); + ctx.logger.info('AuditPlugin: sys_activity reader-language summaries installed'); // [#21155] The compliance ledger's before/after snapshots take the same // narrowing, through the same answer and the same resolver: a ledger // reader is not field-unrestricted by default. An auditor who must see diff --git a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts index eee4f9ece55..c0456f98570 100644 --- a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts @@ -13,8 +13,35 @@ import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; /** - * [#24235] A summary line is stored ONCE, at write time, so the writer's held - * locale must not outlive a language change. Pinned on `ObjectKernel` + + * [#26167] What the held write locale localizes is the @mention notification + * title: `sys_activity.summary` is stored in English and served in each + * READER's locale. So the title is what this pin reads. The plugin's real + * recipient gate needs a sharing model this harness does not boot, so the + * writers are handed a messaging double and an admitting gate; every other + * option — the locale composition included — is the plugin's own. + */ +const mentionTitles = vi.hoisted(() => [] as string[]); +vi.mock('./audit-writers.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + installAuditWriters: (engine: any, packageId: string, opts: any) => + mod.installAuditWriters(engine, packageId, { + ...opts, + getMessaging: () => ({ + emit: async (e: any) => { + if (e.topic === 'collab.mention') mentionTitles.push(String(e.payload?.title)); + return {}; + }, + }), + admitMentionRecipients: async ({ mentions }: { mentions: string[] }) => [...mentions], + }), + }; +}); + +/** + * [#24235] A notification title is written ONCE, at write time, so the + * writer's held locale must not outlive a language change. Pinned on `ObjectKernel` + * `ObjectQLPlugin` + `AuditPlugin` over a real SQLite driver: the plugin's own * locale composition, the core resolver beneath it (which caches here, since * this engine carries the write epoch) and the engine's real hook dispatch. @@ -28,7 +55,7 @@ import { installDefaultOrganization } from './default-organization.testkit.js'; * the TTL running out: what retires the held locale here is the write, or * nothing does. */ -describe('audit writers — a localization change reaches the very next audited write (#24235)', () => { +describe('audit writers — a localization change reaches the very next @mention title (#24235)', () => { const HARNESS_PACKAGE = 'com.objectstack.audit.test.write-locale-seam'; /** A stand-in under the real name: service-settings owns the definition, and the name is what the seam reads. */ const sysSetting = { @@ -67,6 +94,28 @@ describe('audit writers — a localization change reaches the very next audited return String(rows[0].summary); } + let taskId = ''; + let comments = 0; + /** Mention one user in a comment on the task, as the admin; answer the title the mention was given. */ + async function mentionTitle(): Promise { + if (!taskId) taskId = String(((await engine.insert('biz_task', { name: 'thread' }, asAdmin)) as any).id); + comments += 1; + const before = mentionTitles.length; + await engine.insert( + 'sys_comment', + { + thread_id: `biz_task:${taskId}`, + author_id: 'u_admin', + author_name: 'Ada', + body: `note ${comments}`, + mentions: '["u_bob"]', + }, + asAdmin, + ); + expect(mentionTitles.length - before).toBe(1); + return mentionTitles[mentionTitles.length - 1]; + } + beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); await kernel.use(new ObjectQLPlugin()); @@ -107,7 +156,7 @@ describe('audit writers — a localization change reaches the very next audited } }, 30_000); - it('pin: a settings-store write between two audited writes; the second stores the new language', async () => { + it('pin: a settings-store write between two mentions; the second is titled in the new language', async () => { // The precondition the whole case leans on: this engine carries the write // epoch, so the core resolver beneath the writer caches as it does in a // stock boot rather than declining to. @@ -116,12 +165,12 @@ describe('audit writers — a localization change reaches the very next audited vi.setSystemTime(new Date('2026-10-18T00:00:00Z')); // No row yet: the built-in default (`en-US`), read once and held. - expect(await auditedWrite('T1')).toBe('Created Task "T1"'); + expect(await mentionTitle()).toBe('Ada mentioned you'); // CONTROL: a write with no settings change between is answered from the // held locale — not one settings read. const beforeControl = settingsReads(); - expect(await auditedWrite('T2')).toBe('Created Task "T2"'); + expect(await mentionTitle()).toBe('Ada mentioned you'); expect(settingsReads() - beforeControl).toBe(0); // The change: an insert into the settings store… @@ -130,15 +179,19 @@ describe('audit writers — a localization change reaches the very next audited { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'zh-CN' }, SYSTEM as any, ); - expect(await auditedWrite('T3')).toBe('创建了 Task "T3"'); + expect(await mentionTitle()).toBe('Ada 提到了你'); // …and an update of it, the shape a settings write takes once the row exists. await engine.update('sys_setting', { value: 'ja-JP' }, { where: { key: 'locale' }, multi: true, ...SYSTEM } as any); - expect(await auditedWrite('T4')).toBe('Task「T4」を作成しました'); + expect(await mentionTitle()).toBe('Adaさんがあなたをメンションしました'); // The new answer is held in turn: the next unchanged write reads nothing. const beforeHeld = settingsReads(); - expect(await auditedWrite('T5')).toBe('Task「T5」を作成しました'); + expect(await mentionTitle()).toBe('Adaさんがあなたをメンションしました'); expect(settingsReads() - beforeHeld).toBe(0); + + // [#26167] The audited write itself stores the English sentence under any + // workspace language: it is served in each reader's own. + expect(await auditedWrite('T6')).toBe('Created Task "T6"'); }); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 70a33de8a31..d9219038bad 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -12,6 +12,7 @@ import { installAuditWriters } from './audit-writers.js'; // Do not make these lazy again. import { createMemoryI18n } from '@objectstack/core'; import { AuditTranslations } from './translations/index.js'; +import { summaryAsRead } from './__tests__/summary-as-read.js'; /** * Regression coverage for #1532 — on single-tenant stacks the @@ -1141,7 +1142,7 @@ describe('audit writers — localized activity summaries (framework#3039)', () = return locale; }, }); - return { fire, created, localeCalls: () => localeCalls }; + return { engine, fire, created, localeCalls: () => localeCalls }; } const insertCtx = (object = 'person_qualification') => ({ @@ -1151,27 +1152,44 @@ describe('audit writers — localized activity summaries (framework#3039)', () = session: { organizationId: 'org-1', userId: 'user-1' }, }); - it('localizes verb + object label to the workspace locale (zh-CN)', async () => { - const { fire, created } = setup('zh-CN', makeI18n()); + // [#26167] A summary is STORED in English (the authored labels in the English + // catalog) beside its code, and composed in each READER's language on the + // read side; `summaryIn` applies that read side's overlay to a written row. + const activityRows = (created: CapturedRow[]) => created.filter((c) => c.object === 'sys_activity').map((c) => c.row); + const summaryIn = (row: Record | undefined, locale: string, i18n: unknown, engine: unknown) => + summaryAsRead(row, i18n, locale, engine as any); + + it('stores the English sentence; a zh-CN reader is served verb + object label in zh-CN', async () => { + const i18n = makeI18n(); + const { engine, fire, created } = setup('zh-CN', i18n); await fire('afterInsert', insertCtx()); await fire('afterDelete', { ...insertCtx(), result: null, previous: { id: 'q-1', name: 'OC-00001' } }); - const summaries = created.filter((c) => c.object === 'sys_activity').map((c) => c.row.summary); - expect(summaries).toEqual(['创建了 人员资质 "OC-00001"', '删除了 人员资质 "OC-00001"']); + const rows = activityRows(created); + expect(rows.map((r) => r.summary)).toEqual([ + 'Created person_qualification "OC-00001"', + 'Deleted person_qualification "OC-00001"', + ]); + expect(rows.map((r) => summaryIn(r, 'zh-CN', i18n, engine))).toEqual([ + '创建了 人员资质 "OC-00001"', + '删除了 人员资质 "OC-00001"', + ]); }); - it('localizes the generic update fallback', async () => { - const { fire, created } = setup('zh-CN', makeI18n()); + it('localizes the generic update fallback for the reader', async () => { + const i18n = makeI18n(); + const { engine, fire, created } = setup('zh-CN', i18n); await fire('afterUpdate', { ...insertCtx(), previous: { id: 'q-1', name: 'OC-00001', status: 'draft' }, result: { id: 'q-1', name: 'OC-00001', status: 'active' }, }); - const activity = created.find((c) => c.object === 'sys_activity'); + const [activity] = activityRows(created); // [#25787] `status` is undeclared here, so it is named by its key; the - // list and its frame are both the zh-CN bundle's. - expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001":status'); + // list and its frame are both the reader's bundle's. + expect(activity.summary).toBe('Updated person_qualification "OC-00001": status'); + expect(summaryIn(activity, 'zh-CN', i18n, engine)).toBe('更新了 人员资质 "OC-00001":status'); }); it('keeps a locale\'s field-less frame when its bundle has no named-field frame', async () => { @@ -1182,17 +1200,17 @@ describe('audit writers — localized activity summaries (framework#3039)', () = objects: { person_qualification: { label: 'Qualification' } }, messages: { activityUpdated: 'A mis à jour {{object}} « {{label}} »' }, }); - const { fire, created } = setup('fr-FR', i18n); + const { engine, fire, created } = setup('fr-FR', i18n); await fire('afterUpdate', { ...insertCtx(), previous: { id: 'q-1', name: 'OC-00001', status: 'draft' }, result: { id: 'q-1', name: 'OC-00001', status: 'active' }, }); - const activity = created.find((c) => c.object === 'sys_activity'); - expect(activity!.row.summary).toBe('A mis à jour Qualification « OC-00001 »'); + const [activity] = activityRows(created); + expect(summaryIn(activity, 'fr-FR', i18n, engine)).toBe('A mis à jour Qualification « OC-00001 »'); }); - it('localizes the named-field list and its "more" item (zh-CN)', async () => { + it('localizes the named-field list and its "more" item for the reader (zh-CN)', async () => { const fields: Record = { id: { type: 'text' }, name: { type: 'text' } }; const previous: Record = { id: 'q-1', name: 'OC-00001' }; const result: Record = { id: 'q-1', name: 'OC-00001' }; @@ -1201,23 +1219,30 @@ describe('audit writers — localized activity summaries (framework#3039)', () = previous[`f${i}`] = 0; result[`f${i}`] = 1; } - const { fire, created } = setup('zh-CN', makeI18n(), {}, { ...SINGLE_TENANT, person_qualification: fields }); + const i18n = makeI18n(); + const { engine, fire, created } = setup('zh-CN', i18n, {}, { ...SINGLE_TENANT, person_qualification: fields }); await fire('afterUpdate', { ...insertCtx(), previous, result }); - const activity = created.find((c) => c.object === 'sys_activity'); - expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001":F1、F2、F3、F4、F5和另外 2 个字段'); + const [activity] = activityRows(created); + expect(activity.summary).toBe('Updated person_qualification "OC-00001": F1, F2, F3, F4, F5, and 2 more'); + expect(summaryIn(activity, 'zh-CN', i18n, engine)).toBe( + '更新了 人员资质 "OC-00001":F1、F2、F3、F4、F5和另外 2 个字段', + ); }); it('falls back to the object def label, then English, when a translation misses', async () => { - // Locale resolves but the object has no zh-CN label entry → verb is - // localized, label falls back to the authored def label. - const { fire, created } = setup( + // The reader's locale resolves but the object has no zh-CN label entry → + // the verb is localized, the label falls back to the authored def label. + const i18n = makeI18n(); + const { engine, fire, created } = setup( 'zh-CN', - makeI18n(), + i18n, { crm_lead: { label: 'Lead' } }, { ...SINGLE_TENANT, crm_lead: ['id', 'name'] }, ); await fire('afterInsert', { ...insertCtx('crm_lead'), result: { id: 'q-1', name: 'Acme' } }); - expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe('创建了 Lead "Acme"'); + const [activity] = activityRows(created); + expect(activity.summary).toBe('Created Lead "Acme"'); + expect(summaryIn(activity, 'zh-CN', i18n, engine)).toBe('创建了 Lead "Acme"'); }); it('keeps English summaries when no i18n service is resolvable', async () => { @@ -1237,11 +1262,56 @@ describe('audit writers — localized activity summaries (framework#3039)', () = ); }); - it('memoizes the locale lookup per tenant/user scope (hot-path guard)', async () => { + it('[#26167] an audited write asks for no locale at all (hot-path guard)', async () => { const { fire, localeCalls } = setup('zh-CN', makeI18n()); await fire('afterInsert', insertCtx()); await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); - expect(localeCalls()).toBe(1); + expect(localeCalls()).toBe(0); + }); + + // The held write locale now localizes only the @mention notification title + // ([#26167]: a summary asks for none), so its memo and its #24235 retirement + // are pinned on that title. + const mentionCtx = (id: string, organizationId = 'org-1') => ({ + object: 'sys_comment', + input: {}, + result: { + id, + thread_id: 'crm_lead:l-1', + author_id: 'user-1', + author_name: 'Alice', + body: 'hello', + mentions: '["user-2"]', + }, + session: { organizationId, userId: 'user-1' }, + }); + function setupMentions(getLocale: () => Promise) { + const { engine, fire } = makeEngine(WITH_QUALIFICATION); + const titles: string[] = []; + installAuditWriters(engine as any, 'test.audit', { + getI18n: () => makeI18n() as any, + getLocale, + getMessaging: () => ({ + emit: async (e: any) => { + if (e.topic === 'collab.mention') titles.push(e.payload.title); + return {}; + }, + }), + admitMentionRecipients: async ({ mentions }) => [...mentions], + }); + return { fire, titles }; + } + + it('memoizes the locale lookup per tenant/user scope (hot-path guard)', async () => { + let calls = 0; + const { fire, titles } = setupMentions(async () => { + calls += 1; + return 'zh-CN'; + }); + await fire('afterInsert', mentionCtx('c-1')); + await fire('afterInsert', mentionCtx('c-2')); + expect(titles).toEqual(['Alice 提到了你', 'Alice 提到了你']); + expect(calls).toBe(1); }); // [#24235] The held locale is retired by a write to either settings store; @@ -1255,28 +1325,22 @@ describe('audit writers — localized activity summaries (framework#3039)', () = previous: { id: 's-1', namespace: 'localization', key: 'locale' }, session: {}, }); - const summaryOf = (created: CapturedRow[], name: string) => - created.find((c) => c.object === 'sys_activity' && c.row.record_label === name)?.row.summary; it('[#24235] a write to either settings store retires the held locale; without one it is held', async () => { for (const store of [undefined, 'sys_setting', 'sys_platform_setting']) { - const { engine, fire, created } = makeEngine(WITH_QUALIFICATION); let locale: string | undefined; - installAuditWriters(engine as any, 'test.audit', { getI18n: () => makeI18n() as any, getLocale: async () => locale }); - await fire('afterInsert', insertCtx()); + const { fire, titles } = setupMentions(async () => locale); + await fire('afterInsert', mentionCtx('c-1')); locale = 'zh-CN'; if (store) await fire('afterUpdate', settingsWrite(store)); - await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); - expect(summaryOf(created, 'OC-00001'), String(store)).toBe('Created person_qualification "OC-00001"'); + await fire('afterInsert', mentionCtx('c-2')); + expect(titles[0], String(store)).toBe('Alice mentioned you'); // The control (`undefined`: no settings write) still answers from the held locale. - expect(summaryOf(created, 'OC-00002'), String(store)).toBe( - store ? '创建了 人员资质 "OC-00002"' : 'Created person_qualification "OC-00002"', - ); + expect(titles[1], String(store)).toBe(store ? 'Alice 提到了你' : 'Alice mentioned you'); } }); it('[#24235] a settings write landing while the lookup is in flight leaves the pre-write answer unheld', async () => { - const { engine, fire, created } = makeEngine(WITH_QUALIFICATION); let entered!: () => void; const lookupStarted = new Promise((resolve) => { entered = resolve; @@ -1286,26 +1350,22 @@ describe('audit writers — localized activity summaries (framework#3039)', () = release = resolve; }); let calls = 0; - installAuditWriters(engine as any, 'test.audit', { - getI18n: () => makeI18n() as any, - getLocale: async () => { - calls += 1; - if (calls === 1) { - entered(); - await inFlight; // read before the write below, answered after it - return undefined; - } - return 'zh-CN'; - }, + const { fire, titles } = setupMentions(async () => { + calls += 1; + if (calls === 1) { + entered(); + await inFlight; // read before the write below, answered after it + return undefined; + } + return 'zh-CN'; }); - const first = fire('afterInsert', insertCtx()); + const first = fire('afterInsert', mentionCtx('c-1')); await lookupStarted; await fire('afterUpdate', settingsWrite('sys_setting')); release(); await first; - await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); - expect(summaryOf(created, 'OC-00001')).toBe('Created person_qualification "OC-00001"'); - expect(summaryOf(created, 'OC-00002')).toBe('创建了 人员资质 "OC-00002"'); + await fire('afterInsert', mentionCtx('c-2')); + expect(titles).toEqual(['Alice mentioned you', 'Alice 提到了你']); }); // Collaboration notification titles (assignment / @mention) are localized to diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 9089f9de98c..d616fd907e9 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -96,6 +96,17 @@ import { installActivityStampHook, planActivityStamp, } from './activity-changed-fields.js'; +// [#26167] A summary's code, and the ONE composer of its text — the stored +// sentence here and each reader's on the read side. +import { + ACTIVITY_SUMMARY_MESSAGE_KEY, + composeStoredSummary, + withEnglishMessages, + type ActivitySummaryFieldChange, + type ActivitySummaryLocale, + type ActivitySummaryMessage, + type ActivitySummaryValue, +} from './activity-summary-message.js'; /** * Minimal structural view of `NotificationService.emit` (ADR-0030). Declared @@ -138,9 +149,10 @@ export interface AuditWriterOptions { */ getMessaging?(): MessagingEmitSurface | undefined; /** - * Lazily resolve the i18n service used to localize activity summaries - * (framework#3039): verb templates (`messages.activityCreated` …) and object - * display labels (`objects.{name}.label`). Absent → English summaries. + * Lazily resolve the i18n service used to localize the collaboration + * notification titles (`messages.mentionedYou` …). [#26167] Activity + * summaries are stored in English and localized per READER on the read side + * (`installActivitySummaryLocale`). */ getI18n?(): AuditI18nSurface | undefined; /** @@ -149,7 +161,8 @@ export interface AuditWriterOptions { * per scope, so implementations may hit the settings service directly. A * held answer lasts until the next write to `sys_setting` or * `sys_platform_setting` through this engine, or a short TTL, whichever - * comes first. Absent → summaries stay English (status quo). + * comes first. Absent → notification titles stay English. [#26167] An + * activity summary asks for no locale at write time. */ getLocale?(tenantId?: string, userId?: string): Promise; /** @@ -702,10 +715,21 @@ function displayFieldValue( titlesFor?: Map, optionLabelFor?: (optionValue: string) => string | undefined, ): string { - if (value === null || value === undefined || value === '') return '∅'; + const shown = summaryValueOf(field, value, titlesFor); + return shown.option !== undefined ? (optionLabelFor?.(shown.option) ?? shown.text) : shown.text; +} + +/** + * [#26167] {@link displayFieldValue} before any option label is translated: + * the text it renders with no `optionLabelFor`, and the declared option the + * value matched, if any. A field-change summary stores this pair, so the + * option label is translated for each READER (`activity-summary-message.ts`). + */ +function summaryValueOf(field: any, value: any, titlesFor?: Map): ActivitySummaryValue { + if (value === null || value === undefined || value === '') return { text: '∅' }; if (titlesFor && typeof field?.type === 'string' && REFERENCE_FIELD_TYPES.has(field.type)) { const ids = referenceIdsOf(value); - if (ids.length > 0) return ids.map((id) => titlesFor.get(id) ?? id).join(', '); + if (ids.length > 0) return { text: ids.map((id) => titlesFor.get(id) ?? id).join(', ') }; } const options = field?.options; if (Array.isArray(options)) { @@ -713,11 +737,11 @@ function displayFieldValue( const ov = o && typeof o === 'object' ? (o.value ?? o.name ?? o.label) : o; if (ov === value) { const ol = o && typeof o === 'object' ? (o.label ?? o.name ?? ov) : o; - return optionLabelFor?.(String(ov)) ?? String(ol); + return { text: String(ol), option: String(ov) }; } } } - return String(value); + return { text: String(value) }; } /** @@ -763,9 +787,9 @@ function planTrackedLookupReads( * generic "Updated ", which names the changed fields * ({@link renderChangedFieldsSummary}). * - * [#7230] `translate` is the SAME locale-bound translator the three sibling + * [#7230] `locale` is the SAME locale-bound translator the three sibling * summary branches already resolve through (`messages.activityCreated` / - * `messages.activityDeleted` / `messages.activityUpdated`, and `displayLabelFor` + * `messages.activityDeleted` / `messages.activityUpdated`, and `objectLabelIn` * for the object name). This branch was the one never handed it, so a * fully-localized page ended with an English field label — ADR-0053 / * framework#3039 write-time localization, applied where it was missed. The key @@ -784,58 +808,37 @@ function planTrackedLookupReads( * [#21081] Returns the fields it rendered beside the text — the same loop * answers both, so the activity row's declaration of where its summary came * from cannot drift from what was rendered. + * + * [#26167] …and the summary's CODE: each change's field KEY and its two + * values as {@link summaryValueOf} renders them. The #7230 / #7289 + * localization now happens per READER, by the one composer + * (`composeActivitySummary`): the text returned here is that code composed in + * `locale`, the stored language. */ function renderTrackedChangeSummary( objectName: string, fields: Record | undefined | null, oldVals: Record | null, newVals: Record | null, - translate: (key: string, params?: Record) => string | undefined, + locale: ActivitySummaryLocale, lookupTitles?: Map>, -): { text: string; fields: string[] } | null { +): { text: string; fields: string[]; message: ActivitySummaryMessage } | null { if (!fields || !newVals) return null; - const parts: string[] = []; - const rendered: string[] = []; + const changes: ActivitySummaryFieldChange[] = []; for (const key of Object.keys(newVals)) { const field = fields[key]; if (!field || field.trackHistory !== true) continue; - const label = fieldLabelForSummary(objectName, key, field, translate); const reference = referenceTargetForSummary(field); const titlesFor = reference ? lookupTitles?.get(reference) : undefined; - // Only a field that DECLARES options can consume this, so the ordinary - // tracked scalar allocates no closure; both calls below share the one. - const optionLabelFor = Array.isArray(field.options) - ? (optionValue: string): string | undefined => - translate(`objects.${objectName}.fields.${key}.options.${optionValue}`) - : undefined; - const from = displayFieldValue( - field, - oldVals ? oldVals[key] : undefined, - titlesFor, - optionLabelFor, - ); - const to = displayFieldValue(field, newVals[key], titlesFor, optionLabelFor); - parts.push(`${label}: ${from} → ${to}`); - rendered.push(key); + changes.push({ + field: key, + from: summaryValueOf(field, oldVals ? oldVals[key] : undefined, titlesFor), + to: summaryValueOf(field, newVals[key], titlesFor), + }); } - return parts.length > 0 ? { text: parts.join('; '), fields: rendered } : null; -} - -/** - * A field's label inside an activity summary: the locale bundle's - * (`objects..fields..label`), then the authored def label, then - * the machine key. One answer for both branches that name a field. - */ -function fieldLabelForSummary( - objectName: string, - key: string, - field: any, - translate: (key: string, params?: Record) => string | undefined, -): string { - return ( - translate(`objects.${objectName}.fields.${key}.label`) ?? - (typeof field?.label === 'string' && field.label.length > 0 ? field.label : key) - ); + if (changes.length === 0) return null; + const message: ActivitySummaryMessage = { key: 'fieldChanges', object: objectName, changes }; + return { text: composeStoredSummary(message, locale), fields: changes.map((c) => c.field), message }; } /** @@ -858,23 +861,26 @@ const MAX_NAMED_CHANGED_FIELDS = 5; * rest. When `sys_activity.summary` declares `maxLength` (the SQL driver sizes * the column `varchar(n)` from it, so an over-long text fails the whole * activity insert), fewer fields are named until the text fits. Returns null - * when nothing changed, when not even one name fits, or when the locale has a - * field-less frame but no named-field one, and the caller writes the - * field-less frame. + * when nothing changed or when not even one name fits, and the caller writes + * the field-less frame. * * Returns the fields it COUNTED as well as named in `fields`: the read side * drops a summary composed from a field the reader is not served * (`activity-field-redaction.ts`), and "and 3 more" says three fields changed. + * + * [#26167] …and the summary's CODE: the field KEYS it names and the count it + * folds, which a reader's language re-composes (`activity-summary-message.ts` + * owns the composition, the list join included). The fit to `maxLength` is the + * stored sentence's. */ function renderChangedFieldsSummary( objectName: string, fields: Record | undefined | null, newVals: Record | null, - translate: (key: string, params?: Record) => string | undefined, - locale: string | undefined, - frame: { object: string; label: string }, + locale: ActivitySummaryLocale, + label: string, maxLength: number | undefined, -): { text: string; fields: string[] } | null { +): { text: string; fields: string[]; message: ActivitySummaryMessage } | null { if (!newVals) return null; const changed = new Set(Object.keys(newVals)); if (changed.size === 0) return null; @@ -882,55 +888,20 @@ function renderChangedFieldsSummary( ...Object.keys(fields ?? {}).filter((key) => changed.has(key)), ...[...changed].filter((key) => !fields || !(key in fields)).sort(), ]; - const labels = ordered.map((key) => fieldLabelForSummary(objectName, key, fields?.[key], translate)); - // The list joins in the locale whose frame is used, so an English fallback - // frame never carries a zh-CN "、" list. A locale whose bundle answers only - // the field-less `activityUpdated` (an app's own locale, written before this - // key existed) keeps that localized frame rather than an English one. - const localized = translate('messages.activityUpdatedFields', { ...frame, fields: '' }) !== undefined; - if (!localized && translate('messages.activityUpdated', frame) !== undefined) return null; - const joinList = listJoinerFor(localized ? locale : undefined); - const compose = (shown: number): string => { - const items = labels.slice(0, shown); - const rest = labels.length - shown; - if (rest > 0) { - items.push(translate('messages.activityMoreFields', { count: rest }) ?? `${rest} more`); - } - const list = joinList(items); - return ( - translate('messages.activityUpdatedFields', { ...frame, fields: list }) ?? - `Updated ${frame.object} "${frame.label}": ${list}` - ); - }; - for (let shown = Math.min(MAX_NAMED_CHANGED_FIELDS, labels.length); shown >= 1; shown -= 1) { - const text = compose(shown); - if (maxLength === undefined || text.length <= maxLength) return { text, fields: ordered }; + for (let shown = Math.min(MAX_NAMED_CHANGED_FIELDS, ordered.length); shown >= 1; shown -= 1) { + const message: ActivitySummaryMessage = { + key: 'activityUpdatedFields', + object: objectName, + label, + fields: ordered.slice(0, shown), + more: ordered.length - shown, + }; + const text = composeStoredSummary(message, locale); + if (maxLength === undefined || text.length <= maxLength) return { text, fields: ordered, message }; } return null; } -/** A locale's conjunction list ("A, B, and C" / "A、B和C"); `", "` where `Intl` cannot answer. */ -function listJoinerFor(locale: string | undefined): (items: string[]) => string { - // `Intl.ListFormat` is ES2021; this package compiles against the ES2020 lib, - // so the constructor is probed rather than assumed (as `plugin-auth`'s - // invitation role label does). - const ListFormat = (Intl as unknown as { ListFormat?: IntlListFormatConstructor }).ListFormat; - try { - if (ListFormat) { - const format = new ListFormat(locale ?? 'en', { type: 'conjunction' }); - return (items) => format.format(items); - } - } catch { - // A tag `Intl` refuses (a tenant's own locale spelling) still gets a list. - } - return (items) => items.join(', '); -} - -type IntlListFormatConstructor = new ( - locale: string, - options: { type: 'conjunction' }, -) => { format(list: readonly string[]): string }; - /** * [#7290] The `{token}` pattern of an `activityMilestones` summary template. * @@ -1473,6 +1444,15 @@ export function installAuditWriters( localeGeneration += 1; }; + // [#26167] The language an activity summary is STORED in: the English + // catalog over the authored labels. Every reader with a locale is served + // the summary composed in theirs (`activity-summary-message.ts`). + const storedSummaryLocale: ActivitySummaryLocale = withEnglishMessages({ + translate: () => undefined, + locale: undefined, + getObjectDef: (objectName) => getObjectDef(objectName), + }); + // Translate function bound to a locale. Returns undefined on any miss // (no locale / no i18n / key miss) so callers keep their English fallback // (framework#3039). @@ -1597,20 +1577,6 @@ export function installAuditWriters( } }; - // Display label for an object under a given translate fn: translated label - // → authored def label → API name. Shared by activity summaries and the - // collaboration notification titles below. - const displayLabelFor = ( - objectName: string, - translate: (key: string, params?: Record) => string | undefined, - ): string => { - const def = getObjectDef(objectName); - return ( - translate(`objects.${objectName}.label`) ?? - (typeof def?.label === 'string' && def.label.length > 0 ? def.label : objectName) - ); - }; - /** * A title column's value as the text an activity row writes, or `null` when * it carries none (absent, `null`, or blank once trimmed). ONE rule for both @@ -2220,24 +2186,21 @@ export function installAuditWriters( // milestone fires; every other row carries no kind. let kind: { value: string; sources: string[] } | undefined; // Summaries are user-facing (the record Discussion feed and Setup - // dashboards render them verbatim), so name the object by its display - // label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo"), and - // localize both the verb template and the object label to the workspace - // default locale (ADR-0053, framework#3039). Every step is best-effort: - // no locale / no i18n / key miss all degrade to the English literal. - const locale = await resolveWriteLocale(tenantId, userId); - const translate = translateWith(locale); - const objectDisplay = displayLabelFor(ctx.object, translate); + // dashboards render them), so name the object by its display label + // ("Semantic Zoo"), not its API name ("showcase_semantic_zoo"). + // [#26167] Each branch records its summary as a CODE (`summaryMessage`), + // and the read side composes it in the READER's language + // (`activity-summary-message.ts`). The stored `summary` is the English + // sentence, the fallback text for every reader that composition does not + // reach, so no write asks for a locale. A fired milestone's author-written + // template has no code and stays as written. + const writeLocale = storedSummaryLocale; let summary: string; + let summaryMessage: ActivitySummaryMessage | undefined; let activityType: string = activityTypeFor(action); - if (action === 'create') { - summary = - translate('messages.activityCreated', { object: objectDisplay, label }) ?? - `Created ${objectDisplay} "${label}"`; - } else if (action === 'delete') { - summary = - translate('messages.activityDeleted', { object: objectDisplay, label }) ?? - `Deleted ${objectDisplay} "${label}"`; + if (action === 'create' || action === 'delete') { + summaryMessage = { key: action === 'create' ? 'activityCreated' : 'activityDeleted', object: ctx.object, label }; + summary = composeStoredSummary(summaryMessage, writeLocale); } else { // ADR-0052 §5b — declarative activity, precedence: a configured semantic // milestone (§5b.2) wins; else a tracked field-change diff ("Stage: @@ -2293,11 +2256,12 @@ export function installAuditWriters( summaryFields, oldValue, newValue, - translate, + writeLocale, lookupTitles, ); if (tracked) { summary = tracked.text; + summaryMessage = tracked.message; summarySources = tracked.fields; } else { // [#25787] No tracked field changed: name the fields that did. @@ -2305,18 +2269,17 @@ export function installAuditWriters( ctx.object, summaryFields, newValue, - translate, - locale, - { object: objectDisplay, label }, + writeLocale, + label, activitySummaryMaxLength(), ); if (named) { summary = named.text; + summaryMessage = named.message; summarySources = [...new Set([...labelSources, ...named.fields])]; } else { - summary = - translate('messages.activityUpdated', { object: objectDisplay, label }) ?? - `Updated ${objectDisplay} "${label}"`; + summaryMessage = { key: 'activityUpdated', object: ctx.object, label }; + summary = composeStoredSummary(summaryMessage, writeLocale); } } } @@ -2348,6 +2311,7 @@ export function installAuditWriters( new: newValue, ...(kind ? { [ACTIVITY_KIND_KEY]: kind.value } : {}), [ACTIVITY_TEXT_SOURCES_KEY]: textSources, + ...(summaryMessage ? { [ACTIVITY_SUMMARY_MESSAGE_KEY]: summaryMessage } : {}), }; const activityRow: Record = { type: activityType,