Skip to content

Commit ee0a499

Browse files
baozhoutaoclaude
andauthored
fix(i18n/notifications): localize collab notification titles + storage objects; wire the notifications REST routes (#3354)
* feat(i18n): localize collab notification titles; add service-storage translation bundle Fixes three symptoms observed on a zh-CN workspace: - the bell title 'sys_file "repro.png" assigned to you' was a hardcoded English literal built from the raw object API name — assignment and @mention titles now resolve through i18n (messages.assignedToYou / mentionedYou / mentionedYouAnonymous) in the RECIPIENT's locale, with the object named by its translated label (objects.{name}.label → def label → API name); every step degrades best-effort to English. - sys_file / sys_upload_session had no translation bundle, so the file detail page (labels + Pending Upload / Committed / Deleted status pipeline) rendered English on every locale — service-storage now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES) loaded on kernel:ready, mirroring service-messaging. (The third symptom — unread state never clearing — is an objectui fix: the console wrote sys_notification_receipt via the generic data API, which ADR-0103 made read-only; it must use POST /api/v1/notifications/read.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(runtime): mount the in-app notifications REST routes (ADR-0030) handleNotification (GET /notifications, POST /notifications/read[/all]) and its discovery entry existed, but dispatcher-plugin never registered server.<verb>() mounts for them — so only the cloud hosts' hono catch-all reached the handler; the standalone / `os dev` server 404'd every /api/v1/notifications* request. That was the real reason unread notifications never cleared: the console's direct sys_notification_receipt write is rejected by ADR-0103's engine-owned gate, and this REST fallback — the intended path — was unreachable. Mounting the three routes makes mark-read persist in every deployment. Guarded by the dispatcher-plugin route-registration regression test. Verified end-to-end in the showcase example: POST /notifications/read/all → 200, receipts flip delivered→read server-side, and the bell badge stays cleared across the inbox poll (previously it flipped every row back to unread). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 23eebfa commit ee0a499

13 files changed

Lines changed: 951 additions & 20 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
"@objectstack/service-storage": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
feat(i18n): localize collaboration notification titles and the storage objects; wire the notifications REST routes
8+
9+
Three gaps behind one report (a `sys_file "repro.png" assigned to you`
10+
notification that was English on an all-Chinese workspace, opened an English
11+
detail page, and never cleared its unread state):
12+
13+
- **plugin-audit** — the assignment (`collab.assignment`) and @mention
14+
(`collab.mention`) bell titles were hardcoded English literals built from the
15+
raw object API name. They now resolve through the i18n service with the same
16+
key shapes as the activity summaries (framework#3039): new
17+
`messages.assignedToYou` / `messages.mentionedYou` /
18+
`messages.mentionedYouAnonymous` templates (en / zh-CN / ja-JP / es-ES), the
19+
object named by its translated label (`objects.{name}.label` → authored def
20+
label → API name), and the locale resolved for the **recipient** (they read
21+
the bell), not the acting user. Every step stays best-effort: no locale / no
22+
i18n / key miss degrades to the English literal — which now also prefers the
23+
authored object label over the API name.
24+
25+
- **service-storage**`sys_file` / `sys_upload_session` had no translation
26+
bundle at all, so the file detail page (labels, and the Pending Upload /
27+
Committed / Deleted status pipeline) rendered English on every locale. The
28+
service now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES,
29+
`src/translations` + `scripts/i18n-extract.config.ts`) and contributes it via
30+
`i18n.loadTranslations` on `kernel:ready`, matching service-messaging.
31+
(`sys_attachment` stays in platform-objects' bundles pending the
32+
storage-domain decomposition.)
33+
34+
- **runtime** — the in-app notifications REST surface (`GET
35+
/api/v1/notifications`, `POST /api/v1/notifications/read`, `POST
36+
/api/v1/notifications/read/all`; ADR-0030) had its `handleNotification`
37+
dispatch branch and discovery entry, but no `server.<verb>()` mount in
38+
`dispatcher-plugin`, so only the cloud hosts' hono catch-all reached it — the
39+
standalone / `os dev` server 404'd every request. That left mark-read with no
40+
working endpoint (the console's direct `sys_notification_receipt` write is
41+
rejected by ADR-0103's engine-owned gate), so unread notifications could never
42+
clear. The three routes are now mounted explicitly, guarded by the
43+
route-registration regression test.

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,4 +636,78 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti
636636
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
637637
expect(localeCalls()).toBe(1);
638638
});
639+
640+
// Collaboration notification titles (assignment / @mention) are localized to
641+
// the RECIPIENT's locale with the same key shapes, and fall back to the
642+
// authored object label (not the API name) in English.
643+
function setupWithMessaging(
644+
locale: string | undefined,
645+
i18n: { t: Function } | undefined,
646+
objectDefs: Record<string, any> = {},
647+
schemas: Record<string, string[] | Record<string, any>> = SINGLE_TENANT,
648+
) {
649+
const { engine, fire } = makeEngine(schemas, objectDefs);
650+
const emits: any[] = [];
651+
installAuditWriters(engine as any, 'test.audit', {
652+
getI18n: () => i18n as any,
653+
getLocale: async () => locale,
654+
getMessaging: () => ({
655+
emit: async (e: any) => {
656+
emits.push(e);
657+
return {};
658+
},
659+
}),
660+
});
661+
return { fire, emits };
662+
}
663+
664+
it('localizes the assignment notification title to the recipient locale', async () => {
665+
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
666+
await fire('afterInsert', {
667+
...insertCtx(),
668+
result: { id: 'q-1', name: 'OC-00001', owner_id: 'user-2' },
669+
});
670+
const assignment = emits.find((e) => e.topic === 'collab.assignment');
671+
expect(assignment).toBeDefined();
672+
expect(assignment.audience).toEqual(['user-2']);
673+
expect(assignment.payload.title).toBe('人员资质 "OC-00001" 已分配给你');
674+
});
675+
676+
it('localizes the @mention notification title to the recipient locale', async () => {
677+
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
678+
await fire('afterInsert', {
679+
object: 'sys_comment',
680+
input: {},
681+
result: {
682+
id: 'c-1',
683+
thread_id: 'crm_lead:l-1',
684+
author_id: 'user-1',
685+
author_name: 'Alice',
686+
body: 'hello',
687+
mentions: '["user-2"]',
688+
},
689+
session: { tenantId: 'org-1', userId: 'user-1' },
690+
});
691+
const mention = emits.find((e) => e.topic === 'collab.mention');
692+
expect(mention).toBeDefined();
693+
expect(mention.audience).toEqual(['user-2']);
694+
expect(mention.payload.title).toBe('Alice 提到了你');
695+
});
696+
697+
it('falls back to an English title with the authored object label when i18n misses', async () => {
698+
const { fire, emits } = setupWithMessaging(
699+
undefined,
700+
undefined,
701+
{ crm_lead: { label: 'Lead' } },
702+
{ ...SINGLE_TENANT, crm_lead: ['id', 'name'] },
703+
);
704+
await fire('afterInsert', {
705+
object: 'crm_lead',
706+
input: { id: 'l-1' },
707+
result: { id: 'l-1', name: 'Acme', owner_id: 'user-2' },
708+
session: { tenantId: 'org-1', userId: 'user-1' },
709+
});
710+
const assignment = emits.find((e) => e.topic === 'collab.assignment');
711+
expect(assignment.payload.title).toBe('Lead "Acme" assigned to you');
712+
});
639713
});

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

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,24 @@ export function installAuditWriters(
312312
return value;
313313
};
314314

