Skip to content

Commit 0adcc1c

Browse files
os-zhuangclaude
andauthored
feat(automation): notify node click-through target (source_object/source_id) (#2675) (#2677)
The flow `notify` node consumed only recipients/title/message/channels, so every notification it emitted had `sys_notification.source_object` / `source_id` = null — inbox notifications could not be clicked into the related record. Read `sourceObject`/`sourceId` (or the nested `source:{object,id}` form) and `actorId` from the node config and forward them to `messaging.emit()`, which already persists the source columns and synthesizes a `/{object}/{id}` inbox deep-link. Both keys interpolate flow variables; a half-specified target is dropped so the inbox never renders a dead link. Accept `url` as an alias for `actionUrl`, and publish a `configSchema` documenting the accepted keys. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c4fd39f commit 0adcc1c

3 files changed

Lines changed: 156 additions & 3 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
Flow `notify` node: support a click-through target so inbox notifications can be clicked into the related record (#2675).
6+
7+
The `notify` node now reads `sourceObject` / `sourceId` (or the nested `source: { object, id }` form) and `actorId` from its config and forwards them to the messaging service, which persists `sys_notification.source_object` / `source_id` / `actor_id` and synthesizes a `/{object}/{id}` inbox deep-link. Both keys interpolate flow variables (e.g. `sourceId: '{new_quotation.id}'`), and a half-specified target (object without id, or vice versa) is dropped so the inbox never renders a dead link. `url` is now accepted as an alias for `actionUrl` (an explicit URL still overrides the synthesized link). The node also publishes a `configSchema` documenting all accepted keys for the Studio form.
8+
9+
Previously the node consumed only `recipients` / `title` / `message` / `channels`, so every notification it emitted had `source_object` / `source_id` = `null` and could not be clicked through to a record.

packages/services/service-automation/src/builtin/notify-node.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,72 @@ describe('notify (baseline node)', () => {
117117
expect(result.output).toMatchObject({ 'notify.delivered': 2 });
118118
});
119119

120+
it('forwards a click-through target via sourceObject/sourceId, interpolating the id (#2675)', async () => {
121+
engine.registerFlow('notify_flow', notifyFlow({
122+
recipients: ['user_1'],
123+
title: 'Quote {dealName} approved',
124+
message: 'Fill in the line items',
125+
channels: ['inbox'],
126+
sourceObject: 'mtc_quotation',
127+
sourceId: '{dealId}',
128+
}));
129+
130+
const result = await engine.execute('notify_flow', {
131+
params: { dealName: 'Acme', dealId: 'q_42' },
132+
} as any);
133+
134+
expect(result.success).toBe(true);
135+
expect(messaging.emitted[0]).toMatchObject({
136+
source: { object: 'mtc_quotation', id: 'q_42' },
137+
});
138+
});
139+
140+
it('accepts the nested source:{object,id} form and forwards actorId', async () => {
141+
engine.registerFlow('notify_flow', notifyFlow({
142+
recipients: ['user_1'],
143+
title: 'Assigned to you',
144+
source: { object: 'opportunity', id: '{dealId}' },
145+
actorId: '{dealName}',
146+
}));
147+
148+
const result = await engine.execute('notify_flow', {
149+
params: { dealName: 'user_boss', dealId: '99' },
150+
} as any);
151+
152+
expect(result.success).toBe(true);
153+
expect(messaging.emitted[0]).toMatchObject({
154+
source: { object: 'opportunity', id: '99' },
155+
actorId: 'user_boss',
156+
});
157+
});
158+
159+
it('drops a half-specified target (object without id) rather than emitting a dead link', async () => {
160+
engine.registerFlow('notify_flow', notifyFlow({
161+
recipients: ['user_1'],
162+
title: 'Heads up',
163+
sourceObject: 'opportunity',
164+
// no sourceId
165+
}));
166+
167+
const result = await engine.execute('notify_flow');
168+
expect(result.success).toBe(true);
169+
expect(messaging.emitted[0].source).toBeUndefined();
170+
});
171+
172+
it('accepts `url` as an alias for actionUrl', async () => {
173+
engine.registerFlow('notify_flow', notifyFlow({
174+
recipients: ['user_1'],
175+
title: 'Heads up',
176+
url: '/opps/{dealId}',
177+
}));
178+
179+
const result = await engine.execute('notify_flow', {
180+
params: { dealId: '7' },
181+
} as any);
182+
expect(result.success).toBe(true);
183+
expect(messaging.emitted[0].payload).toMatchObject({ url: '/opps/7' });
184+
});
185+
120186
it('accepts a single recipient string and the subject/to aliases', async () => {
121187
engine.registerFlow('notify_flow', notifyFlow({
122188
to: 'user_9',

packages/services/service-automation/src/builtin/notify-node.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { PluginContext } from '@objectstack/core';
4+
import type { AutomationContext } from '@objectstack/spec/contracts';
45
import { defineActionDescriptor } from '@objectstack/spec/automation';
56
import type { AutomationEngine } from '../engine.js';
6-
import { interpolate } from './template.js';
7+
import { interpolate, type VariableMap } from './template.js';
78

89
/**
910
* Structural view of `@objectstack/service-messaging`'s service (ADR-0012),
@@ -33,6 +34,33 @@ function toStringList(value: unknown): string[] {
3334
return [];
3435
}
3536

37+
/** Coerce an interpolated config value to a non-empty trimmed string, else undefined. */
38+
function toStr(value: unknown): string | undefined {
39+
if (value == null) return undefined;
40+
const s = String(value).trim();
41+
return s.length > 0 ? s : undefined;
42+
}
43+
44+
/**
45+
* Resolve the click-through target record from the node config, if any.
46+
*
47+
* Accepts the flat `sourceObject`/`sourceId` keys (canonical — mirrors the
48+
* `sys_notification.source_object`/`source_id` columns) or the nested
49+
* `source: { object, id }` form (mirrors the messaging `emit()` surface). A
50+
* target is produced only when BOTH object and id resolve — a half-specified
51+
* link is dropped so the inbox never renders a dead deep-link.
52+
*/
53+
function resolveSource(
54+
cfg: Record<string, unknown>,
55+
variables: VariableMap,
56+
context: AutomationContext,
57+
): { object: string; id: string } | undefined {
58+
const src = (cfg.source ?? null) as { object?: unknown; id?: unknown } | null;
59+
const object = toStr(interpolate(cfg.sourceObject ?? src?.object, variables, context));
60+
const id = toStr(interpolate(cfg.sourceId ?? src?.id, variables, context));
61+
return object && id ? { object, id } : undefined;
62+
}
63+
3664
/**
3765
* `notify` built-in node (ADR-0012) — outbound notification.
3866
*
@@ -68,6 +96,46 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
6896
// emit → sys_notification_delivery), so it inherits retry/dead-letter.
6997
needsOutbox: true,
7098
paradigms: ['flow', 'approval'],
99+
// Drives the Studio form + documents the accepted keys. Extra keys
100+
// are still tolerated (JSON Schema allows additional properties) —
101+
// this is discoverability, not a lockdown.
102+
configSchema: {
103+
// No `required` array: `recipients`/`title` each accept an alias
104+
// (`to`/`subject`), which a strict required-check would reject.
105+
// The node enforces "title + ≥1 recipient" at execute time.
106+
type: 'object',
107+
properties: {
108+
recipients: {
109+
description: 'Recipient user id(s) / audience selector(s); alias: `to`',
110+
},
111+
title: { type: 'string', description: 'Notification title; alias: `subject`' },
112+
message: { type: 'string', description: 'Notification body; alias: `body`' },
113+
channels: {
114+
type: 'array', items: { type: 'string' },
115+
description: 'Channels to fan out to (default: inbox)',
116+
},
117+
topic: { type: 'string', description: 'Event topic (default: "notify")' },
118+
severity: { type: 'string', description: 'info | warning | critical' },
119+
// ── Click-through target (#2675) ─────────────────────────
120+
sourceObject: {
121+
type: 'string',
122+
description: 'Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId.',
123+
},
124+
sourceId: {
125+
type: 'string',
126+
description: 'Record id the notification links to (writes sys_notification.source_id). Requires sourceObject. The inbox synthesizes a `/{object}/{id}` deep-link from these.',
127+
},
128+
actorId: {
129+
type: 'string',
130+
description: 'User id that caused the event (writes sys_notification.actor_id)',
131+
},
132+
url: {
133+
type: 'string',
134+
description: 'Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId. Alias: `actionUrl`.',
135+
},
136+
payload: { type: 'object', description: 'Extra template inputs merged into the notification payload' },
137+
},
138+
},
71139
}),
72140
async execute(node, variables, context) {
73141
const cfg = (node.config ?? {}) as Record<string, unknown>;
@@ -78,13 +146,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
78146
const channels = toStringList(cfg.channels);
79147
const topic = cfg.topic ? String(cfg.topic) : undefined;
80148
const severity = cfg.severity ? String(cfg.severity) : undefined;
81-
const actionUrl = cfg.actionUrl
82-
? String(interpolate(cfg.actionUrl, variables, context) ?? '')
149+
const urlCfg = cfg.actionUrl ?? cfg.url;
150+
const actionUrl = urlCfg
151+
? String(interpolate(urlCfg, variables, context) ?? '')
83152
: undefined;
84153
const payload = cfg.payload
85154
? (interpolate(cfg.payload, variables, context) as Record<string, unknown>)
86155
: undefined;
87156

157+
// Click-through target: forwarding `source` lets the messaging
158+
// service persist sys_notification.source_object/source_id and
159+
// synthesize a `/{object}/{id}` deep-link for the inbox (#2675). An
160+
// explicit `actionUrl`/`url` still wins over the synthesized link.
161+
const source = resolveSource(cfg, variables, context);
162+
const actorId = toStr(interpolate(cfg.actorId, variables, context));
163+
88164
if (!title) return { success: false, error: 'notify: title (or subject) is required' };
89165
if (recipients.length === 0) {
90166
return { success: false, error: 'notify: at least one recipient is required' };
@@ -111,6 +187,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
111187
audience: recipients,
112188
payload: { ...(payload ?? {}), title, body, url: actionUrl },
113189
severity,
190+
source,
191+
actorId,
114192
channels: channels.length ? channels : undefined,
115193
});
116194
return {

0 commit comments

Comments
 (0)