From 54b71befd5d44b17e05d3afdc6d0f4d1728f1b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Mon, 20 Jul 2026 02:51:13 -0700 Subject: [PATCH 1/2] feat(i18n): localize collab notification titles; add service-storage translation bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ollab-notifications-and-storage-objects.md | 30 ++++ .../plugin-audit/src/audit-writers.test.ts | 74 +++++++++ .../plugins/plugin-audit/src/audit-writers.ts | 87 +++++++--- .../plugin-audit/src/translations/messages.ts | 12 ++ .../scripts/i18n-extract.config.ts | 34 ++++ .../src/storage-service-plugin.ts | 17 ++ .../src/translations/en.objects.generated.ts | 156 ++++++++++++++++++ .../translations/es-ES.objects.generated.ts | 156 ++++++++++++++++++ .../service-storage/src/translations/index.ts | 26 +++ .../translations/ja-JP.objects.generated.ts | 156 ++++++++++++++++++ .../translations/zh-CN.objects.generated.ts | 156 ++++++++++++++++++ 11 files changed, 884 insertions(+), 20 deletions(-) create mode 100644 .changeset/localize-collab-notifications-and-storage-objects.md create mode 100644 packages/services/service-storage/scripts/i18n-extract.config.ts create mode 100644 packages/services/service-storage/src/translations/en.objects.generated.ts create mode 100644 packages/services/service-storage/src/translations/es-ES.objects.generated.ts create mode 100644 packages/services/service-storage/src/translations/index.ts create mode 100644 packages/services/service-storage/src/translations/ja-JP.objects.generated.ts create mode 100644 packages/services/service-storage/src/translations/zh-CN.objects.generated.ts diff --git a/.changeset/localize-collab-notifications-and-storage-objects.md b/.changeset/localize-collab-notifications-and-storage-objects.md new file mode 100644 index 0000000000..8182096bfe --- /dev/null +++ b/.changeset/localize-collab-notifications-and-storage-objects.md @@ -0,0 +1,30 @@ +--- +"@objectstack/plugin-audit": patch +"@objectstack/service-storage": patch +--- + +feat(i18n): localize collaboration notification titles and the storage objects + +Two gaps left the notification surface English-only on localized workspaces +(observed as `sys_file "repro.png" assigned to you` over an all-Chinese UI): + +- **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.) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index c7d5d40550..dca786734e 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -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 = {}, + schemas: Record> = 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'); + }); }); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index ca23ebdab1..c1d411740f 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -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 | 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); @@ -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 | 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 @@ -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 | 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') { @@ -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. @@ -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({ @@ -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, }, @@ -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; }, ): Promise { if (!messaging) return; // no pipeline installed → no assignment notifications @@ -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 @@ -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 { diff --git a/packages/plugins/plugin-audit/src/translations/messages.ts b/packages/plugins/plugin-audit/src/translations/messages.ts index 717355a878..ad6edc7d4c 100644 --- a/packages/plugins/plugin-audit/src/translations/messages.ts +++ b/packages/plugins/plugin-audit/src/translations/messages.ts @@ -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', }; diff --git a/packages/services/service-storage/scripts/i18n-extract.config.ts b/packages/services/service-storage/scripts/i18n-extract.config.ts new file mode 100644 index 0000000000..68b2a13c4b --- /dev/null +++ b/packages/services/service-storage/scripts/i18n-extract.config.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed. + * The service owns the i18n extraction for the objects it owns; the + * `translations` baseline is this service's OWN generated bundles so re-running + * `--merge` preserves every hand-translated string. + * + * `sys_attachment` is contributed by this plugin but still DEFINED in + * platform-objects — its translations stay there pending the storage-domain + * decomposition (see that package's extract config). + * + * os i18n extract packages/services/service-storage/scripts/i18n-extract.config.ts \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --out=packages/services/service-storage/src/translations + */ + +import { defineStack } from '@objectstack/spec'; +import { SystemFile, SystemUploadSession } from '../src/objects/index.js'; +import { enObjects } from '../src/translations/en.objects.generated.js'; +import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; + +export default defineStack({ + name: 'service-storage-i18n-extract', + objects: [SystemFile, SystemUploadSession] as any, + translations: [ + { en: { objects: enObjects } }, + { 'zh-CN': { objects: zhCNObjects } }, + { 'ja-JP': { objects: jaJPObjects } }, + { 'es-ES': { objects: esESObjects } }, + ], +}); diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index c2bc80b5cc..b10a14aaae 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -184,6 +184,23 @@ export class StorageServicePlugin implements Plugin { } catch { // manifest service may not be available in all environments } + + // ADR-0029 D8 — contribute this service's object translations (sys_file / + // sys_upload_session) to the i18n service on kernel:ready (the i18n plugin + // may register after this one). + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', async () => { + try { + const i18n = ctx.getService('i18n'); + if (i18n && typeof i18n.loadTranslations === 'function') { + const { StorageTranslations } = await import('./translations/index.js'); + for (const [locale, data] of Object.entries(StorageTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + } + } catch { /* i18n optional */ } + }); + } } async start(ctx: PluginContext): Promise { diff --git a/packages/services/service-storage/src/translations/en.objects.generated.ts b/packages/services/service-storage/src/translations/en.objects.generated.ts new file mode 100644 index 0000000000..7a6a7074d4 --- /dev/null +++ b/packages/services/service-storage/src/translations/en.objects.generated.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'en'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const enObjects: NonNullable = { + sys_file: { + label: "System File", + pluralLabel: "System Files", + description: "Storage service file metadata (fileId ↔ key mapping)", + fields: { + id: { + label: "File ID" + }, + key: { + label: "Storage Key" + }, + name: { + label: "File Name" + }, + mime_type: { + label: "MIME Type" + }, + size: { + label: "Size (bytes)" + }, + scope: { + label: "Scope", + options: { + user: "User", + tenant: "Tenant", + public: "Public", + private: "Private", + temp: "Temp", + attachments: "Attachments" + } + }, + bucket: { + label: "Bucket" + }, + acl: { + label: "ACL", + options: { + private: "Private", + public_read: "Public Read" + } + }, + status: { + label: "Status", + options: { + pending: "Pending Upload", + committed: "Committed", + deleted: "Deleted" + } + }, + etag: { + label: "ETag" + }, + owner_id: { + label: "Owner ID" + }, + metadata: { + label: "Metadata (JSON)" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + }, + deleted_at: { + label: "Deleted At", + help: "Tombstone timestamp — set when the last sys_attachment reference to an attachments-scope file is removed; the lifecycle TTL reaps the row (and its storage bytes, via the sys_file reap guard) after the grace window. NULL for live rows." + } + } + }, + sys_upload_session: { + label: "System Upload Session", + pluralLabel: "System Upload Sessions", + description: "Resumable multipart upload sessions tracked by service-storage", + fields: { + id: { + label: "Upload Session ID" + }, + file_id: { + label: "File ID" + }, + key: { + label: "Storage Key" + }, + filename: { + label: "Filename" + }, + mime_type: { + label: "MIME Type" + }, + total_size: { + label: "Total Size (bytes)" + }, + chunk_size: { + label: "Chunk Size (bytes)" + }, + total_chunks: { + label: "Total Chunks" + }, + uploaded_chunks: { + label: "Uploaded Chunks" + }, + uploaded_size: { + label: "Uploaded Size (bytes)" + }, + parts: { + label: "Uploaded Parts (JSON)" + }, + resume_token: { + label: "Resume Token" + }, + backend_upload_id: { + label: "Backend Upload ID" + }, + scope: { + label: "Scope" + }, + bucket: { + label: "Bucket" + }, + metadata: { + label: "Metadata (JSON)" + }, + status: { + label: "Status", + options: { + in_progress: "In Progress", + completing: "Completing", + completed: "Completed", + failed: "Failed", + expired: "Expired" + } + }, + started_at: { + label: "Started At" + }, + expires_at: { + label: "Expires At" + }, + updated_at: { + label: "Updated At" + } + } + } +}; diff --git a/packages/services/service-storage/src/translations/es-ES.objects.generated.ts b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts new file mode 100644 index 0000000000..cd43509e4e --- /dev/null +++ b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'es-ES'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const esESObjects: NonNullable = { + sys_file: { + label: "Archivo del sistema", + pluralLabel: "Archivos del sistema", + description: "Metadatos de archivos del servicio de almacenamiento (correspondencia fileId ↔ key)", + fields: { + id: { + label: "ID de archivo" + }, + key: { + label: "Clave de almacenamiento" + }, + name: { + label: "Nombre de archivo" + }, + mime_type: { + label: "Tipo MIME" + }, + size: { + label: "Tamaño (bytes)" + }, + scope: { + label: "Ámbito", + options: { + user: "Usuario", + tenant: "Inquilino", + public: "Público", + private: "Privado", + temp: "Temporal", + attachments: "Adjuntos" + } + }, + bucket: { + label: "Bucket" + }, + acl: { + label: "ACL", + options: { + private: "Privado", + public_read: "Lectura pública" + } + }, + status: { + label: "Estado", + options: { + pending: "Pendiente de subida", + committed: "Confirmado", + deleted: "Eliminado" + } + }, + etag: { + label: "ETag" + }, + owner_id: { + label: "ID de propietario" + }, + metadata: { + label: "Metadatos (JSON)" + }, + created_at: { + label: "Fecha de creación" + }, + updated_at: { + label: "Fecha de actualización" + }, + deleted_at: { + label: "Fecha de eliminación", + help: "Marca de tiempo de lápida — se establece cuando se elimina la última referencia sys_attachment a un archivo con ámbito de adjuntos; el TTL del ciclo de vida recolecta la fila (y sus bytes de almacenamiento, mediante la guarda de recolección de sys_file) tras el período de gracia. NULL para filas activas." + } + } + }, + sys_upload_session: { + label: "Sesión de subida del sistema", + pluralLabel: "Sesiones de subida del sistema", + description: "Sesiones de subida multiparte reanudables gestionadas por service-storage", + fields: { + id: { + label: "ID de sesión de subida" + }, + file_id: { + label: "ID de archivo" + }, + key: { + label: "Clave de almacenamiento" + }, + filename: { + label: "Nombre de archivo" + }, + mime_type: { + label: "Tipo MIME" + }, + total_size: { + label: "Tamaño total (bytes)" + }, + chunk_size: { + label: "Tamaño de fragmento (bytes)" + }, + total_chunks: { + label: "Total de fragmentos" + }, + uploaded_chunks: { + label: "Fragmentos subidos" + }, + uploaded_size: { + label: "Tamaño subido (bytes)" + }, + parts: { + label: "Partes subidas (JSON)" + }, + resume_token: { + label: "Token de reanudación" + }, + backend_upload_id: { + label: "ID de subida del backend" + }, + scope: { + label: "Ámbito" + }, + bucket: { + label: "Bucket" + }, + metadata: { + label: "Metadatos (JSON)" + }, + status: { + label: "Estado", + options: { + in_progress: "En curso", + completing: "Finalizando", + completed: "Completada", + failed: "Fallida", + expired: "Expirada" + } + }, + started_at: { + label: "Fecha de inicio" + }, + expires_at: { + label: "Fecha de expiración" + }, + updated_at: { + label: "Fecha de actualización" + } + } + } +}; diff --git a/packages/services/service-storage/src/translations/index.ts b/packages/services/service-storage/src/translations/index.ts new file mode 100644 index 0000000000..cc225b825e --- /dev/null +++ b/packages/services/service-storage/src/translations/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * StorageTranslations — i18n bundle owned by this service (ADR-0029 D8). + * + * Object label/field/view/action translations for the sys_* objects this + * service owns (sys_file / sys_upload_session). `sys_attachment` is + * contributed by this plugin but still DEFINED in platform-objects, so its + * translations stay in that package's bundles pending the storage-domain + * decomposition. Loaded at runtime via the plugin's `kernel:ready` hook + * (`i18n.loadTranslations`). Regenerate with `os i18n extract` against + * `scripts/i18n-extract.config.ts`. + */ + +import type { TranslationBundle } from '@objectstack/spec/system'; +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'; + +export const StorageTranslations: TranslationBundle = { + en: { objects: enObjects }, + 'zh-CN': { objects: zhCNObjects }, + 'ja-JP': { objects: jaJPObjects }, + 'es-ES': { objects: esESObjects }, +}; diff --git a/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts new file mode 100644 index 0000000000..9cf2fc10a3 --- /dev/null +++ b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'ja-JP'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const jaJPObjects: NonNullable = { + sys_file: { + label: "システムファイル", + pluralLabel: "システムファイル", + description: "ストレージサービスのファイルメタデータ(fileId ↔ key マッピング)", + fields: { + id: { + label: "ファイル ID" + }, + key: { + label: "ストレージキー" + }, + name: { + label: "ファイル名" + }, + mime_type: { + label: "MIME タイプ" + }, + size: { + label: "サイズ(バイト)" + }, + scope: { + label: "スコープ", + options: { + user: "ユーザー", + tenant: "テナント", + public: "公開", + private: "非公開", + temp: "一時", + attachments: "添付ファイル" + } + }, + bucket: { + label: "バケット" + }, + acl: { + label: "ACL", + options: { + private: "非公開", + public_read: "公開読み取り" + } + }, + status: { + label: "ステータス", + options: { + pending: "アップロード待ち", + committed: "コミット済み", + deleted: "削除済み" + } + }, + etag: { + label: "ETag" + }, + owner_id: { + label: "所有者 ID" + }, + metadata: { + label: "メタデータ(JSON)" + }, + created_at: { + label: "作成日時" + }, + updated_at: { + label: "更新日時" + }, + deleted_at: { + label: "削除日時", + help: "トゥームストーンのタイムスタンプ——添付スコープのファイルへの最後の sys_attachment 参照が削除されたときに設定され、猶予期間の後にライフサイクル TTL が行(および sys_file リープガード経由でストレージバイト)を回収します。存続中の行は NULL。" + } + } + }, + sys_upload_session: { + label: "システムアップロードセッション", + pluralLabel: "システムアップロードセッション", + description: "service-storage が追跡する再開可能なマルチパートアップロードセッション", + fields: { + id: { + label: "アップロードセッション ID" + }, + file_id: { + label: "ファイル ID" + }, + key: { + label: "ストレージキー" + }, + filename: { + label: "ファイル名" + }, + mime_type: { + label: "MIME タイプ" + }, + total_size: { + label: "合計サイズ(バイト)" + }, + chunk_size: { + label: "チャンクサイズ(バイト)" + }, + total_chunks: { + label: "チャンク総数" + }, + uploaded_chunks: { + label: "アップロード済みチャンク数" + }, + uploaded_size: { + label: "アップロード済みサイズ(バイト)" + }, + parts: { + label: "アップロード済みパート(JSON)" + }, + resume_token: { + label: "再開トークン" + }, + backend_upload_id: { + label: "バックエンドアップロード ID" + }, + scope: { + label: "スコープ" + }, + bucket: { + label: "バケット" + }, + metadata: { + label: "メタデータ(JSON)" + }, + status: { + label: "ステータス", + options: { + in_progress: "進行中", + completing: "完了処理中", + completed: "完了", + failed: "失敗", + expired: "期限切れ" + } + }, + started_at: { + label: "開始日時" + }, + expires_at: { + label: "有効期限" + }, + updated_at: { + label: "更新日時" + } + } + } +}; diff --git a/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts new file mode 100644 index 0000000000..6cd26dfd31 --- /dev/null +++ b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'zh-CN'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const zhCNObjects: NonNullable = { + sys_file: { + label: "系统文件", + pluralLabel: "系统文件", + description: "存储服务的文件元数据(fileId ↔ key 映射)", + fields: { + id: { + label: "文件 ID" + }, + key: { + label: "存储键" + }, + name: { + label: "文件名" + }, + mime_type: { + label: "MIME 类型" + }, + size: { + label: "大小(字节)" + }, + scope: { + label: "作用域", + options: { + user: "用户", + tenant: "租户", + public: "公开", + private: "私有", + temp: "临时", + attachments: "附件" + } + }, + bucket: { + label: "存储桶" + }, + acl: { + label: "访问控制", + options: { + private: "私有", + public_read: "公开可读" + } + }, + status: { + label: "状态", + options: { + pending: "待上传", + committed: "已提交", + deleted: "已删除" + } + }, + etag: { + label: "ETag" + }, + owner_id: { + label: "所有者 ID" + }, + metadata: { + label: "元数据(JSON)" + }, + created_at: { + label: "创建时间" + }, + updated_at: { + label: "更新时间" + }, + deleted_at: { + label: "删除时间", + help: "墓碑时间戳——当附件作用域文件的最后一个 sys_attachment 引用被移除时设置;宽限期过后生命周期 TTL 回收该行(并经 sys_file 回收守卫回收其存储字节)。存活行为 NULL。" + } + } + }, + sys_upload_session: { + label: "系统上传会话", + pluralLabel: "系统上传会话", + description: "由 service-storage 跟踪的可续传分片上传会话", + fields: { + id: { + label: "上传会话 ID" + }, + file_id: { + label: "文件 ID" + }, + key: { + label: "存储键" + }, + filename: { + label: "文件名" + }, + mime_type: { + label: "MIME 类型" + }, + total_size: { + label: "总大小(字节)" + }, + chunk_size: { + label: "分片大小(字节)" + }, + total_chunks: { + label: "分片总数" + }, + uploaded_chunks: { + label: "已上传分片数" + }, + uploaded_size: { + label: "已上传大小(字节)" + }, + parts: { + label: "已上传分片(JSON)" + }, + resume_token: { + label: "续传令牌" + }, + backend_upload_id: { + label: "后端上传 ID" + }, + scope: { + label: "作用域" + }, + bucket: { + label: "存储桶" + }, + metadata: { + label: "元数据(JSON)" + }, + status: { + label: "状态", + options: { + in_progress: "上传中", + completing: "合并中", + completed: "已完成", + failed: "失败", + expired: "已过期" + } + }, + started_at: { + label: "开始时间" + }, + expires_at: { + label: "过期时间" + }, + updated_at: { + label: "更新时间" + } + } + } +}; From 405752456ee9495f64ef95e92f127c8bd7c1beec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Mon, 20 Jul 2026 03:36:07 -0700 Subject: [PATCH 2/2] fix(runtime): mount the in-app notifications REST routes (ADR-0030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleNotification (GET /notifications, POST /notifications/read[/all]) and its discovery entry existed, but dispatcher-plugin never registered server.() 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 --- ...ollab-notifications-and-storage-objects.md | 19 ++++++++-- .../src/dispatcher-plugin.routes.test.ts | 17 +++++++++ packages/runtime/src/dispatcher-plugin.ts | 37 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.changeset/localize-collab-notifications-and-storage-objects.md b/.changeset/localize-collab-notifications-and-storage-objects.md index 8182096bfe..b05ea42502 100644 --- a/.changeset/localize-collab-notifications-and-storage-objects.md +++ b/.changeset/localize-collab-notifications-and-storage-objects.md @@ -1,12 +1,14 @@ --- "@objectstack/plugin-audit": patch "@objectstack/service-storage": patch +"@objectstack/runtime": patch --- -feat(i18n): localize collaboration notification titles and the storage objects +feat(i18n): localize collaboration notification titles and the storage objects; wire the notifications REST routes -Two gaps left the notification surface English-only on localized workspaces -(observed as `sys_file "repro.png" assigned to you` over an all-Chinese UI): +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 @@ -28,3 +30,14 @@ Two gaps left the notification surface English-only on localized workspaces `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.()` 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. diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts index adfb0f839a..abb3da7292 100644 --- a/packages/runtime/src/dispatcher-plugin.routes.test.ts +++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts @@ -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.() 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 }); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 9a2916e3e2..92ab66f83a 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -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 —