@checkstack/notification-backend 1.2.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 +87 -0
- package/package.json +13 -12
- package/src/automations.test.ts +263 -0
- package/src/automations.ts +200 -0
- package/src/delivery-attempts.ts +71 -1
- package/src/hooks.ts +38 -0
- package/src/index.ts +57 -1
- package/src/router.ts +14 -0
- package/src/service.ts +10 -3
- package/tsconfig.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
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
|
+
|
|
54
|
+
## 1.3.0
|
|
55
|
+
|
|
56
|
+
### Minor Changes
|
|
57
|
+
|
|
58
|
+
- ba07ae2: Quiet down notification spam on flapping systems, auto-open incidents when a check goes critical, and let operators land directly on the broken checks.
|
|
59
|
+
|
|
60
|
+
Notification policy lives **per healthcheck assignment** (one row per `system × configuration`). Different checks on the same system are fully independent — disabling a setting on one check does not affect the others. Defaults preserve existing behaviour for `suppressDeEscalations`; **auto-incident defaults to on** for new and existing assignments.
|
|
61
|
+
|
|
62
|
+
- **`suppressDeEscalations`** (off by default). When on, transitions from a worse state to a better-but-still-failing state (e.g. `unhealthy → degraded`) no longer fire a notification. Escalations and full recoveries to `healthy` are unaffected. Resolved per assignment (the just-ran check is the one driving any aggregate transition).
|
|
63
|
+
- **`autoOpenIncidentOnUnhealthy`** (on by default). Either of two independent triggers can open the auto-incident:
|
|
64
|
+
- **`sustainedUnhealthyTrigger`** (default 30 min) — opens when the check stays continuously unhealthy for the configured duration. Catches real outages.
|
|
65
|
+
- **`flappingTrigger`** (default 3 transitions in 60 min) — opens when the check flips to unhealthy that many times in the window. Catches persistent flapping where each unhealthy phase is too brief for the sustained trigger.
|
|
66
|
+
Each trigger can be individually disabled. One incident per system: triggering checks attach to an existing active auto-incident.
|
|
67
|
+
- **`useNotificationSuppression`** (on by default, only meaningful when auto-open is on). Controls whether the auto-opened incident is created with `suppressNotifications: true` — leaving this off opens the incident but still pings operators on each transition.
|
|
68
|
+
- **`skipDuringMaintenance`** (on by default). No auto-incident is opened while the system has an active maintenance window with suppression. The system is intentionally down and shouldn't trip the on-call.
|
|
69
|
+
- **`autoCloseAfterMinutes`** (default 30). Auto-close cooldown is now per-assignment and snapshotted per-incident at open time — later policy edits don't alter in-flight incidents. Setting `null` ("Never auto-close") leaves the incident for manual resolution.
|
|
70
|
+
- **Require-recovery rule.** After any auto-incident closes (manual or auto), no new auto-incident can open until the check has logged at least one healthy run. Prevents a "operator dismissed but it's still broken" loop.
|
|
71
|
+
- **Auto-close worker** ticks every 60s and resolves auto-opened incidents whose systems have been healthy for their per-row `cooldownMinutes`. Rows with `null` cooldown are skipped entirely. Per-incident: failed close attempts are logged but never abort the sweep.
|
|
72
|
+
- **`incidentResolved` hook subscriber** syncs the auto-incident mapping when an operator manually resolves the incident, so the require-recovery rule sees the close immediately.
|
|
73
|
+
- **Platform-wide defaults.** New admin RPCs `getPlatformNotificationDefaults` / `setPlatformNotificationDefaults` (under the existing `healthcheck.configuration.{read,manage}` access rules) let operators set notification policy once for the whole instance. Per-assignment rows with `notificationPolicy: null` inherit the platform defaults at read time. UI: a "Notification defaults" button in the Assignment IDE opens a modal editor. The per-assignment Notifications panel shows an inheritance banner — "Using platform defaults" (read-only) with an "Override" button, or "Custom override" with a "Use platform defaults" button to revert. The all-or-nothing model keeps the mental model simple: each assignment is either fully inherited or fully overridden.
|
|
74
|
+
- **New service-level RPCs** on the incident plugin (`createAutoIncident`, `resolveAutoIncident`) let other plugins open/close incidents without a user context. Reused by the healthcheck auto-incident flow.
|
|
75
|
+
- **Health-state notification CTA** now deep-links to `?filter=failing` on the system detail page for non-recovery transitions (label changes to "View failing checks"). The system overview gains an `All / Failing / Healthy` segmented filter wired to the same `?filter=…` param.
|
|
76
|
+
- **Notification bell badge** now counts collapse groups instead of raw rows, so the number matches what the user sees in the notifications list. Built on `COUNT(DISTINCT COALESCE(collapse_key, id))` — notifications without a collapse key still each count as one.
|
|
77
|
+
- **`statusFilter` on `getHistory` / `getDetailedHistory`** lets the run-history page and the drawer's Recent Runs panel filter to `All / Healthy / Failing` via shared pills, with the page resetting to the first page on filter change.
|
|
78
|
+
- **Pagination defaults aligned with selector options.** Several pages defaulted to a page size (5 or 20) that wasn't in the dropdown's options (`[10, 25, 50, 100]`), so the page-size `<Select>` rendered empty. The drawer's Recent Runs now defaults to 10; the Run History, History List, and Delivery Logs pages now default to 25.
|
|
79
|
+
|
|
80
|
+
Includes Drizzle migrations adding the `notification_policy` jsonb column to `system_health_checks`, plus two new tables: `health_check_unhealthy_transitions` (for threshold counting) and `health_check_auto_incidents` (for mapping back to incident ids during auto-close).
|
|
81
|
+
|
|
82
|
+
### Patch Changes
|
|
83
|
+
|
|
84
|
+
- @checkstack/backend-api@0.17.1
|
|
85
|
+
- @checkstack/auth-backend@0.4.30
|
|
86
|
+
- @checkstack/cache-api@0.3.5
|
|
87
|
+
- @checkstack/queue-api@0.3.5
|
|
88
|
+
- @checkstack/cache-utils@0.2.10
|
|
89
|
+
|
|
3
90
|
## 1.2.0
|
|
4
91
|
|
|
5
92
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -15,24 +15,25 @@
|
|
|
15
15
|
"test": "bun test"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@checkstack/notification-common": "1.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/
|
|
21
|
-
"@checkstack/cache-
|
|
22
|
-
"@checkstack/
|
|
23
|
-
"@checkstack/
|
|
24
|
-
"@checkstack/
|
|
25
|
-
"@checkstack/auth-
|
|
18
|
+
"@checkstack/notification-common": "1.2.0",
|
|
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",
|
|
23
|
+
"@checkstack/signal-common": "0.2.4",
|
|
24
|
+
"@checkstack/queue-api": "0.3.5",
|
|
25
|
+
"@checkstack/auth-backend": "0.4.30",
|
|
26
|
+
"@checkstack/auth-common": "0.7.1",
|
|
26
27
|
"drizzle-orm": "^0.45.0",
|
|
27
28
|
"zod": "^4.2.1",
|
|
28
|
-
"@checkstack/common": "0.
|
|
29
|
+
"@checkstack/common": "0.11.0",
|
|
29
30
|
"@orpc/server": "^1.13.2"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
33
|
-
"@checkstack/scripts": "0.3.
|
|
34
|
+
"@checkstack/scripts": "0.3.3",
|
|
34
35
|
"@checkstack/tsconfig": "0.0.7",
|
|
35
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
+
}
|
package/src/delivery-attempts.ts
CHANGED
|
@@ -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:
|
|
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 ({
|
|
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/src/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
2
|
-
import { eq, and, count, desc, lt } from "drizzle-orm";
|
|
2
|
+
import { eq, and, count, countDistinct, desc, lt, sql } from "drizzle-orm";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
4
|
|
|
5
5
|
// --- Internal service functions for router (not namespaced) ---
|
|
@@ -41,14 +41,21 @@ export async function getUserNotifications(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Get unread count for a user
|
|
44
|
+
* Get unread count for a user, counted by collapse group so the bell
|
|
45
|
+
* badge matches the number of cards the user actually sees in the
|
|
46
|
+
* notifications list. Notifications without a collapse key each count
|
|
47
|
+
* as their own group via `COALESCE(collapse_key, id::text)`.
|
|
45
48
|
*/
|
|
46
49
|
export async function getUnreadCount(
|
|
47
50
|
db: SafeDatabase<typeof schema>,
|
|
48
51
|
userId: string
|
|
49
52
|
): Promise<number> {
|
|
50
53
|
const result = await db
|
|
51
|
-
.select({
|
|
54
|
+
.select({
|
|
55
|
+
count: countDistinct(
|
|
56
|
+
sql`COALESCE(${schema.notifications.collapseKey}, ${schema.notifications.id}::text)`
|
|
57
|
+
),
|
|
58
|
+
})
|
|
52
59
|
.from(schema.notifications)
|
|
53
60
|
.where(
|
|
54
61
|
and(
|