diff --git a/.changeset/25576-admin-rescue-comment-i18n.md b/.changeset/25576-admin-rescue-comment-i18n.md new file mode 100644 index 00000000000..5501a169484 --- /dev/null +++ b/.changeset/25576-admin-rescue-comment-i18n.md @@ -0,0 +1,10 @@ +--- +"@objectstack/plugin-approvals": patch +--- + +Clause-②: no +Metadata-protocol: none + +The note an `admin_rescue` approval request writes on its own timeline ("Routed to this organization's administrators because the approver slate named no one: …") is now read in the reader's language. The request's node-config snapshot stores the note as a code and its parameters (`__adminRescueNote`: the timeline row it explains, the abandoned approver slots, and why a declared `fallback` did not help), and `listActions` (`GET /api/v1/approvals/requests/:id/actions`) renders that row's `comment` through the plugin's `messages` catalog at the caller's locale, the way the approvals door already localizes a request's flow, step and object labels. The sentence and its clauses ship in `en`, `zh-CN`, `ja-JP` and `es-ES`. + +The stored `comment` is unchanged: it is still the English sentence, which a reader with no locale, an `en` reader, the generic data door and the action-history list views are served as before. Requests opened before this release carry no snapshot entry, so their note keeps showing its stored English; no migration is needed. diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts index 3a343049ca0..78ed3b15c4f 100644 --- a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts @@ -42,9 +42,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; -import { bindSecurityCatalogReader, type SecurityCatalogEntry, type SecurityCatalogReader } from '@objectstack/core'; +import { + bindSecurityCatalogReader, createMemoryI18n, type SecurityCatalogEntry, type SecurityCatalogReader, +} from '@objectstack/core'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { ApprovalsTranslations } from './translations/index.js'; import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; @@ -341,4 +344,84 @@ describe('an empty admin_rescue slate opens on the organization\'s administrator expect(Boolean(decision?.via_override)).toBe(true); }); }); + + // [#25576] The note is stored as a code and its parameters, and the + // approvals door renders it in the reader's language through the plugin's + // own `messages` catalog, loaded into a real in-memory `i18n` service the + // way the plugin loads it at `kernel:ready`. + describe('[#25576] the note is stored as data and read in the reader\'s language', () => { + let reader: ApprovalService; + const sub = (locale?: string) => ({ ...as('u_sub', ORG), ...(locale ? { locale } : {}) }); + const noteOf = async (requestId: string, locale?: string) => { + const served = await reader.listActions(requestId, sub(locale)); + return served.filter((a) => a.action === 'comment' && !a.actor_id); + }; + const ENGLISH = "Routed to this organization's administrators because the approver slate named no one: " + + "position 'sales_manager' has no holder."; + + beforeAll(() => { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(ApprovalsTranslations)) { + i18n.loadTranslations(locale, data as Record); + } + reader = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(now += 1000) }, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any, + i18nServiceSource: () => i18n, + }); + }); + + it('the snapshot stores the note\'s code and parameters, naming the row; the row stores its English', async () => { + const row = await open('note_data', UNSTAFFED); + const [note] = (await actionsOf(row.id)).filter((a) => a.action === 'comment'); + expect(note.comment).toBe(ENGLISH); + expect((await snapshotOf(row.id)).__adminRescueNote).toEqual({ + action_id: note.id, abandoned: ['position:sales_manager'], + }); + }); + + it('a zh-CN reader of the timeline reads the note in Chinese', async () => { + const row = await open('note_zh', UNSTAFFED); + const served = await noteOf(row.id, 'zh-CN'); + expect(served.map((a) => a.comment)).toEqual(['审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职。']); + // A read, never a write: the stored row still carries the English. + expect((await actionsOf(row.id)).find((a) => a.id === served[0].id)?.comment).toBe(ENGLISH); + }); + + it('a fallback that found no one renders both clauses, joined the reader\'s way', async () => { + const row = await open('note_zh_fallback', UNSTAFFED, { + onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'position', value: 'treasury' }], + }); + expect((await snapshotOf(row.id)).__adminRescueNote).toMatchObject({ fallback: 'resolved_to_no_one' }); + expect((await noteOf(row.id, 'zh-CN')).map((a) => a.comment)).toEqual([ + '审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职;' + + '声明的 fallbackApprovers 也未解析到任何人。', + ]); + }); + + it('an en reader, and a reader with no locale, are served the stored English', async () => { + const row = await open('note_en', UNSTAFFED); + for (const locale of ['en', undefined]) { + expect((await noteOf(row.id, locale)).map((a) => a.comment), String(locale)).toEqual([ENGLISH]); + } + }); + + it('CONTROL: a request opened before the note was data, and another system comment, stay as stored', async () => { + const row = await open('note_legacy', UNSTAFFED); + // The stored shape of a request opened before #25576: no snapshot entry. + const snapshot = await snapshotOf(row.id); + delete snapshot.__adminRescueNote; + await engine.update('sys_approval_request', { id: row.id, node_config_json: JSON.stringify(snapshot) }, + { context: SYSTEM } as any); + expect((await noteOf(row.id, 'zh-CN')).map((a) => a.comment)).toEqual([ENGLISH]); + + const noted = await open('note_neighbour', UNSTAFFED); + await reader.comment(noted.id, { actorId: 'system', comment: 'Checked by the nightly audit.' }, SYSTEM); + expect((await noteOf(noted.id, 'zh-CN')).map((a) => a.comment)).toEqual([ + '审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职。', + 'Checked by the nightly audit.', + ]); + }); + }); }); diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-note.test.ts b/packages/plugins/plugin-approvals/src/admin-rescue-note.test.ts new file mode 100644 index 00000000000..a056145af62 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/admin-rescue-note.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25576] The administrators' routing note: one English source, the reader's + * language from the same catalog, and a strict read of the stored shape. + * The end-to-end half (the snapshot an opened request stores, and the door a + * zh-CN reader reads it through) is `admin-rescue-named-slate.integration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { adminRescueNoteOf, adminRescueNoteText, adminRescueReason, type ApprovalSentenceRender } from './admin-rescue-note.js'; +import { approvalMessages } from './translations/messages.js'; + +/** A render over one shipped locale of the catalog, `{{hole}}` filled the `t()` way. */ +const renderIn = (locale: keyof typeof approvalMessages, drop: string[] = []): ApprovalSentenceRender => + (key, params) => { + const id = key.replace(/^messages\./, ''); + if (drop.includes(id)) return undefined; + const template = (approvalMessages[locale] as Record)[id]; + return template?.replace(/\{\{(\w+)\}\}/g, (hole, name: string) => params[name] ?? hole); + }; + +describe('[#25576] the admin_rescue note', () => { + it('the English is the sentence the row has always stored', () => { + expect(adminRescueNoteText({ abandoned: ['position:sales_manager', 'team:ops'] })).toBe( + "Routed to this organization's administrators because the approver slate named no one: " + + "position 'sales_manager' has no holder; team 'ops' resolved to no one.", + ); + expect(adminRescueReason([], 'not_declared')).toBe( + "no approver on the node resolved to a person; onEmptyApprovers is 'fallback' but no fallbackApprovers are declared", + ); + }); + + it('a reader\'s language renders every clause and the separator from its own pack', () => { + const note = { abandoned: ['position:sales_manager', 'team:ops'], fallback: 'resolved_to_no_one' as const }; + expect(adminRescueNoteText(note, renderIn('zh-CN'))).toBe( + '审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职;' + + 'team「ops」未解析到任何人;声明的 fallbackApprovers 也未解析到任何人。', + ); + expect(adminRescueNoteText(note, renderIn('ja-JP'))).toContain('役職「sales_manager」に就いている人がいません'); + }); + + it('a clause the pack lacks falls back to its English; a missing note sentence renders nothing', () => { + const note = { abandoned: ['position:sales_manager'] }; + expect(adminRescueNoteText(note, renderIn('zh-CN', ['approvalAdminRescuePositionUnheld']))).toBe( + "审批人名单未指向任何人,因此已转交本组织管理员处理:position 'sales_manager' has no holder。", + ); + expect(adminRescueNoteText(note, renderIn('zh-CN', ['approvalAdminRescueNote']))).toBeUndefined(); + }); + + it('reads only the shape it writes', () => { + const good = { action_id: 'aact_1', abandoned: ['position:p'], fallback: 'not_declared' }; + expect(adminRescueNoteOf({ __adminRescueNote: good })).toEqual(good); + expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'aact_1', abandoned: [] } })) + .toEqual({ action_id: 'aact_1', abandoned: [] }); + for (const bad of [ + undefined, + {}, + { __adminRescueNote: 'Routed to …' }, + { __adminRescueNote: { abandoned: [] } }, + { __adminRescueNote: { action_id: 'aact_1', abandoned: [1] } }, + { __adminRescueNote: { action_id: 'aact_1', abandoned: [], fallback: 'nope' } }, + ]) { + expect(adminRescueNoteOf(bad), JSON.stringify(bad)).toBeUndefined(); + } + }); +}); diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-note.ts b/packages/plugins/plugin-approvals/src/admin-rescue-note.ts new file mode 100644 index 00000000000..1bf2cd07cec --- /dev/null +++ b/packages/plugins/plugin-approvals/src/admin-rescue-note.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25576] The note an `admin_rescue` request writes on its own timeline, as a + * stored code and its parameters, rendered in the READER's language. + * + * ## What was wrong + * + * When an approver slate named no one and the request opened on the + * organization's administrators (#22824), `openNodeRequest` composed one + * English sentence ("Routed to this organization's administrators because the + * approver slate named no one: position 'X' has no holder.") and stored it as + * the `comment` of a system `sys_approval_action` row. The approvals door + * served that comment as stored, so a zh-CN, ja-JP or es-ES reader of the + * timeline read English. + * + * ## The stored shape + * + * The request's node-config snapshot carries the note as data, under + * {@link ADMIN_RESCUE_NOTE_SNAPSHOT_KEY}: the id of the timeline row it + * explains, the abandoned slate and why a declared `fallback` did not help. + * The snapshot, not a new column, is where a request already keeps what it + * resolved at open time (`__flowLabel`, `__round`, `__resolvedFrom`), so no + * schema moves. The row's `comment` stays the English rendering: the audit + * text the generic data door, the action-history list views and every reader + * without a locale are served, exactly as before. + * + * ## The render, reused + * + * The sentence and its clauses are catalog entries in this plugin's `messages` + * face (`./translations/messages.ts`, `approvalAdminRescue*`). The English is + * those `en` entries interpolated here, ⛔ never a second spelling; the + * reader's language is the same entries through `II18nService.t()` at the + * reader's negotiated locale (`ReaderLabels.sentence`, `reader-locale-labels.ts` + * — the one way the approvals door picks a reader's language). A clause the + * pack does not hold falls back to its English; a note sentence the pack does + * not hold leaves the stored comment as it is. + * + * A row written before this note existed has no snapshot entry, so it is served + * as stored: the English it always carried. Its clauses survive only inside that + * prose, and parsing prose back into a code is the defect this module replaces, + * so no backfill recovers them. + */ + +import { approvalMessageKey, interpolate } from './approval-notification-text.js'; +import { enMessages, type ApprovalMessageId } from './translations/messages.js'; + +/** Why a declared `onEmptyApprovers: 'fallback'` did not name anyone. */ +export type AdminRescueFallback = 'not_declared' | 'resolved_to_no_one'; + +/** The node-config snapshot key the note rides under. */ +export const ADMIN_RESCUE_NOTE_SNAPSHOT_KEY = '__adminRescueNote'; + +/** The note as the request's snapshot stores it. */ +export interface AdminRescueNote { + /** The `sys_approval_action` row (an `action: 'comment'` with no actor) this note is. */ + readonly action_id: string; + /** The slate that named no one, as resolved: `type:value` slots and anything else it held. */ + readonly abandoned: readonly string[]; + /** Set when the node declared `onEmptyApprovers: 'fallback'` and it did not help. */ + readonly fallback?: AdminRescueFallback; +} + +/** One catalog sentence and its params. */ +interface Clause { + readonly id: ApprovalMessageId; + readonly params: Readonly>; +} + +/** Renders a catalog sentence in some language, or answers `undefined` when it cannot. */ +export type ApprovalSentenceRender = ( + key: string, + params: Readonly>, +) => string | undefined; + +/** The reason's clauses: one per abandoned `type:value` slot, then the fallback's. */ +function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): Clause[] { + const clauses: Clause[] = abandoned + .filter((slot) => !!slot && slot.includes(':')) + .map((slot): Clause => { + const at = slot.indexOf(':'); + const type = slot.slice(0, at); + const value = slot.slice(at + 1); + return type === 'position' + ? { id: 'approvalAdminRescuePositionUnheld', params: { value } } + : { id: 'approvalAdminRescueSlotUnresolved', params: { type, value } }; + }); + if (!clauses.length) clauses.push({ id: 'approvalAdminRescueNoApprover', params: {} }); + if (fallback === 'resolved_to_no_one') clauses.push({ id: 'approvalAdminRescueFallbackUnresolved', params: {} }); + if (fallback === 'not_declared') clauses.push({ id: 'approvalAdminRescueFallbackUndeclared', params: {} }); + return clauses; +} + +const english = (clause: Clause): string => interpolate(enMessages[clause.id], clause.params); + +/** + * Why the slate named no one, in English: the audit snapshot's + * `admin_rescue:reason` and the log lines. + */ +export function adminRescueReason(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): string { + return reasonClauses(abandoned, fallback).map(english).join(enMessages.approvalAdminRescueReasonSeparator); +} + +/** + * The note's text. With no `render`, the English the row stores; with one, + * the reader's language, or `undefined` when the note sentence itself is not + * rendered (the stored comment then stands). + */ +export function adminRescueNoteText(note: Omit): string; +export function adminRescueNoteText( + note: Omit, + render: ApprovalSentenceRender, +): string | undefined; +export function adminRescueNoteText( + note: Omit, + render?: ApprovalSentenceRender, +): string | undefined { + const clauses = reasonClauses(note.abandoned, note.fallback); + if (!render) { + const reason = clauses.map(english).join(enMessages.approvalAdminRescueReasonSeparator); + return interpolate(enMessages.approvalAdminRescueNote, { reason }); + } + const separator = render(approvalMessageKey('approvalAdminRescueReasonSeparator'), {}) + ?? enMessages.approvalAdminRescueReasonSeparator; + const reason = clauses + .map((clause) => render(approvalMessageKey(clause.id), clause.params) ?? english(clause)) + .join(separator); + return render(approvalMessageKey('approvalAdminRescueNote'), { reason }); +} + +/** + * The note a parsed node-config snapshot carries, or `undefined` when it + * carries none or one this module did not write (the stored comment stands). + */ +export function adminRescueNoteOf(snapshot: unknown): AdminRescueNote | undefined { + if (!snapshot || typeof snapshot !== 'object') return undefined; + const note = (snapshot as Record)[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY]; + if (!note || typeof note !== 'object') return undefined; + const { action_id, abandoned, fallback } = note as Record; + if (typeof action_id !== 'string' || !action_id) return undefined; + if (!Array.isArray(abandoned) || !abandoned.every((slot) => typeof slot === 'string')) return undefined; + if (fallback !== undefined && fallback !== 'not_declared' && fallback !== 'resolved_to_no_one') return undefined; + return { action_id, abandoned, ...(fallback ? { fallback } : {}) }; +} diff --git a/packages/plugins/plugin-approvals/src/approval-notification-text.ts b/packages/plugins/plugin-approvals/src/approval-notification-text.ts index 46a4260ea7a..cf2cf8411a7 100644 --- a/packages/plugins/plugin-approvals/src/approval-notification-text.ts +++ b/packages/plugins/plugin-approvals/src/approval-notification-text.ts @@ -95,9 +95,10 @@ const HOLE = /\{\{(\w+)\}\}/g; /** * `{{name}}` interpolation with the convention both `II18nService.t()` * implementations use — one pass, a value is never re-scanned, a hole with no - * value stays as written — over each param's literal text. + * value stays as written — over each param's literal text. Also the `en` + * rendering of the administrators' routing note (`admin-rescue-note.ts`). */ -function interpolate(template: string, params: Readonly>): string { +export function interpolate(template: string, params: Readonly>): string { return template.replace(HOLE, (hole, name: string) => { const param = params[name]; if (param === undefined) return hole; diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index d0fb754d308..500547c293a 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -94,6 +94,13 @@ import { type ActingCaller, } from './approver-address.js'; import { labelOrMachineName, requestDisplayLabels, slateLabelOf } from './display-labels.js'; +import { + ADMIN_RESCUE_NOTE_SNAPSHOT_KEY, + adminRescueNoteOf, + adminRescueNoteText, + adminRescueReason, + type AdminRescueNote, +} from './admin-rescue-note.js'; import { localizeRequestLabels, readerLabelsFor, @@ -1104,33 +1111,6 @@ function holdsTenantAdminRung(permissions: readonly string[]): boolean { */ const ADMIN_RESCUE_GROUP = '#0'; -/** - * [#22824] Why a slate opened on the organization's administrators, in words - * an operator reads on the request's timeline and in its `__resolvedFrom` - * snapshot: which approver entries resolved to no one. - * - * `abandoned` is the slate the node resolved (only `type:value` literals, or - * nothing at all: an empty `manager` rung adds no slot). `fallback` says why - * an `onEmptyApprovers: 'fallback'` node degraded here, when it is one. - */ -function emptySlateReason( - abandoned: readonly string[], - fallback: 'not_declared' | 'resolved_to_no_one' | undefined, -): string { - const parts = abandoned - .filter((slot) => !!slot && slot.includes(':')) - .map((slot) => { - const at = slot.indexOf(':'); - const type = slot.slice(0, at); - const value = slot.slice(at + 1); - return type === 'position' ? `position '${value}' has no holder` : `${type} '${value}' resolved to no one`; - }); - let reason = parts.length ? parts.join('; ') : 'no approver on the node resolved to a person'; - if (fallback === 'resolved_to_no_one') reason += '; the declared fallbackApprovers resolved to no one either'; - if (fallback === 'not_declared') reason += "; onEmptyApprovers is 'fallback' but no fallbackApprovers are declared"; - return reason; -} - /** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */ interface OooSubstitution { /** The approver who was skipped (out of office). */ @@ -3658,8 +3638,10 @@ export class ApprovalService implements IApprovalService { // // [#22824] Set when the slate below is replaced by the organization's // administrators: the reason, written to the request's timeline once the - // request row exists. - let routedToAdministrators: string | undefined; + // request row exists. [#25576] As a code and its parameters, which the + // snapshot stores and the approvals door renders in the reader's language + // (`admin-rescue-note.ts`); the row's `comment` is its English. + let adminRescueNote: AdminRescueNote | undefined; if (!slateNamesAPerson(approvers)) { const emptyPolicy = (input.config as any).onEmptyApprovers ?? 'admin_rescue'; // Why a `fallback` node degraded to admin_rescue, for the reason below. @@ -3766,7 +3748,7 @@ export class ApprovalService implements IApprovalService { // Re-tested rather than assumed: a `fallback` that landed people above // has already turned this into a normal request. if (!slateNamesAPerson(approvers)) { - const reason = emptySlateReason(approvers, fallbackDegraded); + const reason = adminRescueReason(approvers, fallbackDegraded); const found = await this.organizationAdministrators(requestOrg); const administrators = 'ids' in found ? found.ids : []; if (administrators.length) { @@ -3792,8 +3774,12 @@ export class ApprovalService implements IApprovalService { resolved: approvers, administrators: administratorSlate, }, ); + adminRescueNote = { + action_id: uid('aact'), + abandoned: [...approvers], + ...(fallbackDegraded ? { fallback: fallbackDegraded } : {}), + }; approvers = administratorSlate; - routedToAdministrators = reason; } else { // Break-glass stays break-glass (#3424): an organization whose // administrators' slate resolves to nobody opens the request on the @@ -3843,6 +3829,7 @@ export class ApprovalService implements IApprovalService { if (Object.keys(resolvedFrom).length) { configSnapshot.__resolvedFrom = resolvedFrom; } + if (adminRescueNote) configSnapshot[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY] = adminRescueNote; // ADR-0044 round numbering: rounds of a revise loop share the run — count // this (run, node)'s prior requests; the new one is round N+1. Stamped on // the snapshot (precedent: __flowLabel), so no schema migration. @@ -3896,13 +3883,15 @@ export class ApprovalService implements IApprovalService { // one system note on its timeline (no person took it, so `actor_id` is // null, ADR-0118 D1 — the shape the OOO hop below records), naming which // approver entries resolved to no one. The same reason rides - // `__resolvedFrom` for the audit snapshot. - if (routedToAdministrators) { + // `__resolvedFrom` for the audit snapshot. [#25576] The row's id is the + // one the snapshot's note names, which is how `listActions` finds the row + // to render in the reader's language. + if (adminRescueNote) { await this.engine.insert('sys_approval_action', await this.actionRow(row, { - id: uid('aact'), request_id: id, organization_id: requestOrg, + id: adminRescueNote.action_id, request_id: id, organization_id: requestOrg, step_name: input.nodeId, step_index: 0, action: 'comment', actor_id: null, - comment: `Routed to this organization's administrators because the approver slate named no one: ${routedToAdministrators}.`, + comment: adminRescueNoteText(adminRescueNote), created_at: now, }), { context: SYSTEM_CTX }); } @@ -8709,9 +8698,42 @@ export class ApprovalService implements IApprovalService { const toName = a.reassign_to ? names.get(String(a.reassign_to)) : undefined; if (toName) a.reassign_to_name = toName; } + await this.localizeAdminRescueNote(requestId, actions, context); return actions; } + /** + * [#25576] The administrators' routing note, in the reader's language. + * + * The note's code and parameters ride the request's node-config snapshot, + * naming the timeline row they explain (`admin-rescue-note.ts`); that row's + * `comment` is overlaid, in place, with the note as the reader's locale + * renders it — the shape {@link readerLabels} gives the request's own labels. + * A read, never a write. Nothing is overlaid, and the stored English stands, + * when the read carries no locale or no `i18n` service is registered, when + * the request carries no note (a request that never routed to its + * administrators, or one opened before the note was stored as data), or + * when the pack holds no note sentence. + */ + private async localizeAdminRescueNote( + requestId: string, + actions: ApprovalActionRow[], + context: ExecutionContext, + ): Promise { + // Only a system comment can be the note: no read for any other timeline. + if (!actions.some((a) => a.action === 'comment' && !a.actor_id)) return; + const labels = this.readerLabels(context); + if (!labels) return; + const tenantOrg = (context as any)?.organizationId ?? context?.tenantId; + const raw = await findRequestRow(this.engine, requestId, tenantOrg ?? null); + const note = adminRescueNoteOf(parseJson(raw?.node_config_json, undefined)); + if (!note) return; + const row = actions.find((a) => a.id === note.action_id && a.action === 'comment' && !a.actor_id); + if (!row) return; + const text = adminRescueNoteText(note, labels.sentence); + if (text) row.comment = text; + } + /** * `IFileAccessDelegate` — may this caller download a decision attachment? * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.) diff --git a/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts b/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts index cee7434e772..21275916344 100644 --- a/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts +++ b/packages/plugins/plugin-approvals/src/approver-address-readers.test.ts @@ -186,6 +186,12 @@ const PERSON_SITES: Readonly`) rendered with `params`, by + * the same `t()` and the same negotiated locale. Not memoised: the params + * differ per row. + */ + sentence(key: string, params: Readonly>): string | undefined; } /** @@ -132,18 +138,20 @@ export function readerLabelsFor( if (typeof requested !== 'string' || requested.length === 0) return undefined; if (!service || typeof service.t !== 'function') return undefined; const locale = negotiatedLocale(service, requested); - const picked = new Map(); - const pick = (key: string): string | undefined => { - if (picked.has(key)) return picked.get(key); - let answer: string | undefined; + const render = (key: string, params?: Readonly>): string | undefined => { try { - const text = service.t(key, locale); + const text = service.t(key, locale, params); // `II18nService.t` echoes the key back on a miss. - if (typeof text === 'string' && text.trim().length > 0 && text !== key) answer = text; + if (typeof text === 'string' && text.trim().length > 0 && text !== key) return text; } catch { // A misbehaving i18n service must not fail the read: the stored label stands. - answer = undefined; } + return undefined; + }; + const picked = new Map(); + const pick = (key: string): string | undefined => { + if (picked.has(key)) return picked.get(key); + const answer = render(key); picked.set(key, answer); return answer; }; @@ -151,6 +159,7 @@ export function readerLabelsFor( flowLabel: (flowName) => pick(flowLabelKey(flowName)), stepLabel: (flowName, nodeId) => pick(approvalStepLabelKey(flowName, nodeId)), objectLabel: (objectName) => pick(objectLabelKey(objectName)), + sentence: (key, params) => render(key, params), }; } diff --git a/packages/plugins/plugin-approvals/src/translations/messages.ts b/packages/plugins/plugin-approvals/src/translations/messages.ts index 2b332695c4c..3b31608a2a4 100644 --- a/packages/plugins/plugin-approvals/src/translations/messages.ts +++ b/packages/plugins/plugin-approvals/src/translations/messages.ts @@ -33,6 +33,17 @@ * - `{{person}}` — a display name; `{{action}}` — the escalation action's * configured token; `{{limit}}` — the revision limit; `{{comment}}` — the * decider's comment, as they wrote it. + * + * ## The administrators' routing note + * + * [#25576] `approvalAdminRescue*` are not notifications: they are the system + * note an `admin_rescue` request writes on its own timeline, rendered by the + * approvals door in the READER's language (`admin-rescue-note.ts`). The note + * is `approvalAdminRescueNote` around `{{reason}}`, and the reason is one + * clause per approver entry that resolved to no one (`{{type}}` is the + * approver type's machine token, `{{value}}` its configured value), joined by + * `approvalAdminRescueReasonSeparator`. The `en` note, rendered, is the + * `comment` the row stores. */ import type { TranslationData } from '@objectstack/spec/system'; @@ -69,6 +80,13 @@ export const enMessages = { approvalReturnedBody: 'Your {{object}} {{record}} needs rework before it can be approved.', approvalRequestInfoTitle: 'More information requested', approvalCommentTitle: 'New comment on an approval', + approvalAdminRescueNote: "Routed to this organization's administrators because the approver slate named no one: {{reason}}.", + approvalAdminRescuePositionUnheld: "position '{{value}}' has no holder", + approvalAdminRescueSlotUnresolved: "{{type}} '{{value}}' resolved to no one", + approvalAdminRescueNoApprover: 'no approver on the node resolved to a person', + approvalAdminRescueFallbackUnresolved: 'the declared fallbackApprovers resolved to no one either', + approvalAdminRescueFallbackUndeclared: "onEmptyApprovers is 'fallback' but no fallbackApprovers are declared", + approvalAdminRescueReasonSeparator: '; ', } as const satisfies Messages; /** One approval notification sentence's id. */ @@ -105,6 +123,13 @@ export const zhCNMessages: ApprovalMessages = { approvalReturnedBody: '您的{{object}} {{record}} 需要修改后才能获批。', approvalRequestInfoTitle: '需要补充信息', approvalCommentTitle: '审批有新评论', + approvalAdminRescueNote: '审批人名单未指向任何人,因此已转交本组织管理员处理:{{reason}}。', + approvalAdminRescuePositionUnheld: '岗位「{{value}}」无人任职', + approvalAdminRescueSlotUnresolved: '{{type}}「{{value}}」未解析到任何人', + approvalAdminRescueNoApprover: '节点上没有任何审批人解析到具体人员', + approvalAdminRescueFallbackUnresolved: '声明的 fallbackApprovers 也未解析到任何人', + approvalAdminRescueFallbackUndeclared: "onEmptyApprovers 为 'fallback',但未声明 fallbackApprovers", + approvalAdminRescueReasonSeparator: ';', }; export const jaJPMessages: ApprovalMessages = { @@ -136,6 +161,13 @@ export const jaJPMessages: ApprovalMessages = { approvalReturnedBody: 'あなたの{{object}} {{record}} は、承認を受ける前に修正が必要です。', approvalRequestInfoTitle: '追加情報が求められています', approvalCommentTitle: '承認に新しいコメントがあります', + approvalAdminRescueNote: '承認者リストが誰も指していなかったため、この組織の管理者に回されました:{{reason}}。', + approvalAdminRescuePositionUnheld: '役職「{{value}}」に就いている人がいません', + approvalAdminRescueSlotUnresolved: '{{type}}「{{value}}」は誰にも解決されませんでした', + approvalAdminRescueNoApprover: 'ノード上のどの承認者も個人に解決されませんでした', + approvalAdminRescueFallbackUnresolved: '宣言された fallbackApprovers も誰にも解決されませんでした', + approvalAdminRescueFallbackUndeclared: "onEmptyApprovers は 'fallback' ですが、fallbackApprovers が宣言されていません", + approvalAdminRescueReasonSeparator: ';', }; export const esESMessages: ApprovalMessages = { @@ -167,6 +199,13 @@ export const esESMessages: ApprovalMessages = { approvalReturnedBody: 'Su {{object}} {{record}} necesita cambios antes de poder aprobarse.', approvalRequestInfoTitle: 'Se solicita más información', approvalCommentTitle: 'Nuevo comentario en una aprobación', + approvalAdminRescueNote: 'Asignada a los administradores de esta organización porque la lista de aprobadores no nombraba a nadie: {{reason}}.', + approvalAdminRescuePositionUnheld: "el puesto '{{value}}' no tiene titular", + approvalAdminRescueSlotUnresolved: "{{type}} '{{value}}' no se resolvió a nadie", + approvalAdminRescueNoApprover: 'ningún aprobador del nodo se resolvió a una persona', + approvalAdminRescueFallbackUnresolved: 'los fallbackApprovers declarados tampoco se resolvieron a nadie', + approvalAdminRescueFallbackUndeclared: "onEmptyApprovers es 'fallback' pero no se han declarado fallbackApprovers", + approvalAdminRescueReasonSeparator: '; ', }; /** The four shipped locales, keyed as the bundle beside this file keys them. */