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
13 changes: 13 additions & 0 deletions .changeset/30121-notify-link-defaults-to-trigger-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-automation': minor
---

feat(service-automation): a record-triggered flow's notification opens the record that started the run, without the author writing `sourceObject` and `sourceId`

Clause-②: no — no accepted key, value or exported type changes; the node's existing `sourceObject` / `sourceId` / `actionUrl` keys keep their meaning when written.
Metadata-protocol: semantic

- **Before.** A `notify` node built its click-through link only from an explicit `sourceObject` + `sourceId` pair (or an `actionUrl`). The obvious record-triggered flow — `recipients`, `title`, `message` — passed `os validate` and sent a notification whose link was empty, so a click opened the notification list instead of the record.
- **Now.** A `notify` node that writes none of `sourceObject`, `sourceId` and `actionUrl` links to the run's trigger record when the run has one: a record change (create or update), a time-relative sweep's row, or an action started on a record. The notification stores that record as its `source_object` / `source_id`, and the inbox opens `/OBJECT/ID`.
- **Unchanged.** An explicit pair or an explicit `actionUrl` wins, as before. A half-written pair is still dropped rather than defaulted. A run with no trigger record (a schedule, an inbound API post, a manual start with no record) links nowhere, as before. A delete event is not linked: its record is gone once the write lands.
- **To link elsewhere** from a record-triggered flow, write the `actionUrl` or the `sourceObject` + `sourceId` pair the notification should open.
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,86 @@ describe('notify (baseline node)', () => {
});
});
});

// A notify node that writes no link (no `sourceObject`, `sourceId` or
// `actionUrl`) links to the run's trigger record, when the run has one. The
// author's own link — a pair, an `actionUrl`, or a half-written pair — is kept
// as written, and a run with no trigger record links nowhere, as before.
describe('notify click-through defaults to the trigger record', () => {
let engine: AutomationEngine;
let messaging: ReturnType<typeof fakeMessaging>;

/** A record-change run's context: the row that triggered it, and its object. */
const recordRun = (event = 'afterInsert') =>
({ record: { id: 't_7', name: 'Pump' }, object: 'repair_ticket', event }) as any;

beforeEach(() => {
messaging = fakeMessaging();
engine = new AutomationEngine(createTestLogger());
registerNotifyNode(engine, createCtx(messaging.service));
});

it('links a record-triggered notify that names no source to the trigger record', async () => {
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Assigned to you' }));

const result = await engine.execute('notify_flow', recordRun());

expect(result.success).toBe(true);
expect(messaging.emitted[0].source).toEqual({ object: 'repair_ticket', id: 't_7' });
expect(messaging.emitted[0].payload.url).toBeUndefined();
});

it('keeps an explicit pair over the trigger record', async () => {
engine.registerFlow('notify_flow', notifyFlow({
recipients: ['user_1'],
title: 'Quote ready',
sourceObject: 'mtc_quotation',
sourceId: 'q_42',
}));

await engine.execute('notify_flow', recordRun());

expect(messaging.emitted[0].source).toEqual({ object: 'mtc_quotation', id: 'q_42' });
});

it('keeps an explicit actionUrl alone: no source is defaulted beside it', async () => {
engine.registerFlow('notify_flow', notifyFlow({
recipients: ['user_1'],
title: 'See the board',
actionUrl: '/boards/main',
}));

await engine.execute('notify_flow', recordRun());

expect(messaging.emitted[0].source).toBeUndefined();
expect(messaging.emitted[0].payload.url).toBe('/boards/main');
});

it('still drops a half-written pair rather than defaulting it', async () => {
engine.registerFlow('notify_flow', notifyFlow({
recipients: ['user_1'],
title: 'Heads up',
sourceObject: 'opportunity',
}));

await engine.execute('notify_flow', recordRun());

expect(messaging.emitted[0].source).toBeUndefined();
});

it('links nowhere on a run with no trigger record', async () => {
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Daily digest' }));

await engine.execute('notify_flow', { object: 'repair_ticket', event: 'manual' } as any);

expect(messaging.emitted[0].source).toBeUndefined();
});

it('does not link a delete event to the record it removed', async () => {
engine.registerFlow('notify_flow', notifyFlow({ recipients: ['user_1'], title: 'Ticket removed' }));

await engine.execute('notify_flow', recordRun('afterDelete'));

expect(messaging.emitted[0].source).toBeUndefined();
});
});
50 changes: 43 additions & 7 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ const SUBJECT_ORGANIZATION_READ = { context: { isSystem: true } } as const;
*
* The candidates are tried in order and the first one that answers wins:
*
* 1. the click-through record the node declares (`sourceObject` + `sourceId`)
* — what `sys_notification.source_object` / `source_id` store, and the
* 1. the click-through record — the one the node declares (`sourceObject` +
* `sourceId`), or the trigger record it defaults to when the node writes
* no link (`defaultClickThroughOf`) — what
* `sys_notification.source_object` / `source_id` store, and the
* anchor ADR-0131's ownership inventory attributes a stored notification
* by, so the producer and the inventory answer the same question the same
* way;
Expand Down Expand Up @@ -156,6 +158,35 @@ function triggerRecordOf(context: AutomationContext): RecordRef | undefined {
return object && id ? { object, id } : undefined;
}

