diff --git a/.changeset/30121-notify-link-defaults-to-trigger-record.md b/.changeset/30121-notify-link-defaults-to-trigger-record.md new file mode 100644 index 00000000000..d26815cbdac --- /dev/null +++ b/.changeset/30121-notify-link-defaults-to-trigger-record.md @@ -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. 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 7fd924de305..39575edef90 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -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; + + /** 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(); + }); +}); diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 4c3f89f2038..94746f6b12e 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -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; @@ -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 = 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, + 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 @@ -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', @@ -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', @@ -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 @@ -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, ]); } }