Skip to content

Commit 21f75ce

Browse files
authored
fix(plugin-audit): localize activity summaries to the workspace locale (#3039) (#3045)
Activity writers hardcoded English verbs and the object API name (`Created person_qualification "OC-00001"`) regardless of the workspace localization.locale setting. writeAudit now: - resolves the ADR-0053 workspace locale per write via a lazy getLocale seam (resolveLocalizationContext), memoized per tenant/user scope with a 30s TTL so audit logging adds no settings query to the CRUD hot path - renders create/delete and the generic update fallback through new messages.activityCreated/Updated/Deleted i18n templates (en, zh-CN, ja-JP, es-ES shipped in the plugin bundle); keys are single-segment because both i18n implementations resolve dot-paths by walking nested objects - names the object by its localized label (objects.{name}.label), falling back to the authored def label, then the API name Both services resolve lazily through structural seams (AuditI18nSurface, mirroring MessagingEmitSurface); missing i18n/settings or bundle keys degrade to the previous English summaries.
1 parent eb89a8c commit 21f75ce

6 files changed

Lines changed: 255 additions & 12 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
Localize activity summaries to the workspace default locale (#3039). Activity
6+
writers previously hardcoded English verbs and the object API name
7+
(`Created person_qualification "OC-00001"`). The writer now resolves the
8+
ADR-0053 `localization.locale` setting per write (memoized per tenant/user
9+
scope), renders the verb through new `messages.activityCreated/Updated/Deleted`
10+
i18n templates (en, zh-CN, ja-JP, es-ES shipped), and names the object by its
11+
localized label (`objects.{name}.label`) with fallback to the authored def
12+
label, then the API name. Missing i18n/settings services or bundle keys
13+
degrade to the previous English summaries.

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import { resolveLocalizationContext } from '@objectstack/core';
45
import type { IDataEngine } from '@objectstack/spec/contracts';
56
import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
67
// `sys_notification` is contributed here but owned by platform-objects; it is
@@ -9,7 +10,7 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
910
// storage (ADR-0052 §3 ownership: a file↔record link belongs with storage, not
1011
// the compliance ledger).
1112
import { SysNotification } from '@objectstack/platform-objects/audit';
12-
import { installAuditWriters, type MessagingEmitSurface } from './audit-writers.js';
13+
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';
1314

1415
/**
1516
* AuditPlugin
@@ -108,7 +109,27 @@ export class AuditPlugin implements Plugin {
108109
return undefined;
109110
}
110111
};
111-
installAuditWriters(engine as any, this.name, { getMessaging });
112+
// framework#3039 — localize activity summaries to the workspace default
113+
// locale (ADR-0053 `localization.locale`). Both seams resolve lazily and
114+
// tolerate absence: no i18n / no settings degrades to English summaries.
115+
const getI18n = (): AuditI18nSurface | undefined => {
116+
try {
117+
return ctx.getService<AuditI18nSurface>('i18n');
118+
} catch {
119+
return undefined;
120+
}
121+
};
122+
const getLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
123+
let settings: unknown;
124+
try {
125+
settings = ctx.getService('settings');
126+
} catch {
127+
settings = undefined;
128+
}
129+
const { locale } = await resolveLocalizationContext({ ql: engine, settings, tenantId, userId });
130+
return locale;
131+
};
132+
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale });
112133
process.stderr.write('[AuditPlugin] writers installed\n');
113134
ctx.logger.info('AuditPlugin: audit + activity writers installed');
114135
});

packages/plugins/plugin-audit/src/audit-writers.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,105 @@ describe('audit writers — enable.files server-side enforcement (#2727)', () =>
439439
await expect(fire('beforeInsert', attachmentInsert(undefined))).resolves.toBeUndefined();
440440
});
441441
});
442+
443+
describe('audit writers — localized activity summaries (framework#3039)', () => {
444+
// Real memory i18n (what the kernel registers as the 'i18n' fallback) loaded
445+
// with this plugin's shipped bundle plus an app-contributed object label —
446+
// exercises the actual key shapes (`messages.activityCreated`,
447+
// `objects.{name}.label`) end to end.
448+
async function makeI18n() {
449+
const { createMemoryI18n } = await import('@objectstack/core');
450+
const { AuditTranslations } = await import('./translations/index.js');
451+
const i18n = createMemoryI18n();
452+
for (const [locale, data] of Object.entries(AuditTranslations)) {
453+
i18n.loadTranslations(locale, data as Record<string, any>);
454+
}
455+
i18n.loadTranslations('zh-CN', {
456+
objects: { person_qualification: { label: '人员资质' } },
457+
});
458+
return i18n;
459+
}
460+
461+
function setup(
462+
locale: string | undefined,
463+
i18n?: { t: Function },
464+
objectDefs: Record<string, any> = {},
465+
schemas: Record<string, string[] | Record<string, any>> = SINGLE_TENANT,
466+
) {
467+
const { engine, fire, created } = makeEngine(schemas, objectDefs);
468+
let localeCalls = 0;
469+
installAuditWriters(engine as any, 'test.audit', {
470+
getI18n: () => i18n as any,
471+
getLocale: async () => {
472+
localeCalls += 1;
473+
return locale;
474+
},
475+
});
476+
return { fire, created, localeCalls: () => localeCalls };
477+
}
478+
479+
const insertCtx = (object = 'person_qualification') => ({
480+
object,
481+
input: { id: 'q-1' },
482+
result: { id: 'q-1', name: 'OC-00001' },
483+
session: { tenantId: 'org-1', userId: 'user-1' },
484+
});
485+
486+
it('localizes verb + object label to the workspace locale (zh-CN)', async () => {
487+
const { fire, created } = setup('zh-CN', await makeI18n());
488+
489+
await fire('afterInsert', insertCtx());
490+
await fire('afterDelete', { ...insertCtx(), result: null, __previous: { id: 'q-1', name: 'OC-00001' } });
491+
492+
const summaries = created.filter((c) => c.object === 'sys_activity').map((c) => c.row.summary);
493+
expect(summaries).toEqual(['创建了 人员资质 "OC-00001"', '删除了 人员资质 "OC-00001"']);
494+
});
495+
496+
it('localizes the generic update fallback', async () => {
497+
const { fire, created } = setup('zh-CN', await makeI18n());
498+
await fire('afterUpdate', {
499+
...insertCtx(),
500+
__previous: { id: 'q-1', name: 'OC-00001', status: 'draft' },
501+
result: { id: 'q-1', name: 'OC-00001', status: 'active' },
502+
});
503+
const activity = created.find((c) => c.object === 'sys_activity');
504+
expect(activity!.row.summary).toBe('更新了 人员资质 "OC-00001"');
505+
});
506+
507+
it('falls back to the object def label, then English, when a translation misses', async () => {
508+
// Locale resolves but the object has no zh-CN label entry → verb is
509+
// localized, label falls back to the authored def label.
510+
const { fire, created } = setup(
511+
'zh-CN',
512+
await makeI18n(),
513+
{ crm_lead: { label: 'Lead' } },
514+
{ ...SINGLE_TENANT, crm_lead: ['id', 'name'] },
515+
);
516+
await fire('afterInsert', { ...insertCtx('crm_lead'), result: { id: 'q-1', name: 'Acme' } });
517+
expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe('创建了 Lead "Acme"');
518+
});
519+
520+
it('keeps English summaries when no i18n service is resolvable', async () => {
521+
const { fire, created } = setup('zh-CN', undefined);
522+
await fire('afterInsert', insertCtx());
523+
expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe(
524+
'Created person_qualification "OC-00001"',
525+
);
526+
});
527+
528+
it('keeps English summaries without a locale resolver (status quo)', async () => {
529+
const { engine, fire, created } = makeEngine(SINGLE_TENANT);
530+
installAuditWriters(engine as any, 'test.audit', { getI18n: () => undefined });
531+
await fire('afterInsert', insertCtx());
532+
expect(created.find((c) => c.object === 'sys_activity')!.row.summary).toBe(
533+
'Created person_qualification "OC-00001"',
534+
);
535+
});
536+
537+
it('memoizes the locale lookup per tenant/user scope (hot-path guard)', async () => {
538+
const { fire, localeCalls } = setup('zh-CN', await makeI18n());
539+
await fire('afterInsert', insertCtx());
540+
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
541+
expect(localeCalls()).toBe(1);
542+
});
543+
});

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export interface MessagingEmitSurface {
2222
}): Promise<unknown>;
2323
}
2424

25+
/**
26+
* Minimal structural view of `II18nService.t`. Declared locally (same
27+
* rationale as {@link MessagingEmitSurface}) so plugin-audit resolves whatever
28+
* object is registered under the `i18n` service without a runtime dependency
29+
* on service-i18n. The kernel always registers at least the in-memory
30+
* fallback, and `t` returns the key verbatim on a miss.
31+
*/
32+
export interface AuditI18nSurface {
33+
t(key: string, locale: string, params?: Record<string, unknown>): string;
34+
}
35+
2536
/** Options for {@link installAuditWriters}. */
2637
export interface AuditWriterOptions {
2738
/**
@@ -32,6 +43,19 @@ export interface AuditWriterOptions {
3243
* matching the `notify` node's degradation.
3344
*/
3445
getMessaging?(): MessagingEmitSurface | undefined;
46+
/**
47+
* Lazily resolve the i18n service used to localize activity summaries
48+
* (framework#3039): verb templates (`messages.activityCreated` …) and object
49+
* display labels (`objects.{name}.label`). Absent → English summaries.
50+
*/
51+
getI18n?(): AuditI18nSurface | undefined;
52+
/**
53+
* Resolve the workspace default locale (ADR-0053 `localization.locale`) for
54+
* the write's tenant/user scope. Called per audited write but memoized here
55+
* with a short TTL, so implementations may hit the settings service
56+
* directly. Absent → summaries stay English (status quo).
57+
*/
58+
getLocale?(tenantId?: string, userId?: string): Promise<string | undefined>;
3559
}
3660

3761
/**
@@ -244,6 +268,28 @@ export function installAuditWriters(
244268
if (!engine || typeof engine.registerHook !== 'function') return;
245269

246270
const getMessaging = opts.getMessaging ?? (() => undefined);
271+
const getI18n = opts.getI18n ?? (() => undefined);
272+
273+
// Workspace locale changes rarely, but writeAudit runs on every CRUD write —
274+
// memoize the settings lookup per principal scope with a short TTL so audit
275+
// logging doesn't add a settings query to every mutation's hot path.
276+
const LOCALE_TTL_MS = 30_000;
277+
const localeCache = new Map<string, { value: string | undefined; expires: number }>();
278+
const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
279+
if (!opts.getLocale) return undefined;
280+
const cacheKey = `${tenantId ?? ''}|${userId ?? ''}`;
281+
const now = Date.now();
282+
const hit = localeCache.get(cacheKey);
283+
if (hit && hit.expires > now) return hit.value;
284+
let value: string | undefined;
285+
try {
286+
value = await opts.getLocale(tenantId, userId);
287+
} catch {
288+
value = undefined;
289+
}
290+
localeCache.set(cacheKey, { value, expires: now + LOCALE_TTL_MS });
291+
return value;
292+
};
247293

248294
// Remove any prior installation so we can safely re-install on hot reload.
249295
if (typeof engine.unregisterHooksByPackage === 'function') {
@@ -449,19 +495,39 @@ export function installAuditWriters(
449495
const label = recordLabel(after ?? before, recordId ?? '');
450496
// Summaries are user-facing (the record Discussion feed and Setup
451497
// dashboards render them verbatim), so name the object by its display
452-
// label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo").
453-
// Best-effort: falls back to the API name when the def isn't resolvable.
498+
// label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo"), and
499+
// localize both the verb template and the object label to the workspace
500+
// default locale (ADR-0053, framework#3039). Every step is best-effort:
501+
// no locale / no i18n / key miss all degrade to the English literal.
502+
const locale = await resolveWriteLocale(tenantId, userId);
503+
const translate = (key: string, params?: Record<string, unknown>): string | undefined => {
504+
if (!locale) return undefined;
505+
const i18n = getI18n();
506+
if (!i18n || typeof i18n.t !== 'function') return undefined;
507+
try {
508+
const value = i18n.t(key, locale, params);
509+
// A miss returns the key verbatim (II18nService contract).
510+
return typeof value === 'string' && value !== key ? value : undefined;
511+
} catch {
512+
return undefined;
513+
}
514+
};
454515
const objectDef = getObjectDef(ctx.object);
455516
const objectDisplay =
456-
typeof objectDef?.label === 'string' && objectDef.label.length > 0
517+
translate(`objects.${ctx.object}.label`) ??
518+
(typeof objectDef?.label === 'string' && objectDef.label.length > 0
457519
? objectDef.label
458-
: ctx.object;
520+
: ctx.object);
459521
let summary: string;
460522
let activityType: string = activityTypeFor(action);
461523
if (action === 'create') {
462-
summary = `Created ${objectDisplay} "${label}"`;
524+
summary =
525+
translate('messages.activityCreated', { object: objectDisplay, label }) ??
526+
`Created ${objectDisplay} "${label}"`;
463527
} else if (action === 'delete') {
464-
summary = `Deleted ${objectDisplay} "${label}"`;
528+
summary =
529+
translate('messages.activityDeleted', { object: objectDisplay, label }) ??
530+
`Deleted ${objectDisplay} "${label}"`;
465531
} else {
466532
// ADR-0052 §5b — declarative activity, precedence: a configured semantic
467533
// milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
@@ -473,6 +539,7 @@ export function installAuditWriters(
473539
} else {
474540
summary =
475541
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
542+
translate('messages.activityUpdated', { object: objectDisplay, label }) ??
476543
`Updated ${objectDisplay} "${label}"`;
477544
}
478545
}

packages/plugins/plugin-audit/src/translations/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import { enObjects } from './en.objects.generated.js';
1515
import { zhCNObjects } from './zh-CN.objects.generated.js';
1616
import { jaJPObjects } from './ja-JP.objects.generated.js';
1717
import { esESObjects } from './es-ES.objects.generated.js';
18+
import { enMessages, zhCNMessages, jaJPMessages, esESMessages } from './messages.js';
1819

1920
export const AuditTranslations: TranslationBundle = {
20-
en: { objects: enObjects },
21-
'zh-CN': { objects: zhCNObjects },
22-
'ja-JP': { objects: jaJPObjects },
23-
'es-ES': { objects: esESObjects },
21+
en: { objects: enObjects, messages: enMessages },
22+
'zh-CN': { objects: zhCNObjects, messages: zhCNMessages },
23+
'ja-JP': { objects: jaJPObjects, messages: jaJPMessages },
24+
'es-ES': { objects: esESObjects, messages: esESMessages },
2425
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Activity summary verb templates (framework#3039).
5+
*
6+
* Keys are single-segment on purpose: both i18n implementations (the core
7+
* memory fallback and service-i18n's FileI18nAdapter) resolve dot-notation
8+
* keys by walking NESTED objects, so a flat record key containing a dot
9+
* (`'activity.created'`) would never resolve — `messages.activityCreated`
10+
* does. Interpolation uses the shared `{{param}}` convention.
11+
*/
12+
13+
import type { TranslationData } from '@objectstack/spec/system';
14+
15+
type Messages = NonNullable<TranslationData['messages']>;
16+
17+
export const enMessages: Messages = {
18+
activityCreated: 'Created {{object}} "{{label}}"',
19+
activityUpdated: 'Updated {{object}} "{{label}}"',
20+
activityDeleted: 'Deleted {{object}} "{{label}}"',
21+
};
22+
23+
export const zhCNMessages: Messages = {
24+
activityCreated: '创建了 {{object}} "{{label}}"',
25+
activityUpdated: '更新了 {{object}} "{{label}}"',
26+
activityDeleted: '删除了 {{object}} "{{label}}"',
27+
};
28+
29+
export const jaJPMessages: Messages = {
30+
activityCreated: '{{object}}「{{label}}」を作成しました',
31+
activityUpdated: '{{object}}「{{label}}」を更新しました',
32+
activityDeleted: '{{object}}「{{label}}」を削除しました',
33+
};
34+
35+
export const esESMessages: Messages = {
36+
activityCreated: 'Creó {{object}} "{{label}}"',
37+
activityUpdated: 'Actualizó {{object}} "{{label}}"',
38+
activityDeleted: 'Eliminó {{object}} "{{label}}"',
39+
};

0 commit comments

Comments
 (0)