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
9 changes: 9 additions & 0 deletions .changeset/notify-click-through-target.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
84 changes: 81 additions & 3 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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<string, unknown>,
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.
*
Expand Down Expand Up @@ -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<string, unknown>;
Expand All @@ -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<string, unknown>)
: 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' };
Expand All @@ -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 {
Expand Down