@checkstack/incident-backend 0.6.0 → 1.0.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,159 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 32d52c6: feat: notification target pattern + per-spec subscriptions
8
+
9
+ Replaces the all-or-nothing catalog system/group notification model with a
10
+ platform-level target pattern. Each notification-emitting plugin declares
11
+ _subscription specs_ against typed _target_ objects exported from the
12
+ target's owning plugin (catalog ships `catalogSystemTarget` and
13
+ `catalogGroupTarget`). Notification-backend handles every per-resource
14
+ group lifecycle, parent-edge inheritance, and legacy-subscription seeding
15
+ — plugins never author groupId helpers, lifecycle hooks, or migration
16
+ code again.
17
+
18
+ **Plugin-author surface area is now ~12 lines per emitter:**
19
+
20
+ ```ts
21
+ // <plugin>-common
22
+ const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
23
+ export const fooSystemSubscription = defineSubscription({
24
+ localId: "system",
25
+ target: catalogSystemTarget,
26
+ display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
27
+ });
28
+
29
+ // <plugin>-backend register()
30
+ env.registerSubscriptionSpecs([fooSystemSubscription]);
31
+ // ^ feeds the plugin loader's dependency sorter — each spec's
32
+ // target.ownerPlugin becomes an implicit init-order dep, so this
33
+ // plugin automatically waits for catalog (the target owner) to
34
+ // finish init + afterPluginsReady before its own runs.
35
+
36
+ // <plugin>-backend afterPluginsReady
37
+ await notificationClient.registerSubscriptionSpec(
38
+ specToRegistration(fooSystemSubscription)
39
+ );
40
+ // dispatch
41
+ await notificationClient.notifyForSubscription({
42
+ specId: fooSystemSubscription.specId,
43
+ resourceKeys: [systemId],
44
+ title,
45
+ body,
46
+ importance,
47
+ action,
48
+ collapseKey,
49
+ subjects,
50
+ });
51
+
52
+ // <plugin>-frontend
53
+ createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
54
+ ```
55
+
56
+ **Migrated plugins**: anomaly, incident, maintenance, healthcheck,
57
+ dependency. Each lost its bespoke `notification-groups.ts`,
58
+ `bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
59
+ all of that is now centralized in notification-backend's
60
+ `subscription-engine`.
61
+
62
+ **Plugin loader change** (`@checkstack/backend-api`,
63
+ `@checkstack/backend`): the register-time API gains
64
+ `env.registerSubscriptionSpecs([...specs])`. The dependency sorter
65
+ walks `spec.target.ownerPlugin` for every declared spec and adds the
66
+ target owner as an init-order dependency of the emitting plugin. This
67
+ guarantees that catalog (the owner of the platform's `system` and
68
+ `group` targets) completes init + afterPluginsReady before any
69
+ emitting plugin tries to register its specs against the notification
70
+ service — no string-prefix heuristics, no manual `dependsOnPlugins`
71
+ list, no stub rows. Plugins that fail to declare their specs at
72
+ register time get a clear `Target type X is not registered. Did the
73
+ emitting plugin declare this spec via env.registerSubscriptionSpecs?`
74
+ error from the dispatcher.
75
+
76
+ **Removed** (no backwards compat):
77
+
78
+ - `catalogClient.notifySystemSubscribers` and
79
+ `catalogClient.notifyManySystemSubscribers`
80
+ - `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
81
+ as direct dispatch primitives — replaced by spec-bound
82
+ `notifyForSubscription`
83
+ - catalog's `bootstrapNotificationGroups` (replaced by
84
+ `bootstrapNotificationTargets`)
85
+
86
+ **Enforcement**: the dispatcher rejects calls referencing unregistered
87
+ specIds, specs owned by other plugins, or resourceKeys that haven't been
88
+ pushed via `upsertNotificationResource`. Display metadata for any
89
+ groupId is recoverable via the spec registry, so audit lists render
90
+ correct labels even when an emitter's frontend isn't loaded.
91
+
92
+ **Per-field anomaly mute** keeps working — it now lives inside the
93
+ generic SubscriptionRow's optional `SubControls` panel
94
+ (`AnomalyFieldMuteList`), exposed through the catalog system detail
95
+ page's notifications card.
96
+
97
+ The catalog system detail page renders a "Notifications" card hosting
98
+ `SystemNotificationSubscriptionsSlot`. The matching group surface is
99
+ not yet rendered — group-level subscriptions are wired end-to-end on
100
+ the backend; a follow-up will add the host UI.
101
+
102
+ **Migration of existing subscribers**: target types declare a
103
+ `legacyGroupIdTemplate`; on first registration of each spec,
104
+ notification-backend reads subscribers from the legacy
105
+ `catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
106
+ spec groups exactly once per (spec × resource) pair, tracked in
107
+ `subscription_migrations`. Anomaly stays opt-in (its target also
108
+ declares the template, but the user-explicit nature of the original
109
+ opt-in flow means the seeding produces the same set of subscribers
110
+ they already had).
111
+
112
+ ### Minor Changes
113
+
114
+ - 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
115
+
116
+ Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
117
+
118
+ Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
119
+
120
+ All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
121
+
122
+ ### Patch Changes
123
+
124
+ - Updated dependencies [32d52c6]
125
+ - Updated dependencies [32d52c6]
126
+ - Updated dependencies [32d52c6]
127
+ - Updated dependencies [32d52c6]
128
+ - Updated dependencies [32d52c6]
129
+ - Updated dependencies [32d52c6]
130
+ - @checkstack/integration-backend@0.1.22
131
+ - @checkstack/notification-common@1.0.0
132
+ - @checkstack/catalog-backend@1.0.0
133
+ - @checkstack/catalog-common@2.0.0
134
+ - @checkstack/incident-common@1.0.0
135
+ - @checkstack/backend-api@0.14.0
136
+ - @checkstack/auth-common@0.6.4
137
+ - @checkstack/cache-api@0.2.2
138
+ - @checkstack/command-backend@0.1.22
139
+ - @checkstack/cache-utils@0.2.2
140
+
141
+ ## 0.6.1
142
+
143
+ ### Patch Changes
144
+
145
+ - Updated dependencies [208ad71]
146
+ - @checkstack/signal-common@0.2.0
147
+ - @checkstack/incident-common@0.5.0
148
+ - @checkstack/integration-common@0.3.0
149
+ - @checkstack/backend-api@0.13.1
150
+ - @checkstack/integration-backend@0.1.21
151
+ - @checkstack/catalog-common@1.5.3
152
+ - @checkstack/catalog-backend@0.7.1
153
+ - @checkstack/cache-api@0.2.1
154
+ - @checkstack/command-backend@0.1.21
155
+ - @checkstack/cache-utils@0.2.1
156
+
3
157
  ## 0.6.0
4
158
 
5
159
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "0.6.0",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,18 +13,19 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.12.0",
17
- "@checkstack/cache-api": "0.1.0",
18
- "@checkstack/cache-utils": "0.1.0",
19
- "@checkstack/incident-common": "0.4.8",
20
- "@checkstack/catalog-common": "1.5.1",
21
- "@checkstack/catalog-backend": "0.6.1",
22
- "@checkstack/auth-common": "0.6.2",
23
- "@checkstack/command-backend": "0.1.19",
24
- "@checkstack/signal-common": "0.1.9",
25
- "@checkstack/integration-backend": "0.1.19",
26
- "@checkstack/integration-common": "0.2.8",
27
- "@checkstack/common": "0.6.5",
16
+ "@checkstack/backend-api": "0.13.1",
17
+ "@checkstack/cache-api": "0.2.1",
18
+ "@checkstack/cache-utils": "0.2.1",
19
+ "@checkstack/incident-common": "0.5.0",
20
+ "@checkstack/catalog-common": "1.5.3",
21
+ "@checkstack/catalog-backend": "0.7.1",
22
+ "@checkstack/notification-common": "0.3.0",
23
+ "@checkstack/auth-common": "0.6.3",
24
+ "@checkstack/command-backend": "0.1.21",
25
+ "@checkstack/signal-common": "0.2.0",
26
+ "@checkstack/integration-backend": "0.1.21",
27
+ "@checkstack/integration-common": "0.3.0",
28
+ "@checkstack/common": "0.7.0",
28
29
  "drizzle-orm": "^0.45.0",
29
30
  "zod": "^4.2.1",
30
31
  "@orpc/server": "^1.13.2"
@@ -32,9 +33,10 @@
32
33
  "devDependencies": {
33
34
  "@checkstack/drizzle-helper": "0.0.4",
34
35
  "@checkstack/scripts": "0.1.2",
35
- "@checkstack/test-utils-backend": "0.1.19",
36
+ "@checkstack/test-utils-backend": "0.1.21",
36
37
  "@checkstack/tsconfig": "0.0.5",
37
38
  "@types/bun": "^1.0.0",
39
+ "drizzle-kit": "^0.31.10",
38
40
  "typescript": "^5.0.0"
39
41
  }
40
42
  }
package/src/index.ts CHANGED
@@ -7,9 +7,15 @@ import {
7
7
  pluginMetadata,
8
8
  incidentContract,
9
9
  incidentRoutes,
10
+ incidentSystemSubscription,
11
+ incidentGroupSubscription,
10
12
  } from "@checkstack/incident-common";
11
13
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
12
14
  import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
15
+ import {
16
+ NotificationApi,
17
+ specToRegistration,
18
+ } from "@checkstack/notification-common";
13
19
  import { IncidentService } from "./service";
14
20
  import { createRouter } from "./router";
15
21
  import { CatalogApi } from "@checkstack/catalog-common";
@@ -60,6 +66,10 @@ export default createBackendPlugin({
60
66
  metadata: pluginMetadata,
61
67
  register(env) {
62
68
  env.registerAccessRules(incidentAccessRules);
69
+ env.registerSubscriptionSpecs([
70
+ incidentSystemSubscription,
71
+ incidentGroupSubscription,
72
+ ]);
63
73
 
64
74
  // Register hooks as integration events
65
75
  const integrationEvents = env.getExtensionPoint(
@@ -125,6 +135,7 @@ export default createBackendPlugin({
125
135
 
126
136
  const catalogClient = rpcClient.forPlugin(CatalogApi);
127
137
  const authClient = rpcClient.forPlugin(AuthApi);
138
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
128
139
 
129
140
  const service = new IncidentService(
130
141
  database as SafeDatabase<typeof schema>,
@@ -135,6 +146,7 @@ export default createBackendPlugin({
135
146
  service,
136
147
  signalService,
137
148
  catalogClient,
149
+ notificationClient,
138
150
  authClient,
139
151
  logger,
140
152
  cache,
@@ -168,12 +180,24 @@ export default createBackendPlugin({
168
180
 
169
181
  logger.debug("✅ Incident Backend initialized.");
170
182
  },
171
- // Phase 3: Subscribe to catalog events for cleanup
172
- afterPluginsReady: async ({ database, logger, onHook }) => {
183
+ // Subscribe to catalog system deletion (clean up incident
184
+ // associations) + register subscription specs. Per-system /
185
+ // per-group notification group lifecycle is fully owned by
186
+ // notification-backend now — incident never touches it.
187
+ afterPluginsReady: async ({ database, logger, onHook, rpcClient }) => {
173
188
  const typedDb = database as SafeDatabase<typeof schema>;
174
189
  const service = new IncidentService(typedDb);
190
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
191
+
192
+ await Promise.all([
193
+ notificationClient.registerSubscriptionSpec(
194
+ specToRegistration(incidentSystemSubscription),
195
+ ),
196
+ notificationClient.registerSubscriptionSpec(
197
+ specToRegistration(incidentGroupSubscription),
198
+ ),
199
+ ]);
175
200
 
176
- // Subscribe to catalog system deletion to clean up associations
177
201
  onHook(
178
202
  catalogHooks.systemDeleted,
179
203
  async (payload) => {
@@ -1,14 +1,21 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import { notifyAffectedSystems } from "./notifications";
3
+ import { incidentCollapseKey } from "@checkstack/incident-common";
3
4
 
4
- // Mock catalog client
5
5
  function createMockCatalogClient() {
6
6
  return {
7
- notifySystemSubscribers: mock(() => Promise.resolve()),
7
+ getSystemGroups: mock(() => Promise.resolve([])),
8
+ };
9
+ }
10
+
11
+ function createMockNotificationClient() {
12
+ return {
13
+ notifyForSubscription: mock(() =>
14
+ Promise.resolve({ notifiedCount: 0 }),
15
+ ),
8
16
  };
9
17
  }
10
18
 
11
- // Mock logger
12
19
  function createMockLogger() {
13
20
  return {
14
21
  warn: mock(() => {}),
@@ -20,10 +27,12 @@ function createMockLogger() {
20
27
 
21
28
  describe("notifyAffectedSystems", () => {
22
29
  let mockCatalogClient: ReturnType<typeof createMockCatalogClient>;
30
+ let mockNotificationClient: ReturnType<typeof createMockNotificationClient>;
23
31
  let mockLogger: ReturnType<typeof createMockLogger>;
24
32
 
25
33
  beforeEach(() => {
26
34
  mockCatalogClient = createMockCatalogClient();
35
+ mockNotificationClient = createMockNotificationClient();
27
36
  mockLogger = createMockLogger();
28
37
  });
29
38
 
@@ -31,15 +40,18 @@ describe("notifyAffectedSystems", () => {
31
40
  it("should use 'info' importance for resolved action regardless of severity", async () => {
32
41
  await notifyAffectedSystems({
33
42
  catalogClient: mockCatalogClient as never,
43
+ notificationClient: mockNotificationClient as never,
34
44
  logger: mockLogger as never,
35
45
  incidentId: "inc-1",
36
46
  incidentTitle: "Test Incident",
37
47
  systemIds: ["sys-1"],
38
48
  action: "resolved",
39
- severity: "critical", // Even critical severity should be info when resolved
49
+ severity: "critical",
40
50
  });
41
51
 
42
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
52
+ expect(
53
+ mockNotificationClient.notifyForSubscription,
54
+ ).toHaveBeenCalledWith(
43
55
  expect.objectContaining({
44
56
  importance: "info",
45
57
  }),
@@ -49,6 +61,7 @@ describe("notifyAffectedSystems", () => {
49
61
  it("should use 'critical' importance for reopened action with critical severity", async () => {
50
62
  await notifyAffectedSystems({
51
63
  catalogClient: mockCatalogClient as never,
64
+ notificationClient: mockNotificationClient as never,
52
65
  logger: mockLogger as never,
53
66
  incidentId: "inc-1",
54
67
  incidentTitle: "Test Incident",
@@ -57,7 +70,9 @@ describe("notifyAffectedSystems", () => {
57
70
  severity: "critical",
58
71
  });
59
72
 
60
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
73
+ expect(
74
+ mockNotificationClient.notifyForSubscription,
75
+ ).toHaveBeenCalledWith(
61
76
  expect.objectContaining({
62
77
  importance: "critical",
63
78
  }),
@@ -67,6 +82,7 @@ describe("notifyAffectedSystems", () => {
67
82
  it("should use 'warning' importance for created action with major severity", async () => {
68
83
  await notifyAffectedSystems({
69
84
  catalogClient: mockCatalogClient as never,
85
+ notificationClient: mockNotificationClient as never,
70
86
  logger: mockLogger as never,
71
87
  incidentId: "inc-1",
72
88
  incidentTitle: "Test Incident",
@@ -75,7 +91,9 @@ describe("notifyAffectedSystems", () => {
75
91
  severity: "major",
76
92
  });
77
93
 
78
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
94
+ expect(
95
+ mockNotificationClient.notifyForSubscription,
96
+ ).toHaveBeenCalledWith(
79
97
  expect.objectContaining({
80
98
  importance: "warning",
81
99
  }),
@@ -85,6 +103,7 @@ describe("notifyAffectedSystems", () => {
85
103
  it("should use 'info' importance for updated action with minor severity", async () => {
86
104
  await notifyAffectedSystems({
87
105
  catalogClient: mockCatalogClient as never,
106
+ notificationClient: mockNotificationClient as never,
88
107
  logger: mockLogger as never,
89
108
  incidentId: "inc-1",
90
109
  incidentTitle: "Test Incident",
@@ -93,7 +112,9 @@ describe("notifyAffectedSystems", () => {
93
112
  severity: "minor",
94
113
  });
95
114
 
96
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
115
+ expect(
116
+ mockNotificationClient.notifyForSubscription,
117
+ ).toHaveBeenCalledWith(
97
118
  expect.objectContaining({
98
119
  importance: "info",
99
120
  }),
@@ -102,113 +123,161 @@ describe("notifyAffectedSystems", () => {
102
123
  });
103
124
 
104
125
  describe("action text", () => {
105
- it("should use 'reported' for created action", async () => {
126
+ it("titles use the incident name and action verb (no per-system suffix)", async () => {
106
127
  await notifyAffectedSystems({
107
128
  catalogClient: mockCatalogClient as never,
129
+ notificationClient: mockNotificationClient as never,
108
130
  logger: mockLogger as never,
109
131
  incidentId: "inc-1",
110
- incidentTitle: "Test Incident",
132
+ incidentTitle: "API Outage",
111
133
  systemIds: ["sys-1"],
112
134
  action: "created",
113
135
  severity: "minor",
114
136
  });
115
137
 
116
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
138
+ expect(
139
+ mockNotificationClient.notifyForSubscription,
140
+ ).toHaveBeenCalledWith(
117
141
  expect.objectContaining({
118
- title: "Incident reported: sys-1",
142
+ title: "Incident reported: API Outage",
119
143
  body: expect.stringContaining("reported"),
120
144
  }),
121
145
  );
122
146
  });
123
147
 
124
- it("should use 'reopened' for reopened action", async () => {
148
+ it("uses 'reopened' verb on reopen", async () => {
125
149
  await notifyAffectedSystems({
126
150
  catalogClient: mockCatalogClient as never,
151
+ notificationClient: mockNotificationClient as never,
127
152
  logger: mockLogger as never,
128
153
  incidentId: "inc-1",
129
- incidentTitle: "Test Incident",
154
+ incidentTitle: "API Outage",
130
155
  systemIds: ["sys-1"],
131
156
  action: "reopened",
132
157
  severity: "minor",
133
158
  });
134
159
 
135
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
160
+ expect(
161
+ mockNotificationClient.notifyForSubscription,
162
+ ).toHaveBeenCalledWith(
136
163
  expect.objectContaining({
137
- title: "Incident reopened: sys-1",
138
- body: expect.stringContaining("reopened"),
164
+ title: "Incident reopened: API Outage",
139
165
  }),
140
166
  );
141
167
  });
142
168
  });
143
169
 
144
- describe("system deduplication", () => {
145
- it("should deduplicate system IDs", async () => {
170
+ describe("bulking", () => {
171
+ it("emits a single batched call regardless of how many affected systems", async () => {
146
172
  await notifyAffectedSystems({
147
173
  catalogClient: mockCatalogClient as never,
174
+ notificationClient: mockNotificationClient as never,
148
175
  logger: mockLogger as never,
149
176
  incidentId: "inc-1",
150
177
  incidentTitle: "Test Incident",
151
- systemIds: ["sys-1", "sys-1", "sys-2", "sys-2", "sys-1"],
178
+ systemIds: ["sys-1", "sys-2", "sys-3"],
152
179
  action: "created",
153
180
  severity: "minor",
154
181
  });
155
182
 
156
- // Should only be called twice (for sys-1 and sys-2)
157
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(
158
- 2,
183
+ expect(
184
+ mockNotificationClient.notifyForSubscription,
185
+ ).toHaveBeenCalledTimes(1);
186
+ expect(
187
+ mockNotificationClient.notifyForSubscription,
188
+ ).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ resourceKeys: ["sys-1", "sys-2", "sys-3"],
191
+ }),
159
192
  );
160
193
  });
161
- });
162
194
 
163
- describe("error handling", () => {
164
- it("should log warning but not throw when notification fails", async () => {
165
- mockCatalogClient.notifySystemSubscribers.mockRejectedValue(
166
- new Error("Network error"),
195
+ it("deduplicates repeated system ids in the input list", async () => {
196
+ await notifyAffectedSystems({
197
+ catalogClient: mockCatalogClient as never,
198
+ notificationClient: mockNotificationClient as never,
199
+ logger: mockLogger as never,
200
+ incidentId: "inc-1",
201
+ incidentTitle: "Test Incident",
202
+ systemIds: ["sys-1", "sys-1", "sys-2", "sys-2", "sys-1"],
203
+ action: "created",
204
+ severity: "minor",
205
+ });
206
+
207
+ expect(
208
+ mockNotificationClient.notifyForSubscription,
209
+ ).toHaveBeenCalledTimes(1);
210
+ expect(
211
+ mockNotificationClient.notifyForSubscription,
212
+ ).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ resourceKeys: ["sys-1", "sys-2"],
215
+ }),
167
216
  );
217
+ });
168
218
 
169
- // Should not throw
219
+ it("skips the call when there are no affected systems", async () => {
170
220
  await notifyAffectedSystems({
171
221
  catalogClient: mockCatalogClient as never,
222
+ notificationClient: mockNotificationClient as never,
172
223
  logger: mockLogger as never,
173
224
  incidentId: "inc-1",
174
225
  incidentTitle: "Test Incident",
175
- systemIds: ["sys-1"],
226
+ systemIds: [],
176
227
  action: "created",
177
228
  severity: "minor",
178
229
  });
179
230
 
180
- expect(mockLogger.warn).toHaveBeenCalled();
231
+ expect(
232
+ mockNotificationClient.notifyForSubscription,
233
+ ).not.toHaveBeenCalled();
181
234
  });
182
235
  });
183
236
 
184
- describe("system name inclusion", () => {
185
- it("should include system name in title and body when systemNames map is provided", async () => {
237
+ describe("subjects + collapseKey", () => {
238
+ it("includes one subject per affected system with name + url", async () => {
186
239
  const systemNames = new Map([
187
240
  ["sys-1", "Production Database"],
241
+ ["sys-2", "Cache Layer"],
188
242
  ]);
189
243
 
190
244
  await notifyAffectedSystems({
191
245
  catalogClient: mockCatalogClient as never,
246
+ notificationClient: mockNotificationClient as never,
192
247
  logger: mockLogger as never,
193
248
  incidentId: "inc-1",
194
249
  incidentTitle: "DB Outage",
195
- systemIds: ["sys-1"],
250
+ systemIds: ["sys-1", "sys-2"],
196
251
  systemNames,
197
252
  action: "created",
198
253
  severity: "critical",
199
254
  });
200
255
 
201
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
256
+ const call = (
257
+ mockNotificationClient.notifyForSubscription.mock
258
+ .calls[0] as unknown as [
259
+ { subjects?: Array<Record<string, unknown>> },
260
+ ]
261
+ )[0];
262
+ expect(call?.subjects).toEqual([
202
263
  expect.objectContaining({
203
- title: "Incident reported: Production Database",
204
- body: expect.stringContaining("**Production Database**"),
264
+ kind: "catalog.system", // produced by createSystemSubject
265
+ id: "sys-1",
266
+ name: "Production Database",
267
+ url: expect.stringContaining("sys-1"),
205
268
  }),
206
- );
269
+ expect.objectContaining({
270
+ kind: "catalog.system", // produced by createSystemSubject
271
+ id: "sys-2",
272
+ name: "Cache Layer",
273
+ }),
274
+ ]);
207
275
  });
208
276
 
209
- it("should fall back to systemId when systemNames map is not provided", async () => {
277
+ it("falls back to systemId for the subject name when no map is provided", async () => {
210
278
  await notifyAffectedSystems({
211
279
  catalogClient: mockCatalogClient as never,
280
+ notificationClient: mockNotificationClient as never,
212
281
  logger: mockLogger as never,
213
282
  incidentId: "inc-1",
214
283
  incidentTitle: "Test Incident",
@@ -217,13 +286,55 @@ describe("notifyAffectedSystems", () => {
217
286
  severity: "minor",
218
287
  });
219
288
 
220
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
289
+ const call = (
290
+ mockNotificationClient.notifyForSubscription.mock
291
+ .calls[0] as unknown as [
292
+ { subjects?: Array<Record<string, unknown>> },
293
+ ]
294
+ )[0];
295
+ expect(call?.subjects?.[0]?.name).toBe("sys-1");
296
+ });
297
+
298
+ it("uses a stable collapseKey derived from the incident id", async () => {
299
+ await notifyAffectedSystems({
300
+ catalogClient: mockCatalogClient as never,
301
+ notificationClient: mockNotificationClient as never,
302
+ logger: mockLogger as never,
303
+ incidentId: "inc-42",
304
+ incidentTitle: "Test Incident",
305
+ systemIds: ["sys-1"],
306
+ action: "created",
307
+ severity: "minor",
308
+ });
309
+
310
+ expect(
311
+ mockNotificationClient.notifyForSubscription,
312
+ ).toHaveBeenCalledWith(
221
313
  expect.objectContaining({
222
- title: "Incident resolved: sys-1",
223
- body: expect.stringContaining("**sys-1**"),
314
+ collapseKey: incidentCollapseKey("inc-42"),
224
315
  }),
225
316
  );
226
317
  });
227
318
  });
228
- });
229
319
 
320
+ describe("error handling", () => {
321
+ it("logs a warning but does not throw when the notify call fails", async () => {
322
+ mockNotificationClient.notifyForSubscription.mockRejectedValue(
323
+ new Error("Network error"),
324
+ );
325
+
326
+ await notifyAffectedSystems({
327
+ catalogClient: mockCatalogClient as never,
328
+ notificationClient: mockNotificationClient as never,
329
+ logger: mockLogger as never,
330
+ incidentId: "inc-1",
331
+ incidentTitle: "Test Incident",
332
+ systemIds: ["sys-1"],
333
+ action: "created",
334
+ severity: "minor",
335
+ });
336
+
337
+ expect(mockLogger.warn).toHaveBeenCalled();
338
+ });
339
+ });
340
+ });
@@ -1,40 +1,27 @@
1
- import { CatalogApi } from "@checkstack/catalog-common";
1
+ import {
2
+ CatalogApi,
3
+ catalogRoutes,
4
+ createSystemSubject,
5
+ } from "@checkstack/catalog-common";
2
6
  import type { Logger } from "@checkstack/backend-api";
3
7
  import type { InferClient } from "@checkstack/common";
4
8
  import { resolveRoute } from "@checkstack/common";
5
- import { incidentRoutes } from "@checkstack/incident-common";
9
+ import type { NotificationApi } from "@checkstack/notification-common";
10
+ import {
11
+ incidentRoutes,
12
+ incidentCollapseKey,
13
+ incidentSystemSubscription,
14
+ } from "@checkstack/incident-common";
6
15
 
7
16
  /**
8
- * Determines notification importance based on action and severity.
9
- * Resolved actions are always "info" (good news).
10
- * Other actions derive importance from severity.
11
- */
12
- function getImportance(
13
- action: "created" | "updated" | "resolved" | "reopened",
14
- severity: string,
15
- ): "info" | "warning" | "critical" {
16
- // Resolved is always good news
17
- if (action === "resolved") {
18
- return "info";
19
- }
20
-
21
- // For other actions, derive from severity
22
- if (severity === "critical") {
23
- return "critical";
24
- }
25
- if (severity === "major") {
26
- return "warning";
27
- }
28
- return "info";
29
- }
30
-
31
- /**
32
- * Helper to notify subscribers of affected systems about an incident event.
33
- * Each system triggers a separate notification call, but within each call
34
- * the subscribers are deduplicated (system + its groups).
17
+ * Send a single notification to every subscriber across the affected
18
+ * systems and their parent catalog groups. Inheritance and dedup are
19
+ * handled inside notification-backend incident only supplies the
20
+ * resource keys (system ids) and the payload.
35
21
  */
36
22
  export async function notifyAffectedSystems(props: {
37
23
  catalogClient: InferClient<typeof CatalogApi>;
24
+ notificationClient: InferClient<typeof NotificationApi>;
38
25
  logger: Logger;
39
26
  incidentId: string;
40
27
  incidentTitle: string;
@@ -44,7 +31,7 @@ export async function notifyAffectedSystems(props: {
44
31
  severity: string;
45
32
  }): Promise<void> {
46
33
  const {
47
- catalogClient,
34
+ notificationClient,
48
35
  logger,
49
36
  incidentId,
50
37
  incidentTitle,
@@ -53,6 +40,10 @@ export async function notifyAffectedSystems(props: {
53
40
  action,
54
41
  severity,
55
42
  } = props;
43
+ void props.catalogClient;
44
+
45
+ const uniqueSystemIds = [...new Set(systemIds)];
46
+ if (uniqueSystemIds.length === 0) return;
56
47
 
57
48
  const actionText = {
58
49
  created: "reported",
@@ -62,34 +53,42 @@ export async function notifyAffectedSystems(props: {
62
53
  }[action];
63
54
 
64
55
  const importance = getImportance(action, severity);
65
-
66
56
  const incidentDetailPath = resolveRoute(incidentRoutes.routes.detail, {
67
57
  incidentId,
68
58
  });
59
+ const subjects = uniqueSystemIds.map((systemId) =>
60
+ createSystemSubject({
61
+ id: systemId,
62
+ name: systemNames?.get(systemId) ?? systemId,
63
+ url: resolveRoute(catalogRoutes.routes.systemDetail, { systemId }),
64
+ }),
65
+ );
69
66
 
70
- // Deduplicate: collect unique system IDs
71
- const uniqueSystemIds = [...new Set(systemIds)];
72
-
73
- for (const systemId of uniqueSystemIds) {
74
- // Resolve system name from provided map, or fall back to systemId
75
- const systemName = systemNames?.get(systemId) ?? systemId;
76
-
77
- try {
78
- await catalogClient.notifySystemSubscribers({
79
- systemId,
80
- title: `Incident ${actionText}: ${systemName}`,
81
- body: `Incident **"${incidentTitle}"** has been ${actionText} affecting **${systemName}**.`,
82
- importance,
83
- action: { label: "View Incident", url: incidentDetailPath },
84
- includeGroupSubscribers: true,
85
- });
86
- } catch (error) {
87
- // Log but don't fail the operation - notifications are best-effort
88
- logger.warn(
89
- `Failed to notify subscribers for system ${systemId}:`,
90
- error,
91
- );
92
- }
67
+ try {
68
+ await notificationClient.notifyForSubscription({
69
+ specId: incidentSystemSubscription.specId,
70
+ resourceKeys: uniqueSystemIds,
71
+ title: `Incident ${actionText}: ${incidentTitle}`,
72
+ body: `Incident **"${incidentTitle}"** has been ${actionText}.`,
73
+ importance,
74
+ action: { label: "View Incident", url: incidentDetailPath },
75
+ collapseKey: incidentCollapseKey(incidentId),
76
+ subjects,
77
+ });
78
+ } catch (error) {
79
+ logger.warn(
80
+ `Failed to notify subscribers for incident ${incidentId}:`,
81
+ error,
82
+ );
93
83
  }
94
84
  }
95
85
 
86
+ function getImportance(
87
+ action: "created" | "updated" | "resolved" | "reopened",
88
+ severity: string,
89
+ ): "info" | "warning" | "critical" {
90
+ if (action === "resolved") return "info";
91
+ if (severity === "critical") return "critical";
92
+ if (severity === "major") return "warning";
93
+ return "info";
94
+ }
package/src/router.ts CHANGED
@@ -22,6 +22,9 @@ export function createRouter(
22
22
  service: IncidentService,
23
23
  signalService: SignalService,
24
24
  catalogClient: InferClient<typeof CatalogApi>,
25
+ notificationClient: InferClient<
26
+ typeof import("@checkstack/notification-common").NotificationApi
27
+ >,
25
28
  authClient: InferClient<typeof AuthApi>,
26
29
  logger: Logger,
27
30
  cache: IncidentCache,
@@ -175,6 +178,7 @@ export function createRouter(
175
178
  const systemNames = await resolveSystemNames(result.systemIds);
176
179
  await notifyAffectedSystems({
177
180
  catalogClient,
181
+ notificationClient,
178
182
  logger,
179
183
  incidentId: result.id,
180
184
  incidentTitle: result.title,
@@ -219,6 +223,7 @@ export function createRouter(
219
223
  const systemNames = await resolveSystemNames(result.systemIds);
220
224
  await notifyAffectedSystems({
221
225
  catalogClient,
226
+ notificationClient,
222
227
  logger,
223
228
  incidentId: result.id,
224
229
  incidentTitle: result.title,
@@ -296,6 +301,7 @@ export function createRouter(
296
301
  const systemNames = await resolveSystemNames(incident.systemIds);
297
302
  await notifyAffectedSystems({
298
303
  catalogClient,
304
+ notificationClient,
299
305
  logger,
300
306
  incidentId: input.incidentId,
301
307
  incidentTitle: incident.title,
@@ -347,6 +353,7 @@ export function createRouter(
347
353
  const systemNames = await resolveSystemNames(result.systemIds);
348
354
  await notifyAffectedSystems({
349
355
  catalogClient,
356
+ notificationClient,
350
357
  logger,
351
358
  incidentId: result.id,
352
359
  incidentTitle: result.title,