315+
// Translate function bound to a locale. Returns undefined on any miss
316+
// (no locale / no i18n / key miss) so callers keep their English fallback
317+
// (framework#3039).
318+
const translateWith =
319+
(locale: string | undefined) =>
320+
(key: string, params?: Record<string, unknown>): string | undefined => {
321+
if (!locale) return undefined;
322+
const i18n = getI18n();
323+
if (!i18n || typeof i18n.t !== 'function') return undefined;
324+
try {
325+
const value = i18n.t(key, locale, params);
326+
// A miss returns the key verbatim (II18nService contract).
327+
return typeof value === 'string' && value !== key ? value : undefined;
328+
} catch {
329+
return undefined;
330+
}
331+
};
332+
315333
// Remove any prior installation so we can safely re-install on hot reload.
316334
if (typeof engine.unregisterHooksByPackage === 'function') {
317335
engine.unregisterHooksByPackage(packageId);
@@ -381,6 +399,20 @@ export function installAuditWriters(
381399
return def;
382400
};
383401

402+
// Display label for an object under a given translate fn: translated label
403+
// → authored def label → API name. Shared by activity summaries and the
404+
// collaboration notification titles below.
405+
const displayLabelFor = (
406+
objectName: string,
407+
translate: (key: string, params?: Record<string, unknown>) => string | undefined,
408+
): string => {
409+
const def = getObjectDef(objectName);
410+
return (
411+
translate(`objects.${objectName}.label`) ??
412+
(typeof def?.label === 'string' && def.label.length > 0 ? def.label : objectName)
413+
);
414+
};
415+
384416
/**
385417
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
386418
* so we can compute the diff in the afterXxx hook. We attach the snapshot
@@ -521,24 +553,8 @@ export function installAuditWriters(
521553
// default locale (ADR-0053, framework#3039). Every step is best-effort:
522554
// no locale / no i18n / key miss all degrade to the English literal.
523555
const locale = await resolveWriteLocale(tenantId, userId);
524-
const translate = (key: string, params?: Record<string, unknown>): string | undefined => {
525-
if (!locale) return undefined;
526-
const i18n = getI18n();
527-
if (!i18n || typeof i18n.t !== 'function') return undefined;
528-
try {
529-
const value = i18n.t(key, locale, params);
530-
// A miss returns the key verbatim (II18nService contract).
531-
return typeof value === 'string' && value !== key ? value : undefined;
532-
} catch {
533-
return undefined;
534-
}
535-
};
536-
const objectDef = getObjectDef(ctx.object);
537-
const objectDisplay =
538-
translate(`objects.${ctx.object}.label`) ??
539-
(typeof objectDef?.label === 'string' && objectDef.label.length > 0
540-
? objectDef.label
541-
: ctx.object);
556+
const translate = translateWith(locale);
557+
const objectDisplay = displayLabelFor(ctx.object, translate);
542558
let summary: string;
543559
let activityType: string = activityTypeFor(action);
544560
if (action === 'create') {
@@ -614,6 +630,16 @@ export function installAuditWriters(
614630
after,
615631
actorId: userId ?? null,
616632
tenantId: tenantId ?? null,
633+
// Localize the bell title to the RECIPIENT's locale (they read it),
634+
// not the acting user's — same key shapes as the activity summaries.
635+
makeTitle: async (recipientId) => {
636+
const rTranslate = translateWith(await resolveWriteLocale(tenantId, recipientId));
637+
const objectLabel = displayLabelFor(ctx.object, rTranslate);
638+
return (
639+
rTranslate('messages.assignedToYou', { object: objectLabel, label }) ??
640+
`${objectLabel} "${label}" assigned to you`
641+
);
642+
},
617643
});
618644
} catch (err) {
619645
// Log via engine logger if available, but never throw.
@@ -729,6 +755,12 @@ export function installAuditWriters(
729755
for (const uid of userIds) {
730756
if (uid === actorId) continue; // don't notify the mention author
731757
try {
758+
// Localized to the mentioned user's locale (same rationale as the
759+
// assignment titles); miss → English literal.
760+
const tr = translateWith(await resolveWriteLocale(tenantId ?? undefined, uid));
761+
const title = actorName
762+
? (tr('messages.mentionedYou', { actor: actorName }) ?? `${actorName} mentioned you`)
763+
: (tr('messages.mentionedYouAnonymous') ?? 'You were mentioned');
732764
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
733765
// channel materializes the bell row + a delivered receipt.
734766
await messaging.emit({
@@ -740,7 +772,7 @@ export function installAuditWriters(
740772
organizationId: tenantId ?? undefined,
741773
dedupKey: commentId ? `collab.mention:${commentId}:${uid}` : undefined,
742774
payload: {
743-
title: actorName ? `${actorName} mentioned you` : 'You were mentioned',
775+
title,
744776
body: bodyPreview,
745777
actorName,
746778
},
@@ -780,6 +812,12 @@ async function writeAssignmentNotifications(
780812
after: any;
781813
actorId: string | null;
782814
tenantId: string | null;
815+
/**
816+
* Build the (recipient-locale-localized) notification title. Optional —
817+
* absent or throwing, the English literal below is used, with the raw
818+
* object API name (pre-#3039 behavior).
819+
*/
820+
makeTitle?: (recipientId: string) => Promise<string>;
783821
},
784822
): Promise<void> {
785823
if (!messaging) return; // no pipeline installed → no assignment notifications
@@ -792,6 +830,15 @@ async function writeAssignmentNotifications(
792830
if (params.action === 'update' && newOwner === oldOwner) return;
793831
if (newOwner === params.actorId) return; // self-assignment is silent
794832

833+
let title = `${params.object} "${params.label}" assigned to you`;
834+
if (params.makeTitle) {
835+
try {
836+
title = await params.makeTitle(newOwner);
837+
} catch {
838+
/* keep the English fallback */
839+
}
840+
}
841+
795842
try {
796843
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
797844
// channel materializes the bell row + a delivered receipt. organizationId
@@ -817,7 +864,7 @@ async function writeAssignmentNotifications(
817864
? `collab.assignment:${params.object}:${params.recordId}:${newOwner}:${writeVersion}`
818865
: undefined,
819866
payload: {
820-
title: `${params.object} "${params.label}" assigned to you`,
867+
title,
821868
},
822869
});
823870
} catch {

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,34 @@ export const enMessages: Messages = {
1818
activityCreated: 'Created {{object}} "{{label}}"',
1919
activityUpdated: 'Updated {{object}} "{{label}}"',
2020
activityDeleted: 'Deleted {{object}} "{{label}}"',
21+
assignedToYou: '{{object}} "{{label}}" assigned to you',
22+
mentionedYou: '{{actor}} mentioned you',
23+
mentionedYouAnonymous: 'You were mentioned',
2124
};
2225

2326
export const zhCNMessages: Messages = {
2427
activityCreated: '创建了 {{object}} "{{label}}"',
2528
activityUpdated: '更新了 {{object}} "{{label}}"',
2629
activityDeleted: '删除了 {{object}} "{{label}}"',
30+
assignedToYou: '{{object}} "{{label}}" 已分配给你',
31+
mentionedYou: '{{actor}} 提到了你',
32+
mentionedYouAnonymous: '有人提到了你',
2733
};
2834

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

3544
export const esESMessages: Messages = {
3645
activityCreated: 'Creó {{object}} "{{label}}"',
3746
activityUpdated: 'Actualizó {{object}} "{{label}}"',
3847
activityDeleted: 'Eliminó {{object}} "{{label}}"',
48+
assignedToYou: 'Se te asignó {{object}} "{{label}}"',
49+
mentionedYou: '{{actor}} te mencionó',
50+
mentionedYouAnonymous: 'Te han mencionado',
3951
};

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
8989
expect(routes).toContain('POST /api/v1/packages');
9090
});
9191

