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
43 changes: 43 additions & 0 deletions .changeset/localize-collab-notifications-and-storage-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/plugin-audit": patch
"@objectstack/service-storage": patch
"@objectstack/runtime": patch
---

feat(i18n): localize collaboration notification titles and the storage objects; wire the notifications REST routes

Three gaps behind one report (a `sys_file "repro.png" assigned to you`
notification that was English on an all-Chinese workspace, opened an English
detail page, and never cleared its unread state):

- **plugin-audit** — the assignment (`collab.assignment`) and @mention
(`collab.mention`) bell titles were hardcoded English literals built from the
raw object API name. They now resolve through the i18n service with the same
key shapes as the activity summaries (framework#3039): new
`messages.assignedToYou` / `messages.mentionedYou` /
`messages.mentionedYouAnonymous` templates (en / zh-CN / ja-JP / es-ES), the
object named by its translated label (`objects.{name}.label` → authored def
label → API name), and the locale resolved for the **recipient** (they read
the bell), not the acting user. Every step stays best-effort: no locale / no
i18n / key miss degrades to the English literal — which now also prefers the
authored object label over the API name.

- **service-storage** — `sys_file` / `sys_upload_session` had no translation
bundle at all, so the file detail page (labels, and the Pending Upload /
Committed / Deleted status pipeline) rendered English on every locale. The
service now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES,
`src/translations` + `scripts/i18n-extract.config.ts`) and contributes it via
`i18n.loadTranslations` on `kernel:ready`, matching service-messaging.
(`sys_attachment` stays in platform-objects' bundles pending the
storage-domain decomposition.)

- **runtime** — the in-app notifications REST surface (`GET
/api/v1/notifications`, `POST /api/v1/notifications/read`, `POST
/api/v1/notifications/read/all`; ADR-0030) had its `handleNotification`
dispatch branch and discovery entry, but no `server.<verb>()` mount in
`dispatcher-plugin`, so only the cloud hosts' hono catch-all reached it — the
standalone / `os dev` server 404'd every request. That left mark-read with no
working endpoint (the console's direct `sys_notification_receipt` write is
rejected by ADR-0103's engine-owned gate), so unread notifications could never
clear. The three routes are now mounted explicitly, guarded by the
route-registration regression test.
74 changes: 74 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,4 +636,78 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
expect(localeCalls()).toBe(1);
});

// Collaboration notification titles (assignment / @mention) are localized to
// the RECIPIENT's locale with the same key shapes, and fall back to the
// authored object label (not the API name) in English.
function setupWithMessaging(
locale: string | undefined,
i18n: { t: Function } | undefined,
objectDefs: Record<string, any> = {},
schemas: Record<string, string[] | Record<string, any>> = SINGLE_TENANT,
) {
const { engine, fire } = makeEngine(schemas, objectDefs);
const emits: any[] = [];
installAuditWriters(engine as any, 'test.audit', {
getI18n: () => i18n as any,
getLocale: async () => locale,
getMessaging: () => ({
emit: async (e: any) => {
emits.push(e);
return {};
},
}),
});
return { fire, emits };
}

it('localizes the assignment notification title to the recipient locale', async () => {
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
await fire('afterInsert', {
...insertCtx(),
result: { id: 'q-1', name: 'OC-00001', owner_id: 'user-2' },
});
const assignment = emits.find((e) => e.topic === 'collab.assignment');
expect(assignment).toBeDefined();
expect(assignment.audience).toEqual(['user-2']);
expect(assignment.payload.title).toBe('人员资质 "OC-00001" 已分配给你');
});

it('localizes the @mention notification title to the recipient locale', async () => {
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
await fire('afterInsert', {
object: 'sys_comment',
input: {},
result: {
id: 'c-1',
thread_id: 'crm_lead:l-1',
author_id: 'user-1',
author_name: 'Alice',
body: 'hello',
mentions: '["user-2"]',
},
session: { tenantId: 'org-1', userId: 'user-1' },
});
const mention = emits.find((e) => e.topic === 'collab.mention');
expect(mention).toBeDefined();
expect(mention.audience).toEqual(['user-2']);
expect(mention.payload.title).toBe('Alice 提到了你');
});

