diff --git a/.changeset/plugin-audit-summary-i18n-3039.md b/.changeset/plugin-audit-summary-i18n-3039.md new file mode 100644 index 0000000000..40d69d2513 --- /dev/null +++ b/.changeset/plugin-audit-summary-i18n-3039.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-audit': patch +--- + +Localize activity summaries to the workspace default locale (#3039). Activity +writers previously hardcoded English verbs and the object API name +(`Created person_qualification "OC-00001"`). The writer now resolves the +ADR-0053 `localization.locale` setting per write (memoized per tenant/user +scope), renders the verb through new `messages.activityCreated/Updated/Deleted` +i18n templates (en, zh-CN, ja-JP, es-ES shipped), and names the object by its +localized label (`objects.{name}.label`) with fallback to the authored def +label, then the API name. Missing i18n/settings services or bundle keys +degrade to the previous English summaries. diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 16d782937a..b305a873fd 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveLocalizationContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; // `sys_notification` is contributed here but owned by platform-objects; it is @@ -9,7 +10,7 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; // storage (ADR-0052 §3 ownership: a file↔record link belongs with storage, not // the compliance ledger). import { SysNotification } from '@objectstack/platform-objects/audit'; -import { installAuditWriters, type MessagingEmitSurface } from './audit-writers.js'; +import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js'; /** * AuditPlugin @@ -108,7 +109,27 @@ export class AuditPlugin implements Plugin { return undefined; } }; - installAuditWriters(engine as any, this.name, { getMessaging }); + // 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. + const getI18n = (): AuditI18nSurface | undefined => { + try { + return ctx.getService('i18n'); + } catch { + return undefined; + } + }; + const getLocale = async (tenantId?: string, userId?: string): Promise => { + let settings: unknown; + try { + settings = ctx.getService('settings'); + } catch { + settings = undefined; + } + const { locale } = await resolveLocalizationContext({ ql: engine, settings, tenantId, userId }); + return locale; + }; + installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale }); process.stderr.write('[AuditPlugin] writers installed\n'); ctx.logger.info('AuditPlugin: audit + activity writers installed'); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 7aa750de91..acd497ac35 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -439,3 +439,105 @@ describe('audit writers — enable.files server-side enforcement (#2727)', () => await expect(fire('beforeInsert', attachmentInsert(undefined))).resolves.toBeUndefined(); }); }); + +describe('audit writers — localized activity summaries (framework#3039)', () => { + // Real memory i18n (what the kernel registers as the 'i18n' fallback) loaded + // with this plugin's shipped bundle plus an app-contributed object label — + // exercises the actual key shapes (`messages.activityCreated`, + // `objects.{name}.label`) end to end. + async function makeI18n() { + const { createMemoryI18n } = await import('@objectstack/core'); + const { AuditTranslations } = await import('./translations/index.js'); + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(AuditTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + i18n.loadTranslations('zh-CN', { + objects: { person_qualification: { label: '人员资质' } }, + }); + return i18n; + } + + function setup( + locale: string | undefined, + i18n?: { t: Function }, + objectDefs: Record = {}, + schemas: Record> = SINGLE_TENANT, + ) { + const { engine, fire, created } = makeEngine(schemas, objectDefs); + let localeCalls = 0; + installAuditWriters(engine as any, 'test.audit', { + getI18n: () => i18n as any, + getLocale: async () => { + localeCalls += 1; + return locale; + }, + }); + return { fire, created, localeCalls: () => localeCalls }; + } + + const insertCtx = (object = 'person_qualification') => ({ + object, + input: { id: 'q-1' }, + result: { id: 'q-1', name: 'OC-00001' }, + session: { tenantId: 'org-1', userId: 'user-1' }, + }); + + it('localizes verb + object label to the workspace locale (zh-CN)', async () => { + const { fire, created } = setup('zh-CN', await makeI18n()); + + 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"']); + }); + + it('localizes the generic update fallback', async () => { + const { fire, created } = setup('zh-CN', await makeI18n()); + 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('更新了 人员资质 "OC-00001"'); + }); + + 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( + 'zh-CN', + await makeI18n(), + { 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"'); + }); + + it('keeps English summaries when no i18n service is resolvable', async () => { + const { fire, created } = setup('zh-CN', undefined); + await fire('afterInsert', insertCtx()); + expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe( + 'Created person_qualification "OC-00001"', + ); + }); + + it('keeps English summaries without a locale resolver (status quo)', async () => { + const { engine, fire, created } = makeEngine(SINGLE_TENANT); + installAuditWriters(engine as any, 'test.audit', { getI18n: () => undefined }); + await fire('afterInsert', insertCtx()); + expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe( + 'Created person_qualification "OC-00001"', + ); + }); + + it('memoizes the locale lookup per tenant/user scope (hot-path guard)', async () => { + const { fire, localeCalls } = setup('zh-CN', await makeI18n()); + await fire('afterInsert', insertCtx()); + await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); + expect(localeCalls()).toBe(1); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 749cf7d839..994186f2d2 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -22,6 +22,17 @@ export interface MessagingEmitSurface { }): Promise; } +/** + * Minimal structural view of `II18nService.t`. Declared locally (same + * rationale as {@link MessagingEmitSurface}) so plugin-audit resolves whatever + * object is registered under the `i18n` service without a runtime dependency + * on service-i18n. The kernel always registers at least the in-memory + * fallback, and `t` returns the key verbatim on a miss. + */ +export interface AuditI18nSurface { + t(key: string, locale: string, params?: Record): string; +} + /** Options for {@link installAuditWriters}. */ export interface AuditWriterOptions { /** @@ -32,6 +43,19 @@ export interface AuditWriterOptions { * matching the `notify` node's degradation. */ 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. + */ + getI18n?(): AuditI18nSurface | undefined; + /** + * Resolve the workspace default locale (ADR-0053 `localization.locale`) for + * the write's tenant/user scope. Called per audited write but memoized here + * with a short TTL, so implementations may hit the settings service + * directly. Absent → summaries stay English (status quo). + */ + getLocale?(tenantId?: string, userId?: string): Promise; } /** @@ -244,6 +268,28 @@ export function installAuditWriters( if (!engine || typeof engine.registerHook !== 'function') return; const getMessaging = opts.getMessaging ?? (() => undefined); + const getI18n = opts.getI18n ?? (() => undefined); + + // Workspace locale changes rarely, but writeAudit runs on every CRUD write — + // memoize the settings lookup per principal scope with a short TTL so audit + // logging doesn't add a settings query to every mutation's hot path. + const LOCALE_TTL_MS = 30_000; + const localeCache = new Map(); + const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise => { + if (!opts.getLocale) return undefined; + const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`; + const now = Date.now(); + const hit = localeCache.get(cacheKey); + if (hit && hit.expires > now) return hit.value; + let value: string | undefined; + try { + value = await opts.getLocale(tenantId, userId); + } catch { + value = undefined; + } + localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS }); + return value; + }; // Remove any prior installation so we can safely re-install on hot reload. if (typeof engine.unregisterHooksByPackage === 'function') { @@ -449,19 +495,39 @@ export function installAuditWriters( const label = recordLabel(after ?? before, recordId ?? ''); // 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"). - // Best-effort: falls back to the API name when the def isn't resolvable. + // 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 = (key: string, params?: Record): string | undefined => { + if (!locale) return undefined; + const i18n = getI18n(); + if (!i18n || typeof i18n.t !== 'function') return undefined; + try { + const value = i18n.t(key, locale, params); + // A miss returns the key verbatim (II18nService contract). + return typeof value === 'string' && value !== key ? value : undefined; + } catch { + return undefined; + } + }; const objectDef = getObjectDef(ctx.object); const objectDisplay = - typeof objectDef?.label === 'string' && objectDef.label.length > 0 + translate(`objects.${ctx.object}.label`) ?? + (typeof objectDef?.label === 'string' && objectDef.label.length > 0 ? objectDef.label - : ctx.object; + : ctx.object); let summary: string; let activityType: string = activityTypeFor(action); if (action === 'create') { - summary = `Created ${objectDisplay} "${label}"`; + summary = + translate('messages.activityCreated', { object: objectDisplay, label }) ?? + `Created ${objectDisplay} "${label}"`; } else if (action === 'delete') { - summary = `Deleted ${objectDisplay} "${label}"`; + summary = + translate('messages.activityDeleted', { object: objectDisplay, label }) ?? + `Deleted ${objectDisplay} "${label}"`; } else { // ADR-0052 §5b — declarative activity, precedence: a configured semantic // milestone (§5b.2) wins; else a tracked field-change diff ("Stage: @@ -473,6 +539,7 @@ export function installAuditWriters( } else { summary = renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ?? + translate('messages.activityUpdated', { object: objectDisplay, label }) ?? `Updated ${objectDisplay} "${label}"`; } } diff --git a/packages/plugins/plugin-audit/src/translations/index.ts b/packages/plugins/plugin-audit/src/translations/index.ts index a90f97e45d..0d7f606c31 100644 --- a/packages/plugins/plugin-audit/src/translations/index.ts +++ b/packages/plugins/plugin-audit/src/translations/index.ts @@ -15,10 +15,11 @@ import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { enMessages, zhCNMessages, jaJPMessages, esESMessages } from './messages.js'; export const AuditTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: { objects: enObjects, messages: enMessages }, + 'zh-CN': { objects: zhCNObjects, messages: zhCNMessages }, + 'ja-JP': { objects: jaJPObjects, messages: jaJPMessages }, + 'es-ES': { objects: esESObjects, messages: esESMessages }, }; diff --git a/packages/plugins/plugin-audit/src/translations/messages.ts b/packages/plugins/plugin-audit/src/translations/messages.ts new file mode 100644 index 0000000000..717355a878 --- /dev/null +++ b/packages/plugins/plugin-audit/src/translations/messages.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Activity summary verb templates (framework#3039). + * + * Keys are single-segment on purpose: both i18n implementations (the core + * memory fallback and service-i18n's FileI18nAdapter) resolve dot-notation + * keys by walking NESTED objects, so a flat record key containing a dot + * (`'activity.created'`) would never resolve — `messages.activityCreated` + * does. Interpolation uses the shared `{{param}}` convention. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +type Messages = NonNullable; + +export const enMessages: Messages = { + activityCreated: 'Created {{object}} "{{label}}"', + activityUpdated: 'Updated {{object}} "{{label}}"', + activityDeleted: 'Deleted {{object}} "{{label}}"', +}; + +export const zhCNMessages: Messages = { + activityCreated: '创建了 {{object}} "{{label}}"', + activityUpdated: '更新了 {{object}} "{{label}}"', + activityDeleted: '删除了 {{object}} "{{label}}"', +}; + +export const jaJPMessages: Messages = { + activityCreated: '{{object}}「{{label}}」を作成しました', + activityUpdated: '{{object}}「{{label}}」を更新しました', + activityDeleted: '{{object}}「{{label}}」を削除しました', +}; + +export const esESMessages: Messages = { + activityCreated: 'Creó {{object}} "{{label}}"', + activityUpdated: 'Actualizó {{object}} "{{label}}"', + activityDeleted: 'Eliminó {{object}} "{{label}}"', +};