92+
// Regression: the in-app notifications surface (ADR-0030) — inbox list +
93+
// receipt mark-read — had a `handleNotification` dispatch() branch and a
94+
// discovery entry, but NO server.<verb>() registration, so every
95+
// `/api/v1/notifications*` request 404'd on the standalone / `os dev` server
96+
// (only the cloud hosts' hono catch-all reached it). That left mark-read with
97+
// no working endpoint — the console's direct receipt write is rejected by
98+
// ADR-0103's engine-owned gate — so unread notifications could never clear.
99+
it('mounts /notifications (GET list, POST read, POST read/all) so mark-read reaches dispatch()', async () => {
100+
const { server, routes } = makeFakeServer();
101+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
102+
await plugin.start?.(makeCtx(server));
103+
104+
expect(routes).toContain('GET /api/v1/notifications');
105+
expect(routes).toContain('POST /api/v1/notifications/read');
106+
expect(routes).toContain('POST /api/v1/notifications/read/all');
107+
});
108+
92109
it('also mounts a known existing route (sanity that start() ran)', async () => {
93110
const { server, routes } = makeFakeServer();
94111
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,43 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
691691
}
692692
});
693693

694+
// ── In-app notifications (ADR-0030) ─────────────────────────
695+
// The inbox list + receipt mark-read surface backed by the
696+
// messaging service. `handleNotification` + its discovery entry
697+
// already existed, but only the cloud hosts' @objectstack/hono
698+
// catch-all reached it — the standalone / `os dev` server mounts
699+
// ONLY the explicit routes here, so every `/api/v1/notifications*`
700+
// request 404'd (mark-read had no working endpoint: the console's
701+
// direct receipt write is rejected by ADR-0103's engine-owned
702+
// gate, and this REST fallback was unreachable). Mount them
703+
// explicitly so read-state actually persists in every deployment.
704+
server.get(`${prefix}/notifications`, async (req: any, res: any) => {
705+
try {
706+
const result = await dispatcher.dispatch('GET', '/notifications', undefined, req.query, { request: req });
707+
sendResult(result, res);
708+
} catch (err: any) {
709+
errorResponse(err, res);
710+
}
711+
});
712+
713+
server.post(`${prefix}/notifications/read`, async (req: any, res: any) => {
714+
try {
715+
const result = await dispatcher.dispatch('POST', '/notifications/read', req.body, req.query, { request: req });
716+
sendResult(result, res);
717+
} catch (err: any) {
718+
errorResponse(err, res);
719+
}
720+
});
721+
722+
server.post(`${prefix}/notifications/read/all`, async (req: any, res: any) => {
723+
try {
724+
const result = await dispatcher.dispatch('POST', '/notifications/read/all', req.body, req.query, { request: req });
725+
sendResult(result, res);
726+
} catch (err: any) {
727+
errorResponse(err, res);
728+
}
729+
});
730+
694731
// ── Packages ────────────────────────────────────────────────
695732
// Single pipeline (ADR-0076 D11 / OQ#9): every package route flows
696733
// through dispatch() — like analytics / i18n / automation / AI —

0 commit comments

Comments
 (0)