-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathnotify-node.ts
More file actions
212 lines (199 loc) · 10.4 KB
/
Copy pathnotify-node.ts
File metadata and controls
212 lines (199 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// 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, stringifyForTemplate, type VariableMap } from './template.js';
/**
* Structural view of `@objectstack/service-messaging`'s service (ADR-0012),
* declared locally so service-automation does not take a runtime dependency on
* it — mirrors the `ConnectorRegistrySurface` pattern. The `notify` node
* resolves whatever object is registered under the `messaging` service and
* dispatches through this shape; if no such service is present the node
* degrades to a no-op success.
*/
export interface MessagingServiceSurface {
emit(input: {
topic: string;
audience: string[];
payload?: Record<string, unknown>;
severity?: string;
dedupKey?: string;
source?: { object: string; id: string };
actorId?: string;
channels?: string[];
}): Promise<{ notificationId: string; delivered: number; failed: number }>;
}
/** Coerce a config value (string | string[]) into a clean string[]. */
function toStringList(value: unknown): string[] {
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
if (typeof value === 'string' && value.trim()) return [value.trim()];
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.
*
* Baseline node and the human-notification counterpart to `http`
* ("raw call") and `connector_action` ("call a registered integration"):
* `notify` hands a topic + recipients + message to the platform's messaging
* service, which fans it out across the user's channels (inbox by default).
*
* Like the CRUD nodes degrade without a data engine, `notify` degrades to a
* warning + success when no `messaging` service is registered — the capability
* simply isn't installed in that stack. Install `MessagingServicePlugin`
* (`@objectstack/service-messaging`) and the same flow starts delivering, with
* no flow edit. This is the seam that fixes the "notify drops on the floor"
* gap (#1292) once messaging is present.
*/
export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext): void {
const getMessaging = (): MessagingServiceSurface | undefined => {
try {
return ctx.getService<MessagingServiceSurface>('messaging');
} catch {
return undefined;
}
};
engine.registerNodeExecutor({
type: 'notify',
descriptor: defineActionDescriptor({
type: 'notify', version: '1.0.0', name: 'Notify',
description: 'Send an outbound notification to users via the messaging service (inbox / email / push / …).',
icon: 'bell', category: 'io', source: 'builtin',
supportsRetry: true,
// Delivery is outbox-backed inside the messaging service (ADR-0030
// 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>;
const recipients = toStringList(interpolate(cfg.recipients ?? cfg.to ?? [], variables, context));
// stringifyForTemplate (not String()): a sole-token `{$error}` resolves
// to the engine's error OBJECT, which String() would render as the
// useless `[object Object]` (#3450). Serialize it readably instead.
const title = stringifyForTemplate(interpolate(cfg.title ?? cfg.subject ?? '', variables, context));
const body = stringifyForTemplate(interpolate(cfg.message ?? cfg.body ?? '', variables, context));
const channels = toStringList(cfg.channels);
const topic = cfg.topic ? String(cfg.topic) : undefined;
const severity = cfg.severity ? String(cfg.severity) : undefined;
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' };
}
const messaging = getMessaging();
if (!messaging) {
ctx.logger.warn(
`[notify] no messaging service registered; notification "${title}" not delivered`,
);
return {
success: true,
output: { delivered: 0, failed: 0, skipped: true },
};
}
try {
// ADR-0030 single ingress: hand the messaging service a topic +
// audience + payload; it writes the L2 event and materializes
// per channel. title/body/url ride in the payload (templates in
// a later phase fall back to these).
const result = await messaging.emit({
topic: topic ?? 'notify',
audience: recipients,
payload: { ...(payload ?? {}), title, body, url: actionUrl },
severity,
source,
actorId,
channels: channels.length ? channels : undefined,
});
return {
success: true,
output: {
notificationId: result.notificationId,
delivered: result.delivered,
failed: result.failed,
},
};
} catch (err) {
return { success: false, error: `notify failed: ${(err as Error).message}` };
}
},
});
ctx.logger.info('[Notify] 1 built-in node executor registered (notify)');
}