/** The record-change events whose trigger record no longer exists, or soon will not. */
const DELETE_EVENTS: ReadonlySet<string> = new Set(['beforeDelete', 'afterDelete']);

/**
* The click-through record of a notify node that names none: the run's trigger
* record, so a record-triggered flow's notification opens the record it is
* about without the author writing `sourceObject` + `sourceId: record.id`.
*
* Only when the node leaves the whole link to the runtime — none of
* `sourceObject`, `sourceId` and `actionUrl` written. An explicit pair, an
* explicit `actionUrl`, and a half-written pair (still dropped, as before) are
* the author's word and are kept as written.
*
* Only when the run has a trigger record (`triggerRecordOf`): a record-change
* run, a time-relative sweep's row, an action started on a record. A schedule
* tick, an inbound API post and a manual start with no record have none and
* link nowhere, as before. A delete event's record is gone once the write
* lands, so it is not linked: the inbox never renders a dead link.
*/
function defaultClickThroughOf(
cfg: Pick<NotifyConfigParsed, 'sourceObject' | 'sourceId' | 'actionUrl'>,
context: AutomationContext,
trigger: RecordRef | undefined,
): RecordRef | undefined {
if (cfg.sourceObject != null || cfg.sourceId != null || cfg.actionUrl != null) return undefined;
if (typeof context.event === 'string' && DELETE_EVENTS.has(context.event)) return undefined;
return trigger;
}

/**
* The code a thrown failure carries (`err.code`, the ADR-0112 envelope's
* machine-readable half), so a step that fails on the engine's refusal names
Expand Down Expand Up @@ -851,11 +882,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
// literals, read as written.
sourceObject: {
type: 'string',
description: 'Object name of the record the notification links to (writes sys_notification.source_object) — read as written: a single-brace {token} is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link.',
description: 'Object name of the record the notification links to (writes sys_notification.source_object) — read as written: a single-brace {token} is not resolved here and is refused, as is a CEL value envelope; write the value. Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record.',
},
sourceId: {
type: 'string',
description: 'Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope evaluated per run to one, such as { dialect: \'cel\', source: \'record.id\' }; a single-brace {token} is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. The inbox synthesizes a `/{object}/{id}` deep-link from these.',
description: 'Record id the notification links to (writes sys_notification.source_id) — a string, or a CEL value envelope evaluated per run to one, such as { dialect: \'cel\', source: \'record.id\' }; a single-brace {token} is refused. Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. The inbox synthesizes a `/{object}/{id}` deep-link from these. When sourceObject, sourceId and actionUrl are all left out, a run started by a record (a record change other than a delete, a time-relative sweep, an action on a record) links to that record.',
},
actorId: {
type: 'string',
Expand All @@ -868,7 +899,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
// [#19939] A TEXT slot, rendered with {{ }} holes like `title`.
actionUrl: {
type: 'string',
description: 'Explicit click-through URL, rendered per run with {{ }} placeholders like title (e.g. /crm_case/{{ record.id }}); a single-brace {token} is refused. Overrides the link synthesized from sourceObject/sourceId.',
description: 'Explicit click-through URL, rendered per run with {{ }} placeholders like title (e.g. /crm_case/{{ record.id }}); a single-brace {token} is refused. Overrides the link synthesized from sourceObject/sourceId, and written alone keeps the notification from linking to the record that started the run.',
},
payload: {
type: 'object',
Expand Down Expand Up @@ -985,9 +1016,12 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
const sourceObject = toStr(cfg.sourceObject);
const sourceId = resolveStringSlot(engine, 'sourceId', cfg.sourceId, variables, context);
if (!sourceId.ok) return { success: false, error: sourceId.error };
// A node that writes no link at all links to the run's trigger
// record, when it has one (`defaultClickThroughOf`).
const trigger = triggerRecordOf(context);
const source = sourceObject && sourceId.value
? { object: sourceObject, id: sourceId.value }
: undefined;
: defaultClickThroughOf(cfg, context, trigger);
const actorId = toStr(cfg.actorId);

// With a `template` reference the content lives in the template
Expand Down Expand Up @@ -1089,9 +1123,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
if (!organizationId) {
const data = getData();
if (data) {
// A defaulted click-through IS the trigger record:
// asked once, not twice.
organizationId = await organizationOfRecordAbout(data, wallResolverFor(data), [
source,
triggerRecordOf(context),
source === trigger ? undefined : trigger,
]);
}
}
Expand Down
Loading