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
40 changes: 40 additions & 0 deletions .changeset/notify-node-email-template-locale-bridge.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
"@objectstack/service-messaging": minor
---

feat(automation): flow `notify` nodes can reference an email template for localized delivery — `template` + `templateData` on `NotifyNodeConfig`, resolved by `(name, recipient locale)` at delivery time (#9205)

Ruled 「立项,走 emailTemplates 路线」: instead of widening the `flows`
translation surface (whose guidance excludes notification text, #7646), a
`notify` node now bridges to the existing localized email-template subsystem.

- **Spec** — `NotifyConfigSchema` gains `template` (a `sys_email_template`
name, read raw like `topic`/`channels`) and `templateData` (render context
for the template's `{{var}}` holes; values interpolate `{token}` templates
per run) as the localizable alternative to inline `title`/`message`. Inline
strings stay fully valid and byte-identical for existing flows — they are
the non-localizable path, and the describes now say so. A node carrying BOTH
paths, or `templateData` without `template`, or NEITHER path, is refused
loudly with the fix in the message (the `objectNavTargetExclusivity`
posture: unrepresentable over silent precedence).
- **service-automation** — the notify executor forwards the template
reference and its interpolated render context in the emit payload (the
outbox snapshots it onto each delivery row), and no longer demands an
inline title when a template is referenced.
- **service-messaging** — the email channel routes a template-carrying
delivery through `IEmailService.sendTemplate({ template, locale, data })`,
resolving the recipient locale per delivery: `payload.locale` if the
producer set one, else the deployment default
(`II18nService.getDefaultLocale()`, the #8195 ruled source), else
`sendTemplate`'s documented `en-US` ladder. Template-resolution failures
(`TEMPLATE_NOT_FOUND` / `TEMPLATE_INACTIVE` / `MISSING_VARIABLES`, and an
email service without `sendTemplate`) are graded `permanent` — dead
immediately with the code on the delivery row, instead of burning the retry
schedule on metadata that cannot fix itself.

The inbox channel keeps its existing rendering (notification title/body,
falling back to the topic on the template path): it has no locale-capable
rendering seam to the email-template subsystem today, and that gap is
documented in the PR rather than papered over with a duplicated resolver.
6 changes: 4 additions & 2 deletions content/docs/references/automation/io-node-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,8 +96,10 @@ const result = HttpConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
| **title** | `string` | ✅ | Notification title |
| **message** | `string` | optional | Notification body |
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, recipient locale)` at delivery time and renders subject/body per recipient. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
| **topic** | `string` | optional | Event topic (default: "notify") |
| **severity** | `Enum<'info' \| 'warning' \| 'critical'>` | optional | Severity forwarded to the messaging service |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,6 +210,48 @@ describe('notify (baseline node)', () => {
expect(result.error).toContain('title');
});

// ── #9205 — the localizable content path: template references ────────
it('emits the template reference + interpolated templateData instead of inline content', async () => {
engine.registerFlow('notify_flow', notifyFlow({
topic: 'deal.won',
recipients: ['user_1'],
channels: ['inbox', 'email'],
template: 'crm.large_deal_won',
templateData: { dealName: '{dealName}', dealUrl: '/opps/{dealId}' },
}));

const result = await engine.execute('notify_flow', {
params: { dealName: 'Acme', dealId: '42' },
} as any);

expect(result.success).toBe(true);
expect(messaging.emitted).toHaveLength(1);
const payload = messaging.emitted[0].payload;
// The reference rides RAW (a static metadata cross-reference); its
// render context is interpolated per run — that pair is what the
// email channel resolves per recipient locale at delivery time.
expect(payload.template).toBe('crm.large_deal_won');
expect(payload.templateData).toEqual({ dealName: 'Acme', dealUrl: '/opps/42' });
// No inline content keys on this path: a channel without template
// support falls back to the topic, the honest degraded rendering —
// not an empty string masquerading as content.
expect(payload).not.toHaveProperty('title');
expect(payload).not.toHaveProperty('body');
});

it('refuses a node carrying BOTH template and inline title (the contract superRefine, at the parse seam)', async () => {
engine.registerFlow('notify_flow', notifyFlow({
recipients: ['user_1'],
title: 'Deal won',
template: 'crm.large_deal_won',
}));
const result = await engine.execute('notify_flow');
expect(result.success).toBe(false);
expect(result.error).toContain('`template`');
expect(result.error).toContain('`title`');
expect(messaging.emitted).toHaveLength(0);
});

it('fails the step when no recipient is given', async () => {
engine.registerFlow('notify_flow', notifyFlow({ title: 'Hi' }));
const result = await engine.execute('notify_flow');
Expand Down
55 changes: 50 additions & 5 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,8 +168,27 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
recipients: {
description: 'Recipient user id(s) / audience selector(s)',
},
title: { type: 'string', description: 'Notification title' },
message: { type: 'string', description: 'Notification body' },
title: {
type: 'string',
description: 'Notification title, sent verbatim (not localizable — use template for per-locale content). Either this or template is required; mutually exclusive with template.',
},
message: {
type: 'string',
description: 'Notification body, sent verbatim (not localizable). Only valid with inline title, never with template.',
},
// ── Localizable content path (#9205) ─────────────────────
// Mirrors `NotifyConfigSchema.template`/`templateData`; the
// mutual exclusion with title/message lives in the Zod
// contract's superRefine (executed at parse time), matching
// how requiredness is owned there rather than by the form.
template: {
type: 'string',
description: 'Email template name (sys_email_template.name) — resolved by (name, recipient locale) at delivery time and rendered per recipient. Mutually exclusive with inline title/message.',
},
templateData: {
type: 'object',
description: 'Render context for the referenced template\'s {{var}} placeholders; values interpolate {token} templates per run. Only valid together with template.',
},
channels: {
type: 'array', items: { type: 'string' },
description: 'Channels to fan out to (default: inbox)',
Expand DownExpand Up@@ -228,6 +247,14 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
// useless `[object Object]` (#3450). Serialize it readably instead.
const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context));
const body = stringifyForTemplate(interpolate(cfg.message ?? '', variables, context));
// #9205 — the localizable content path. `template` is read RAW (a
// static metadata cross-reference, like `topic`/`channels`);
// `templateData` VALUES interpolate per run, so flow state can feed
// the template's `{{var}}` holes at delivery time.
const template = toStr(cfg.template);
const templateData = cfg.templateData
? (interpolate(cfg.templateData, variables, context) as Record<string, unknown>)
: undefined;
const channels = toStringList(cfg.channels);
const topic = cfg.topic ? String(cfg.topic) : undefined;
const severity = cfg.severity ? String(cfg.severity) : undefined;
Expand All@@ -246,7 +273,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
const source = resolveSource(cfg, variables, context);
const actorId = toStr(interpolate(cfg.actorId, variables, context));

if (!title) return { success: false, error: 'notify: title is required' };
// With a `template` reference the content lives in the template
// bundle, resolved per recipient locale at delivery — no inline
// title to demand (the Zod contract already refused a node carrying
// NEITHER, and one carrying BOTH).
if (!title && !template) return { success: false, error: 'notify: title is required' };
if (recipients.length === 0) {
// Name the templates that came up empty (framework#3582). The
// dominant cause is a cross-object hop — `{record.owner.manager}`
Expand All@@ -269,7 +300,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
const messaging = getMessaging();
if (!messaging) {
ctx.logger.warn(
`[notify] no messaging service registered; notification "${title}" not delivered`,
`[notify] no messaging service registered; notification "${title || `template ${template}`}" not delivered`,
);
return {
success: true,
Expand All@@ -289,7 +320,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
const result = await messaging.emit({
topic: topic ?? 'notify',
audience: recipients,
payload: { ...(payload ?? {}), title, body, url: actionUrl },
// Content rides in the payload per path (#9205): the inline
// strings, or the template reference + its render context —
// which the outbox snapshots onto each delivery row, so the
// per-recipient-locale resolution happens at delivery time
// in the channel (email-channel.ts reads payload.template).
// On the template path no inline title/body keys are set:
// channels without template support fall back to the topic,
// which is the honest degraded rendering, not ''.
payload: {
...(payload ?? {}),
...(template
? { template, ...(templateData !== undefined ? { templateData } : {}) }
: { title, body }),
url: actionUrl,
},
severity,
source,
actorId,
Expand Down
Loading
Loading