@checkstack/notification-backend 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @checkstack/notification-backend
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 41c77f4: feat(notification): Phase 9 — delivered/failed triggers + send action
8
+
9
+ - New hooks `notificationHooks.delivered` and `notificationHooks.failed`,
10
+ fired from the shared `dispatchWithAttempt` funnel so every external
11
+ delivery path (subscription fan-out + transactional send) surfaces
12
+ uniformly. Persisted attempt rows are unchanged — the hooks are
13
+ best-effort and never block dispatch.
14
+ - Triggers `notification.delivered`, `notification.failed`, both
15
+ carrying `contextKey: (p) => p.notificationId` so an automation can
16
+ resume the run that opened the notification.
17
+ - Action `notification.send` wraps the existing `userType: "service"`
18
+ `sendTransactional` RPC, so automation-driven sends honour the same
19
+ per-user strategy preferences + contact resolution as code-driven
20
+ ones. Returns a `notification.send_result` artifact (per-strategy
21
+ outcome).
22
+
23
+ Plumbing note: `createNotificationRouter` now takes a late-bound
24
+ `getDispatchHookSink` getter. `register()` constructs an empty mutable
25
+ container that the router reads on every dispatch; `afterPluginsReady`
26
+ populates it with the real `emitHook`. Until populated (e.g. in
27
+ stripped-down test harnesses) delivery proceeds without firing the
28
+ hooks — no behaviour change for existing callers.
29
+
30
+ ### Patch Changes
31
+
32
+ - Updated dependencies [e2d6f25]
33
+ - Updated dependencies [41c77f4]
34
+ - Updated dependencies [e1a2077]
35
+ - Updated dependencies [41c77f4]
36
+ - Updated dependencies [41c77f4]
37
+ - Updated dependencies [41c77f4]
38
+ - Updated dependencies [41c77f4]
39
+ - Updated dependencies [41c77f4]
40
+ - Updated dependencies [6d52276]
41
+ - Updated dependencies [6d52276]
42
+ - Updated dependencies [35bc682]
43
+ - @checkstack/automation-backend@0.2.0
44
+ - @checkstack/common@0.12.0
45
+ - @checkstack/backend-api@0.18.0
46
+ - @checkstack/auth-backend@0.4.31
47
+ - @checkstack/auth-common@0.7.2
48
+ - @checkstack/notification-common@1.2.1
49
+ - @checkstack/signal-common@0.2.5
50
+ - @checkstack/cache-api@0.3.6
51
+ - @checkstack/queue-api@0.3.6
52
+ - @checkstack/cache-utils@0.2.11
53
+
3
54
  ## 1.3.0
4
55
 
5
56
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-backend",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,12 +16,13 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@checkstack/notification-common": "1.2.0",
19
- "@checkstack/backend-api": "0.17.0",
20
- "@checkstack/cache-api": "0.3.4",
21
- "@checkstack/cache-utils": "0.2.9",
19
+ "@checkstack/backend-api": "0.17.1",
20
+ "@checkstack/automation-backend": "0.1.0",
21
+ "@checkstack/cache-api": "0.3.5",
22
+ "@checkstack/cache-utils": "0.2.10",
22
23
  "@checkstack/signal-common": "0.2.4",
23
- "@checkstack/queue-api": "0.3.4",
24
- "@checkstack/auth-backend": "0.4.29",
24
+ "@checkstack/queue-api": "0.3.5",
25
+ "@checkstack/auth-backend": "0.4.30",
25
26
  "@checkstack/auth-common": "0.7.1",
26
27
  "drizzle-orm": "^0.45.0",
27
28
  "zod": "^4.2.1",
@@ -32,7 +33,7 @@
32
33
  "@checkstack/drizzle-helper": "0.0.5",
33
34
  "@checkstack/scripts": "0.3.3",
34
35
  "@checkstack/tsconfig": "0.0.7",
35
- "@checkstack/test-utils-backend": "0.1.29",
36
+ "@checkstack/test-utils-backend": "0.1.30",
36
37
  "@types/node": "^20.0.0",
37
38
  "drizzle-kit": "^0.31.10",
