diff --git a/.changeset/notify-click-through-target.md b/.changeset/notify-click-through-target.md new file mode 100644 index 0000000000..72db2a0f30 --- /dev/null +++ b/.changeset/notify-click-through-target.md @@ -0,0 +1,9 @@ +--- +'@objectstack/service-automation': minor +--- + +Flow `notify` node: support a click-through target so inbox notifications can be clicked into the related record (#2675). + +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. + +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. diff --git a/packages/services/service-automation/src/builtin/notify-node.test.ts b/packages/services/service-automation/src/builtin/notify-node.test.ts index e04822ec34..fe7a584c35 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -117,6 +117,72 @@ describe('notify (baseline node)', () => { expect(result.output).toMatchObject({ 'notify.delivered': 2 }); }); + it('forwards a click-through target via sourceObject/sourceId, interpolating the id (#2675)', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + recipients: ['user_1'], + title: 'Quote {dealName} approved', + message: 'Fill in the line items', + channels: ['inbox'], + sourceObject: 'mtc_quotation', + sourceId: '{dealId}', + })); + + const result = await engine.execute('notify_flow', { + params: { dealName: 'Acme', dealId: 'q_42' }, + } as any); + + expect(result.success).toBe(true); + expect(messaging.emitted[0]).toMatchObject({ + source: { object: 'mtc_quotation', id: 'q_42' }, + }); + }); + + it('accepts the nested source:{object,id} form and forwards actorId', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + recipients: ['user_1'], + title: 'Assigned to you', + source: { object: 'opportunity', id: '{dealId}' }, + actorId: '{dealName}', + })); + + const result = await engine.execute('notify_flow', { + params: { dealName: 'user_boss', dealId: '99' }, + } as any); + + expect(result.success).toBe(true); + expect(messaging.emitted[0]).toMatchObject({ + source: { object: 'opportunity', id: '99' }, + actorId: 'user_boss', + }); + }); + + it('drops a half-specified target (object without id) rather than emitting a dead link', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + recipients: ['user_1'], + title: 'Heads up', + sourceObject: 'opportunity', + // no sourceId + })); + + const result = await engine.execute('notify_flow'); + expect(result.success).toBe(true); + expect(messaging.emitted[0].source).toBeUndefined(); + }); + + it('accepts `url` as an alias for actionUrl', async () => { + engine.registerFlow('notify_flow', notifyFlow({ + recipients: ['user_1'], + title: 'Heads up', + url: '/opps/{dealId}', + })); + + const result = await engine.execute('notify_flow', { + params: { dealId: '7' }, + } as any); + expect(result.success).toBe(true); + expect(messaging.emitted[0].payload).toMatchObject({ url: '/opps/7' }); + }); + it('accepts a single recipient string and the subject/to aliases', async () => { engine.registerFlow('notify_flow', notifyFlow({ to: 'user_9', diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 3b5716520c..5f6532c2e5 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -1,9 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { PluginContext } from '@objectstack/core'; +import type { AutomationContext } from '@objectstack/spec/contracts'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; -import { interpolate } from './template.js'; +import { interpolate, type VariableMap } from './template.js'; /** * Structural view of `@objectstack/service-messaging`'s service (ADR-0012), @@ -33,6 +34,33 @@ function toStringList(value: unknown): string[] { return []; } +/** Coerce an interpolated config value to a non-empty trimmed string, else undefined. */ +function toStr(value: unknown): string | undefined { + if (value == null) return undefined; + const s = String(value).trim(); + return s.length > 0 ? s : undefined; +} + +/** + * Resolve the click-through target record from the node config, if any. + * + * Accepts the flat `sourceObject`/`sourceId` keys (canonical — mirrors the + * `sys_notification.source_object`/`source_id` columns) or the nested + * `source: { object, id }` form (mirrors the messaging `emit()` surface). A + * target is produced only when BOTH object and id resolve — a half-specified + * link is dropped so the inbox never renders a dead deep-link. + */ +function resolveSource( + cfg: Record, + variables: VariableMap, + context: AutomationContext, +): { object: string; id: string } | undefined { + const src = (cfg.source ?? null) as { object?: unknown; id?: unknown } | null; + const object = toStr(interpolate(cfg.sourceObject ?? src?.object, variables, context)); + const id = toStr(interpolate(cfg.sourceId ?? src?.id, variables, context)); + return object && id ? { object, id } : undefined; +} + /** * `notify` built-in node (ADR-0012) — outbound notification. * @@ -68,6 +96,46 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // emit → sys_notification_delivery), so it inherits retry/dead-letter. needsOutbox: true, paradigms: ['flow', 'approval'], + // Drives the Studio form + documents the accepted keys. Extra keys + // are still tolerated (JSON Schema allows additional properties) — + // this is discoverability, not a lockdown. + configSchema: { + // No `required` array: `recipients`/`title` each accept an alias + // (`to`/`subject`), which a strict required-check would reject. + // The node enforces "title + ≥1 recipient" at execute time. + type: 'object', + properties: { + recipients: { + description: 'Recipient user id(s) / audience selector(s); alias: `to`', + }, + title: { type: 'string', description: 'Notification title; alias: `subject`' }, + message: { type: 'string', description: 'Notification body; alias: `body`' }, + channels: { + type: 'array', items: { type: 'string' }, + description: 'Channels to fan out to (default: inbox)', + }, + topic: { type: 'string', description: 'Event topic (default: "notify")' }, + severity: { type: 'string', description: 'info | warning | critical' }, + // ── Click-through target (#2675) ───────────────────────── + sourceObject: { + type: 'string', + description: 'Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId.', + }, + sourceId: { + type: 'string', + description: 'Record id the notification links to (writes sys_notification.source_id). Requires sourceObject. The inbox synthesizes a `/{object}/{id}` deep-link from these.', + }, + actorId: { + type: 'string', + description: 'User id that caused the event (writes sys_notification.actor_id)', + }, + url: { + type: 'string', + description: 'Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId. Alias: `actionUrl`.', + }, + payload: { type: 'object', description: 'Extra template inputs merged into the notification payload' }, + }, + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; @@ -78,13 +146,21 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const channels = toStringList(cfg.channels); const topic = cfg.topic ? String(cfg.topic) : undefined; const severity = cfg.severity ? String(cfg.severity) : undefined; - const actionUrl = cfg.actionUrl - ? String(interpolate(cfg.actionUrl, variables, context) ?? '') + const urlCfg = cfg.actionUrl ?? cfg.url; + const actionUrl = urlCfg + ? String(interpolate(urlCfg, variables, context) ?? '') : undefined; const payload = cfg.payload ? (interpolate(cfg.payload, variables, context) as Record) : undefined; + // Click-through target: forwarding `source` lets the messaging + // service persist sys_notification.source_object/source_id and + // synthesize a `/{object}/{id}` deep-link for the inbox (#2675). An + // explicit `actionUrl`/`url` still wins over the synthesized link. + const source = resolveSource(cfg, variables, context); + const actorId = toStr(interpolate(cfg.actorId, variables, context)); + if (!title) return { success: false, error: 'notify: title (or subject) is required' }; if (recipients.length === 0) { return { success: false, error: 'notify: at least one recipient is required' }; @@ -111,6 +187,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) audience: recipients, payload: { ...(payload ?? {}), title, body, url: actionUrl }, severity, + source, + actorId, channels: channels.length ? channels : undefined, }); return {