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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/25576-admin-rescue-comment-i18n.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>);
}
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.',
]);
});
});
});
67 changes: 67 additions & 0 deletions packages/plugins/plugin-approvals/src/admin-rescue-note.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)[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();
}
});
});
144 changes: 144 additions & 0 deletions packages/plugins/plugin-approvals/src/admin-rescue-note.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
}

/** Renders a catalog sentence in some language, or answers `undefined` when it cannot. */
export type ApprovalSentenceRender = (
key: string,
params: Readonly<Record<string, string>>,
) => 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<AdminRescueNote, 'action_id'>): string;
export function adminRescueNoteText(
note: Omit<AdminRescueNote, 'action_id'>,
render: ApprovalSentenceRender,
): string | undefined;
export function adminRescueNoteText(
note: Omit<AdminRescueNote, 'action_id'>,
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<string, unknown>)[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY];
if (!note || typeof note !== 'object') return undefined;
const { action_id, abandoned, fallback } = note as Record<string, unknown>;
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 } : {}) };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, ApprovalTextParam>>): string {
export function interpolate(template: string, params: Readonly<Record<string, ApprovalTextParam>>): string {
return template.replace(HOLE, (hole, name: string) => {
const param = params[name];
if (param === undefined) return hole;
Expand Down
Loading
Loading