it('falls back to an English title with the authored object label when i18n misses', async () => {
const { fire, emits } = setupWithMessaging(
undefined,
undefined,
{ crm_lead: { label: 'Lead' } },
{ ...SINGLE_TENANT, crm_lead: ['id', 'name'] },
);
await fire('afterInsert', {
object: 'crm_lead',
input: { id: 'l-1' },
result: { id: 'l-1', name: 'Acme', owner_id: 'user-2' },
session: { tenantId: 'org-1', userId: 'user-1' },
});
const assignment = emits.find((e) => e.topic === 'collab.assignment');
expect(assignment.payload.title).toBe('Lead "Acme" assigned to you');
});
});
87 changes: 67 additions & 20 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,24 @@ export function installAuditWriters(
return value;
};

// 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).
const translateWith =
(locale: string | undefined) =>
(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;
}
};

// Remove any prior installation so we can safely re-install on hot reload.
if (typeof engine.unregisterHooksByPackage === 'function') {
engine.unregisterHooksByPackage(packageId);
Expand Down Expand Up @@ -381,6 +399,20 @@ export function installAuditWriters(
return def;
};

// 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, unknown>) => string | undefined,
): string => {
const def = getObjectDef(objectName);
return (
translate(`objects.${objectName}.label`) ??
(typeof def?.label === 'string' && def.label.length > 0 ? def.label : objectName)
);
};

/**
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
* so we can compute the diff in the afterXxx hook. We attach the snapshot
Expand Down Expand Up @@ -521,24 +553,8 @@ export function installAuditWriters(
// 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 =
translate(`objects.${ctx.object}.label`) ??
(typeof objectDef?.label === 'string' && objectDef.label.length > 0
? objectDef.label
: ctx.object);
const translate = translateWith(locale);
const objectDisplay = displayLabelFor(ctx.object, translate);
let summary: string;
let activityType: string = activityTypeFor(action);
if (action === 'create') {
Expand Down Expand Up @@ -614,6 +630,16 @@ export function installAuditWriters(
after,
actorId: userId ?? null,
tenantId: tenantId ?? null,
// Localize the bell title to the RECIPIENT's locale (they read it),
// not the acting user's — same key shapes as the activity summaries.
makeTitle: async (recipientId) => {
const rTranslate = translateWith(await resolveWriteLocale(tenantId, recipientId));
const objectLabel = displayLabelFor(ctx.object, rTranslate);
return (
rTranslate('messages.assignedToYou', { object: objectLabel, label }) ??
`${objectLabel} "${label}" assigned to you`
);
},
});
} catch (err) {
// Log via engine logger if available, but never throw.
Expand Down Expand Up @@ -729,6 +755,12 @@ export function installAuditWriters(
for (const uid of userIds) {
if (uid === actorId) continue; // don't notify the mention author
try {
// Localized to the mentioned user's locale (same rationale as the
// assignment titles); miss → English literal.
const tr = translateWith(await resolveWriteLocale(tenantId ?? undefined, uid));
const title = actorName
? (tr('messages.mentionedYou', { actor: actorName }) ?? `${actorName} mentioned you`)
: (tr('messages.mentionedYouAnonymous') ?? 'You were mentioned');
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
// channel materializes the bell row + a delivered receipt.
await messaging.emit({
Expand All @@ -740,7 +772,7 @@ export function installAuditWriters(
organizationId: tenantId ?? undefined,
dedupKey: commentId ? `collab.mention:${commentId}:${uid}` : undefined,
payload: {
title: actorName ? `${actorName} mentioned you` : 'You were mentioned',
title,
body: bodyPreview,
actorName,
},
Expand Down Expand Up @@ -780,6 +812,12 @@ async function writeAssignmentNotifications(
after: any;
actorId: string | null;
tenantId: string | null;
/**
* Build the (recipient-locale-localized) notification title. Optional —
* absent or throwing, the English literal below is used, with the raw
* object API name (pre-#3039 behavior).
*/
makeTitle?: (recipientId: string) => Promise<string>;
},
): Promise<void> {
if (!messaging) return; // no pipeline installed → no assignment notifications
Expand All @@ -792,6 +830,15 @@ async function writeAssignmentNotifications(
if (params.action === 'update' && newOwner === oldOwner) return;
if (newOwner === params.actorId) return; // self-assignment is silent

let title = `${params.object} "${params.label}" assigned to you`;
if (params.makeTitle) {
try {
title = await params.makeTitle(newOwner);
} catch {
/* keep the English fallback */
}
}

try {
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
// channel materializes the bell row + a delivered receipt. organizationId
Expand All @@ -817,7 +864,7 @@ async function writeAssignmentNotifications(
? `collab.assignment:${params.object}:${params.recordId}:${newOwner}:${writeVersion}`
: undefined,
payload: {
title: `${params.object} "${params.label}" assigned to you`,
title,
},
});
} catch {
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/plugin-audit/src/translations/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,34 @@ export const enMessages: Messages = {
activityCreated: 'Created {{object}} "{{label}}"',
activityUpdated: 'Updated {{object}} "{{label}}"',
activityDeleted: 'Deleted {{object}} "{{label}}"',
assignedToYou: '{{object}} "{{label}}" assigned to you',
mentionedYou: '{{actor}} mentioned you',
mentionedYouAnonymous: 'You were mentioned',
};

export const zhCNMessages: Messages = {
activityCreated: '创建了 {{object}} "{{label}}"',
activityUpdated: '更新了 {{object}} "{{label}}"',
activityDeleted: '删除了 {{object}} "{{label}}"',
assignedToYou: '{{object}} "{{label}}" 已分配给你',
mentionedYou: '{{actor}} 提到了你',
mentionedYouAnonymous: '有人提到了你',
};

export const jaJPMessages: Messages = {
activityCreated: '{{object}}「{{label}}」を作成しました',
activityUpdated: '{{object}}「{{label}}」を更新しました',
activityDeleted: '{{object}}「{{label}}」を削除しました',
assignedToYou: '{{object}}「{{label}}」があなたに割り当てられました',
mentionedYou: '{{actor}}さんがあなたをメンションしました',
mentionedYouAnonymous: 'あなたがメンションされました',
};

export const esESMessages: Messages = {
activityCreated: 'Creó {{object}} "{{label}}"',
activityUpdated: 'Actualizó {{object}} "{{label}}"',
activityDeleted: 'Eliminó {{object}} "{{label}}"',
assignedToYou: 'Se te asignó {{object}} "{{label}}"',
mentionedYou: '{{actor}} te mencionó',
mentionedYouAnonymous: 'Te han mencionado',
};
17 changes: 17 additions & 0 deletions packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
expect(routes).toContain('POST /api/v1/packages');
});