38
39
  "typescript": "^5.0.0"
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Behaviour tests for the notification automation triggers + send action.
3
+ *
4
+ * The triggers wrap two new hooks fired from `dispatchWithAttempt`,
5
+ * so the surface tested here is dataclass-ish (payload validation +
6
+ * contextKey extraction).
7
+ *
8
+ * The send action delegates to `notificationClient.sendTransactional`.
9
+ * We mock the rpcClient.forPlugin(...) plumbing with a tiny shim so we
10
+ * can exercise the happy / zero-deliveries / thrown-error paths and
11
+ * lock down the artifact mapping.
12
+ */
13
+ import { describe, expect, it, mock } from "bun:test";
14
+ import type { Logger, RpcClient } from "@checkstack/backend-api";
15
+ import { createMockLogger } from "@checkstack/test-utils-backend";
16
+
17
+ import {
18
+ createNotificationActions,
19
+ notificationDeliveredTrigger,
20
+ notificationFailedTrigger,
21
+ notificationSendArtifactType,
22
+ notificationTriggers,
23
+ } from "./automations";
24
+
25
+ const logger = createMockLogger() as Logger;
26
+
27
+ const ctxBase = {
28
+ runId: "run-1",
29
+ automationId: "auto-1",
30
+ contextKey: null,
31
+ logger,
32
+ getService: async <T,>(): Promise<T> => {
33
+ throw new Error("not used");
34
+ },
35
+ };
36
+
37
+ // ─── Triggers ──────────────────────────────────────────────────────────
38
+
39
+ describe("notification triggers", () => {
40
+ it("exposes two triggers in a stable order", () => {
41
+ expect(notificationTriggers).toHaveLength(2);
42
+ expect(notificationTriggers[0]).toBe(
43
+ notificationDeliveredTrigger as (typeof notificationTriggers)[number],
44
+ );
45
+ expect(notificationTriggers[1]).toBe(
46
+ notificationFailedTrigger as (typeof notificationTriggers)[number],
47
+ );
48
+ });
49
+
50
+ it("extracts notificationId as the contextKey", () => {
51
+ const delivered = {
52
+ notificationId: "n-1",
53
+ strategyQualifiedId: "email.smtp",
54
+ durationMs: 42,
55
+ timestamp: "2026-05-29T11:00:00Z",
56
+ };
57
+ expect(notificationDeliveredTrigger.contextKey?.(delivered)).toBe("n-1");
58
+
59
+ const failed = {
60
+ notificationId: "n-2",
61
+ strategyQualifiedId: "webex.bot",
62
+ errorMessage: "401 Unauthorized",
63
+ durationMs: 12,
64
+ timestamp: "2026-05-29T11:00:00Z",
65
+ };
66
+ expect(notificationFailedTrigger.contextKey?.(failed)).toBe("n-2");
67
+ });
68
+
69
+ it("rejects payloads missing required fields", () => {
70
+ const bad = notificationFailedTrigger.payloadSchema.safeParse({
71
+ notificationId: "n-2",
72
+ strategyQualifiedId: "webex.bot",
73
+ durationMs: 12,
74
+ timestamp: "2026-05-29T11:00:00Z",
75
+ });
76
+ expect(bad.success).toBe(false);
77
+ });
78
+ });
79
+
80
+ // ─── Artifact ──────────────────────────────────────────────────────────
81
+
82
+ describe("notificationSendArtifactType", () => {
83
+ it("validates the canonical artifact shape", () => {
84
+ const ok = notificationSendArtifactType.schema.safeParse({
85
+ userId: "user-1",
86
+ deliveredCount: 2,
87
+ results: [
88
+ { strategyId: "email.smtp", success: true },
89
+ { strategyId: "webex.bot", success: false, error: "401" },
90
+ ],
91
+ });
92
+ expect(ok.success).toBe(true);
93
+ });
94
+ });
95
+
96
+ // ─── Action: notification.send ─────────────────────────────────────────
97
+
98
+ interface SendTransactionalResult {
99
+ deliveredCount: number;
100
+ results: Array<{ strategyId: string; success: boolean; error?: string }>;
101
+ }
102
+
103
+ function makeRpcClient(behaviour:
104
+ | { ok: true; result: SendTransactionalResult }
105
+ | { ok: false; error: Error },
106
+ ): RpcClient & { sendMock: ReturnType<typeof mock> } {
107
+ const sendMock = mock(async (_input: unknown) => {
108
+ if (!behaviour.ok) throw behaviour.error;
109
+ return behaviour.result;
110
+ });
111
+ return {
112
+ forPlugin: () => ({ sendTransactional: sendMock }),
113
+ sendMock,
114
+ } as unknown as RpcClient & { sendMock: ReturnType<typeof mock> };
115
+ }
116
+
117
+ describe("notification.send", () => {
118
+ it("returns success when at least one strategy delivers and emits a per-strategy artifact", async () => {
119
+ const rpcClient = makeRpcClient({
120
+ ok: true,
121
+ result: {
122
+ deliveredCount: 1,
123
+ results: [
124
+ { strategyId: "email.smtp", success: true },
125
+ { strategyId: "webex.bot", success: false, error: "401" },
126
+ ],
127
+ },
128
+ });
129
+ const [send] = createNotificationActions({ rpcClient });
130
+
131
+ const result = await send!.execute({
132
+ ...ctxBase,
133
+ consumedArtifacts: {},
134
+ config: {
135
+ userId: "user-1",
136
+ title: "Hi",
137
+ body: "Hello",
138
+ } as never,
139
+ });
140
+
141
+ expect(result.success).toBe(true);
142
+ if (!result.success) return;
143
+ expect(result.externalId).toBe("user-1");
144
+ const artifact = result.artifact as {
145
+ userId: string;
146
+ deliveredCount: number;
147
+ results: Array<{ strategyId: string; success: boolean }>;
148
+ };
149
+ expect(artifact.deliveredCount).toBe(1);
150
+ expect(artifact.results).toHaveLength(2);
151
+
152
+ // sendTransactional was called with the operator-supplied fields.
153
+ const call = rpcClient.sendMock.mock.calls[0]![0] as {
154
+ userId: string;
155
+ notification: { title: string; body: string };
156
+ };
157
+ expect(call.userId).toBe("user-1");
158
+ expect(call.notification.title).toBe("Hi");
159
+ expect(call.notification.body).toBe("Hello");
160
+ });
161
+
162
+ it("returns success=false when no strategy delivered but still emits the artifact for audit", async () => {
163
+ const rpcClient = makeRpcClient({
164
+ ok: true,
165
+ result: {
166
+ deliveredCount: 0,
167
+ results: [
168
+ { strategyId: "email.smtp", success: false, error: "no contact" },
169
+ ],
170
+ },
171
+ });
172
+ const [send] = createNotificationActions({ rpcClient });
173
+
174
+ const result = await send!.execute({
175
+ ...ctxBase,
176
+ consumedArtifacts: {},
177
+ config: {
178
+ userId: "user-1",
179
+ title: "Hi",
180
+ body: "Hello",
181
+ } as never,
182
+ });
183
+
184
+ expect(result.success).toBe(false);
185
+ if (result.success) return;
186
+ const artifact = result.artifact as { deliveredCount: number };
187
+ expect(artifact.deliveredCount).toBe(0);
188
+ });
189
+
190
+ it("forwards an action button to sendTransactional when both label + url are set", async () => {
191
+ const rpcClient = makeRpcClient({
192
+ ok: true,
193
+ result: { deliveredCount: 1, results: [] },
194
+ });
195
+ const [send] = createNotificationActions({ rpcClient });
196
+
197
+ await send!.execute({
198
+ ...ctxBase,
199
+ consumedArtifacts: {},
200
+ config: {
201
+ userId: "user-1",
202
+ title: "Hi",
203
+ body: "Hello",
204
+ actionLabel: "Open",
205
+ actionUrl: "https://example.com",
206
+ } as never,
207
+ });
208
+
209
+ const call = rpcClient.sendMock.mock.calls[0]![0] as {
210
+ notification: { action?: { label: string; url: string } };
211
+ };
212
+ expect(call.notification.action).toEqual({
213
+ label: "Open",
214
+ url: "https://example.com",
215
+ });
216
+ });
217
+
218
+ it("omits the action object when only one of label/url is set", async () => {
219
+ const rpcClient = makeRpcClient({
220
+ ok: true,
221
+ result: { deliveredCount: 1, results: [] },
222
+ });
223
+ const [send] = createNotificationActions({ rpcClient });
224
+
225
+ await send!.execute({
226
+ ...ctxBase,
227
+ consumedArtifacts: {},
228
+ config: {
229
+ userId: "user-1",
230
+ title: "Hi",
231
+ body: "Hello",
232
+ actionUrl: "https://example.com",
233
+ } as never,
234
+ });
235
+
236
+ const call = rpcClient.sendMock.mock.calls[0]![0] as {
237
+ notification: { action?: unknown };
238
+ };
239
+ expect(call.notification.action).toBeUndefined();
240
+ });
241
+
242
+ it("returns failure when sendTransactional throws", async () => {
243
+ const rpcClient = makeRpcClient({
244
+ ok: false,
245
+ error: new Error("rpc unreachable"),
246
+ });
247
+ const [send] = createNotificationActions({ rpcClient });
248
+
249
+ const result = await send!.execute({
250
+ ...ctxBase,
251
+ consumedArtifacts: {},
252
+ config: {
253
+ userId: "user-1",
254
+ title: "Hi",
255
+ body: "Hello",
256
+ } as never,
257
+ });
258
+
259
+ expect(result.success).toBe(false);
260
+ if (result.success) return;
261
+ expect(result.error).toMatch(/rpc unreachable/);
262
+ });
263
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Notification triggers + actions registered with the Automation Platform.
3
+ *
4
+ * Triggers expose the new `notificationHooks.delivered` and
5
+ * `notificationHooks.failed` events as automation entry points. The
6
+ * hooks are fired from the shared `dispatchWithAttempt` funnel so every
7
+ * external delivery path (subscription fan-out, transactional send,
8
+ * future fan-outs) surfaces uniformly.
9
+ *
10
+ * The `notification.send` action wraps the existing `sendTransactional`
11
+ * RPC — that endpoint is already `userType: "service"`, so the
12
+ * dispatch engine's rpcClient can invoke it directly. Centralising on
13
+ * `sendTransactional` means automation-driven sends honour the same
14
+ * per-user strategy preferences + contact resolution as code-driven
15
+ * ones.
16
+ */
17
+ import { z } from "zod";
18
+ import { Versioned, type RpcClient } from "@checkstack/backend-api";
19
+ import type {
20
+ ActionDefinition,
21
+ TriggerDefinition,
22
+ } from "@checkstack/automation-backend";
23
+ import { extractErrorMessage } from "@checkstack/common";
24
+ import { NotificationApi } from "@checkstack/notification-common";
25
+
26
+ import { notificationHooks } from "./hooks";
27
+
28
+ // ─── Payload schemas — match the hook payloads exactly ─────────────────
29
+
30
+ const deliveredPayloadSchema = z.object({
31
+ notificationId: z.string(),
32
+ strategyQualifiedId: z.string(),
33
+ durationMs: z.number(),
34
+ timestamp: z.string(),
35
+ });
36
+
37
+ const failedPayloadSchema = z.object({
38
+ notificationId: z.string(),
39
+ strategyQualifiedId: z.string(),
40
+ errorMessage: z.string(),
41
+ durationMs: z.number(),
42
+ timestamp: z.string(),
43
+ });
44
+
45
+ // ─── Triggers ──────────────────────────────────────────────────────────
46
+
47
+ export const notificationDeliveredTrigger: TriggerDefinition<
48
+ z.infer<typeof deliveredPayloadSchema>
49
+ > = {
50
+ id: "delivered",
51
+ displayName: "Notification Delivered",
52
+ description: "Fires when an external delivery attempt succeeded",
53
+ category: "Notifications",
54
+ icon: "BellRing",
55
+ payloadSchema: deliveredPayloadSchema,
56
+ hook: notificationHooks.delivered,
57
+ contextKey: (p) => p.notificationId,
58
+ };
59
+
60
+ export const notificationFailedTrigger: TriggerDefinition<
61
+ z.infer<typeof failedPayloadSchema>
62
+ > = {
63
+ id: "failed",
64
+ displayName: "Notification Failed",
65
+ description: "Fires when an external delivery attempt failed",
66
+ category: "Notifications",
67
+ icon: "BellOff",
68
+ payloadSchema: failedPayloadSchema,
69
+ hook: notificationHooks.failed,
70
+ contextKey: (p) => p.notificationId,
71
+ };
72
+
73
+ export const notificationTriggers: TriggerDefinition<unknown>[] = [
74
+ notificationDeliveredTrigger as TriggerDefinition<unknown>,
75
+ notificationFailedTrigger as TriggerDefinition<unknown>,
76
+ ];
77
+
78
+ // ─── Action: notification.send ─────────────────────────────────────────
79
+
80
+ const notificationSendConfigSchema = z.object({
81
+ userId: z.string().min(1).describe("Target user to notify"),
82
+ title: z.string().min(1).describe("Notification title"),
83
+ body: z.string().min(1).describe("Notification body (supports markdown)"),
84
+ importance: z
85
+ .enum(["info", "warning", "critical"])
86
+ .optional()
87
+ .describe("Severity; defaults to 'info'"),
88
+ actionLabel: z
89
+ .string()
90
+ .optional()
91
+ .describe("Optional action button label"),
92
+ actionUrl: z
93
+ .string()
94
+ .optional()
95
+ .describe("Optional action button URL"),
96
+ });
97
+
98
+ export type NotificationSendConfig = z.infer<
99
+ typeof notificationSendConfigSchema
100
+ >;
101
+
102
+ // ─── Artifact type ─────────────────────────────────────────────────────
103
+
104
+ const notificationSendArtifactSchema = z.object({
105
+ userId: z.string(),
106
+ deliveredCount: z
107
+ .number()
108
+ .describe("Number of strategies that delivered successfully"),
109
+ results: z.array(
110
+ z.object({
111
+ strategyId: z.string(),
112
+ success: z.boolean(),
113
+ error: z.string().optional(),
114
+ }),
115
+ ),
116
+ });
117
+
118
+ export type NotificationSendArtifact = z.infer<
119
+ typeof notificationSendArtifactSchema
120
+ >;
121
+
122
+ export const notificationSendArtifactType = {
123
+ id: "send_result",
124
+ displayName: "Notification Send Result",
125
+ description:
126
+ "Per-strategy outcome from a `notification.send` automation action",
127
+ schema: notificationSendArtifactSchema,
128
+ } as const;
129
+
130
+ // ─── Action factory ────────────────────────────────────────────────────
131
+
132
+ export interface NotificationActionDeps {
133
+ /**
134
+ * Plugin rpcClient. The action invokes
135
+ * `sendTransactional` (service-mode) so notification-backend's
136
+ * existing dispatch loop runs — same strategy fan-out + per-attempt
137
+ * persistence + (now) delivered/failed hook emission as a
138
+ * code-driven send.
139
+ */
140
+ rpcClient: RpcClient;
141
+ }
142
+
143
+ export function createNotificationActions(
144
+ deps: NotificationActionDeps,
145
+ ): ActionDefinition<unknown, unknown>[] {
146
+ const sendAction: ActionDefinition<
147
+ NotificationSendConfig,
148
+ NotificationSendArtifact
149
+ > = {
150
+ id: "send",
151
+ displayName: "Send Notification",
152
+ description:
153
+ "Send a transactional notification to a specific user via all enabled strategies",
154
+ category: "Notifications",
155
+ icon: "BellRing",
156
+ config: new Versioned({
157
+ version: 1,
158
+ schema: notificationSendConfigSchema,
159
+ }),
160
+ produces: "notification.send_result",
161
+ execute: async ({ config, logger }) => {
162
+ const notificationClient = deps.rpcClient.forPlugin(NotificationApi);
163
+ const action =
164
+ config.actionLabel && config.actionUrl
165
+ ? { label: config.actionLabel, url: config.actionUrl }
166
+ : undefined;
167
+ try {
168
+ const result = await notificationClient.sendTransactional({
169
+ userId: config.userId,
170
+ notification: {
171
+ title: config.title,
172
+ body: config.body,
173
+ importance: config.importance,
174
+ action,
175
+ },
176
+ });
177
+ logger.info(
178
+ `Automation sent notification to ${config.userId} (${result.deliveredCount} delivered)`,
179
+ );
180
+ return {
181
+ success: result.deliveredCount > 0,
182
+ // No single externalId — but recording the user keeps the
183
+ // run-detail UI useful when there are several send steps.
184
+ externalId: config.userId,
185
+ artifact: {
186
+ userId: config.userId,
187
+ deliveredCount: result.deliveredCount,
188
+ results: result.results,
189
+ },
190
+ };
191
+ } catch (error) {
192
+ const message = extractErrorMessage(error);
193
+ logger.error(`Notification send failed: ${message}`);
194
+ return { success: false, error: message };
195
+ }
196
+ },
197
+ };
198
+
199
+ return [sendAction as ActionDefinition<unknown, unknown>];
200
+ }
@@ -64,6 +64,29 @@ export interface SendableStrategy {
64
64
  ) => Promise<{ success: boolean; error?: string }>;
65
65
  }
66
66
 
67
+ /**
68
+ * Optional hook-emission callbacks the dispatch funnel can fire to
69
+ * surface the per-attempt outcome to other plugins (e.g. as
70
+ * automation triggers). Bound from `afterPluginsReady` where
71
+ * `emitHook` becomes available — when not provided, no hook fires
72
+ * and behaviour is unchanged.
73
+ */
74
+ export interface DispatchAttemptHookSink {
75
+ onDelivered: (event: {
76
+ notificationId: string;
77
+ strategyQualifiedId: string;
78
+ durationMs: number;
79
+ timestamp: string;
80
+ }) => Promise<void>;
81
+ onFailed: (event: {
82
+ notificationId: string;
83
+ strategyQualifiedId: string;
84
+ errorMessage: string;
85
+ durationMs: number;
86
+ timestamp: string;
87
+ }) => Promise<void>;
88
+ }
89
+
67
90
  /**
68
91
  * Invoke `strategy.send(...)` with duration measurement + best-effort
69
92
  * attempt persistence on both branches. Centralised so the same
@@ -88,14 +111,17 @@ export const dispatchWithAttempt = async ({
88
111
  strategy,
89
112
  sendContext,
90
113
  notificationId,
114
+ hookSink,
91
115
  }: {
92
116
  database: SafeDatabase<typeof schema>;
93
117
  logger: Logger;
94
118
  strategy: SendableStrategy;
95
119
  sendContext: NotificationSendContext<unknown, unknown, unknown>;
96
120
  notificationId: string;
121
+ hookSink?: DispatchAttemptHookSink;
97
122
  }): Promise<void> => {
98
123
  const startMs = performance.now();
124
+ const timestamp = new Date().toISOString();
99
125
  try {
100
126
  const result = await strategy.send(sendContext);
101
127
  const durationMs = Math.round(performance.now() - startMs);
@@ -122,8 +148,36 @@ export const dispatchWithAttempt = async ({
122
148
  durationMs,
123
149
  },
124
150
  });
151
+ // Fire the per-attempt outcome hook for automation triggers.
152
+ // Best-effort: failures here are logged but never propagated, the
153
+ // same as the persist-attempt guarantee.
154
+ if (hookSink) {
155
+ try {
156
+ const hookCall = result.success
157
+ ? hookSink.onDelivered({
158
+ notificationId,
159
+ strategyQualifiedId: strategy.qualifiedId,
160
+ durationMs,
161
+ timestamp,
162
+ })
163
+ : hookSink.onFailed({
164
+ notificationId,
165
+ strategyQualifiedId: strategy.qualifiedId,
166
+ errorMessage: result.error ?? "Strategy reported failure",
167
+ durationMs,
168
+ timestamp,
169
+ });
170
+ await hookCall;
171
+ } catch (hookError) {
172
+ logger.error(
173
+ `[external-delivery] Hook sink failed for ${strategy.qualifiedId}:`,
174
+ hookError,
175
+ );
176
+ }
177
+ }
125
178
  } catch (sendError) {
126
179
  const durationMs = Math.round(performance.now() - startMs);
180
+ const sanitisedMessage = extractErrorMessage(sendError);
127
181
  logger.error(
128
182
  `[external-delivery] Error sending via ${strategy.qualifiedId}:`,
129
183
  sendError,
@@ -138,9 +192,25 @@ export const dispatchWithAttempt = async ({
138
192
  // `extractErrorMessage` sanitises arbitrary thrown values -
139
193
  // never persist the raw error object since it may embed
140
194
  // webhook URLs / tokens via the strategy's send context.
141
- errorMessage: extractErrorMessage(sendError),
195
+ errorMessage: sanitisedMessage,
142
196
  durationMs,
143
197
  },
144
198
  });
199
+ if (hookSink) {
200
+ try {
201
+ await hookSink.onFailed({
202
+ notificationId,
203
+ strategyQualifiedId: strategy.qualifiedId,
204
+ errorMessage: sanitisedMessage,
205
+ durationMs,
206
+ timestamp,
207
+ });
208
+ } catch (hookError) {
209
+ logger.error(
210
+ `[external-delivery] Hook sink failed for ${strategy.qualifiedId}:`,
211
+ hookError,
212
+ );
213
+ }
214
+ }
145
215
  }
146
216
  };
package/src/hooks.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Notification-backend hooks for cross-plugin reaction.
5
+ *
6
+ * Emitted from the shared `dispatchWithAttempt` funnel — so every
7
+ * external delivery (whether triggered by `notifyForSubscription`,
8
+ * `sendTransactional`, or any future fan-out path) is observable via
9
+ * the same two hooks. Internal-only notifications (those not sent via
10
+ * an external strategy) don't fire these.
11
+ */
12
+ export const notificationHooks = {
13
+ /**
14
+ * Emitted when a delivery attempt succeeded for a specific strategy.
15
+ * Carries enough to correlate against the persisted attempt row
16
+ * (`notificationDeliveryAttempts.notificationId + strategyQualifiedId`)
17
+ * without forcing subscribers to round-trip the DB.
18
+ */
19
+ delivered: createHook<{
20
+ notificationId: string;
21
+ strategyQualifiedId: string;
22
+ durationMs: number;
23
+ timestamp: string;
24
+ }>("notification.delivered"),
25
+
26
+ /**
27
+ * Emitted when a delivery attempt failed (either the strategy
28
+ * returned `success: false` or `.send(...)` threw). `errorMessage`
29
+ * is the same already-sanitised string written to the attempt row.
30
+ */
31
+ failed: createHook<{
32
+ notificationId: string;
33
+ strategyQualifiedId: string;
34
+ errorMessage: string;
35
+ durationMs: number;
36
+ timestamp: string;
37
+ }>("notification.failed"),
38
+ } as const;
package/src/index.ts CHANGED
@@ -25,6 +25,18 @@ import { createNotificationCache } from "./cache";
25
25
  import { authHooks } from "@checkstack/auth-backend";
26
26
  import { createOAuthCallbackHandler } from "./oauth-callback-handler";
27
27
  import { createStrategyService } from "./strategy-service";
28
+ import {
29
+ automationActionExtensionPoint,
30
+ automationArtifactTypeExtensionPoint,
31
+ automationTriggerExtensionPoint,
32
+ } from "@checkstack/automation-backend";
33
+ import {
34
+ createNotificationActions,
35
+ notificationSendArtifactType,
36
+ notificationTriggers,
37
+ } from "./automations";
38
+ import { notificationHooks } from "./hooks";
39
+ import type { DispatchAttemptHookSink } from "./delivery-attempts";
28
40
 
29
41
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30
42
  // Extension Point
@@ -158,6 +170,23 @@ export default createBackendPlugin({
158
170
  },
159
171
  });
