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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/plugin-audit-summary-i18n-3039.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 23 additions & 2 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<AuditI18nSurface>('i18n');
} catch {
return undefined;
}
};
const getLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
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');
});
Expand Down
102 changes: 102 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>);
}
i18n.loadTranslations('zh-CN', {
objects: { person_qualification: { label: '人员资质' } },
});
return i18n;
}

function setup(
locale: string | undefined,
i18n?: { t: Function },
objectDefs: Record<string, any> = {},
schemas: Record<string, string[] | Record<string, any>> = 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);
});
});
79 changes: 73 additions & 6 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export interface MessagingEmitSurface {
}): Promise<unknown>;
}

/**
* 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, unknown>): string;
}

/** Options for {@link installAuditWriters}. */
export interface AuditWriterOptions {
/**
Expand All @@ -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<string | undefined>;
}

/**
Expand Down Expand Up @@ -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<string, { value: string | undefined; expires: number }>();
const resolveWriteLocale = async (tenantId?: string, userId?: string): Promise<string | undefined> => {
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') {
Expand Down Expand Up @@ -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, unknown>): 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:
Expand All @@ -473,6 +539,7 @@ export function installAuditWriters(
} else {
summary =
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
translate('messages.activityUpdated', { object: objectDisplay, label }) ??
`Updated ${objectDisplay} "${label}"`;
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/plugins/plugin-audit/src/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
39 changes: 39 additions & 0 deletions packages/plugins/plugin-audit/src/translations/messages.ts
Original file line number Diff line number Diff line change
@@ -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<TranslationData['messages']>;

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}}"',
};