// Regression: the in-app notifications surface (ADR-0030) — inbox list +
// receipt mark-read — had a `handleNotification` dispatch() branch and a
// discovery entry, but NO server.<verb>() registration, so every
// `/api/v1/notifications*` request 404'd on the standalone / `os dev` server
// (only the cloud hosts' hono catch-all reached it). That left mark-read with
// no working endpoint — the console's direct receipt write is rejected by
// ADR-0103's engine-owned gate — so unread notifications could never clear.
it('mounts /notifications (GET list, POST read, POST read/all) so mark-read reaches dispatch()', async () => {
const { server, routes } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(makeCtx(server));

expect(routes).toContain('GET /api/v1/notifications');
expect(routes).toContain('POST /api/v1/notifications/read');
expect(routes).toContain('POST /api/v1/notifications/read/all');
});

it('also mounts a known existing route (sanity that start() ran)', async () => {
const { server, routes } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
Expand Down
37 changes: 37 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,43 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
}
});

// ── In-app notifications (ADR-0030) ─────────────────────────
// The inbox list + receipt mark-read surface backed by the
// messaging service. `handleNotification` + its discovery entry
// already existed, but only the cloud hosts' @objectstack/hono
// catch-all reached it — the standalone / `os dev` server mounts
// ONLY the explicit routes here, so every `/api/v1/notifications*`
// request 404'd (mark-read had no working endpoint: the console's
// direct receipt write is rejected by ADR-0103's engine-owned
// gate, and this REST fallback was unreachable). Mount them
// explicitly so read-state actually persists in every deployment.
server.get(`${prefix}/notifications`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('GET', '/notifications', undefined, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server.post(`${prefix}/notifications/read`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', '/notifications/read', req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server.post(`${prefix}/notifications/read/all`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', '/notifications/read/all', req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

// ── Packages ────────────────────────────────────────────────
// Single pipeline (ADR-0076 D11 / OQ#9): every package route flows
// through dispatch() — like analytics / i18n / automation / AI —
Expand Down
Loading
Loading