160
172
 
173
+ // ─── Automation Platform: triggers + artifact type ─────────────────
174
+ const automationTriggers = env.getExtensionPoint(
175
+ automationTriggerExtensionPoint,
176
+ );
177
+ for (const trigger of notificationTriggers) {
178
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
179
+ }
180
+ env
181
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
182
+ .registerArtifactType(notificationSendArtifactType, pluginMetadata);
183
+
184
+ // Late-bound hook sink. `afterPluginsReady` populates it once
185
+ // `emitHook` is available; the router reads it lazily on every
186
+ // dispatch, so until populated, delivery still works but no
187
+ // automation triggers fire.
188
+ const dispatchHookSinkRef: { current?: DispatchAttemptHookSink } = {};
189
+
161
190
  env.registerInit({
162
191
  schema,
163
192
  deps: {
@@ -206,6 +235,7 @@ export default createBackendPlugin({
206
235
  rpcClient,
207
236
  logger,
208
237
  cache,
238
+ () => dispatchHookSinkRef.current,
209
239
  );
210
240
  rpc.registerRouter(router, notificationContract);
211
241
 
@@ -220,9 +250,35 @@ export default createBackendPlugin({
220
250
 
221
251
  logger.debug("✅ Notification Backend initialized.");
222
252
  },
223
- afterPluginsReady: async ({ database, logger, onHook, emitHook }) => {
253
+ afterPluginsReady: async ({
254
+ database,
255
+ logger,
256
+ onHook,
257
+ emitHook,
258
+ rpcClient,
259
+ }) => {
224
260
  const db = database;
225
261
 
262
+ // Populate the late-bound dispatch hook sink. After this
263
+ // point every external delivery attempt also fires the
264
+ // matching automation trigger.
265
+ dispatchHookSinkRef.current = {
266
+ onDelivered: (event) =>
267
+ emitHook(notificationHooks.delivered, event),
268
+ onFailed: (event) =>
269
+ emitHook(notificationHooks.failed, event),
270
+ };
271
+
272
+ // Register automation actions now that `rpcClient` (= the
273
+ // service-mode caller for `sendTransactional`) is available
274
+ // and all other plugins have registered their access rules.
275
+ const automationActions = env.getExtensionPoint(
276
+ automationActionExtensionPoint,
277
+ );
278
+ for (const action of createNotificationActions({ rpcClient })) {
279
+ automationActions.registerAction(action, pluginMetadata);
280
+ }
281
+
226
282
  // Log registered strategies
227
283
  const strategies = strategyRegistry.getStrategies();
228
284
  logger.debug(
package/src/router.ts CHANGED
@@ -144,6 +144,19 @@ export const createNotificationRouter = (
144
144
  rpcApi: RpcClient,
145
145
  logger: Logger,
146
146
  cache: NotificationCache,
147
+ /**
148
+ * Late-bound: returns the dispatch hook sink (delivered/failed
149
+ * automation triggers). `init()` wires this with a closure on a
150
+ * mutable container; `afterPluginsReady()` populates the container
151
+ * once `emitHook` is available. Until then — and on stripped-down
152
+ * test setups — the getter returns `undefined` and hook firing is
153
+ * skipped without affecting persisted delivery attempts.
154
+ */
155
+ getDispatchHookSink: () =>
156
+ | import("./delivery-attempts").DispatchAttemptHookSink
157
+ | undefined = () => {
158
+ return;
159
+ },
147
160
  ) => {
148
161
  // Create strategy service for config management
149
162
  const strategyService: StrategyService = createStrategyService({
@@ -298,6 +311,7 @@ export const createNotificationRouter = (
298
311
  strategy,
299
312
  sendContext,
300
313
  notificationId,
314
+ hookSink: getDispatchHookSink(),
301
315
  });
302
316
  } catch (error) {
303
317
  // Log error but continue - external delivery shouldn't block in-app
package/tsconfig.json CHANGED
@@ -10,6 +10,9 @@
10
10
  {
11
11
  "path": "../auth-common"
12
12
  },
13
+ {
14
+ "path": "../automation-backend"
15
+ },
13
16
  {
14
17
  "path": "../backend-api"
15
18
  },