@checkstack/maintenance-backend 0.7.1 → 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,143 @@
1
1
  # @checkstack/maintenance-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/maintenance-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
+
3
141
  ## 0.7.1
4
142
 
5
143
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "0.7.1",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,17 +13,18 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.13.0",
17
- "@checkstack/cache-api": "0.2.0",
18
- "@checkstack/cache-utils": "0.2.0",
19
- "@checkstack/maintenance-common": "0.4.11",
20
- "@checkstack/notification-common": "0.2.9",
21
- "@checkstack/catalog-common": "1.5.2",
16
+ "@checkstack/backend-api": "0.13.1",
17
+ "@checkstack/cache-api": "0.2.1",
18
+ "@checkstack/cache-utils": "0.2.1",
19
+ "@checkstack/maintenance-common": "0.5.0",
20
+ "@checkstack/notification-common": "0.3.0",
21
+ "@checkstack/catalog-common": "1.5.3",
22
+ "@checkstack/catalog-backend": "0.7.1",
22
23
  "@checkstack/auth-common": "0.6.3",
23
- "@checkstack/command-backend": "0.1.20",
24
- "@checkstack/signal-common": "0.1.10",
25
- "@checkstack/integration-backend": "0.1.20",
26
- "@checkstack/integration-common": "0.2.9",
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",
27
28
  "drizzle-orm": "^0.45.0",
28
29
  "zod": "^4.2.1",
29
30
  "@checkstack/common": "0.7.0",
@@ -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.20",
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
@@ -8,10 +8,16 @@ import {
8
8
  maintenanceContract,
9
9
  maintenanceRoutes,
10
10
  MaintenanceApi,
11
+ maintenanceSystemSubscription,
12
+ maintenanceGroupSubscription,
11
13
  } from "@checkstack/maintenance-common";
12
14
 
13
15
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
14
16
  import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
17
+ import {
18
+ NotificationApi,
19
+ specToRegistration,
20
+ } from "@checkstack/notification-common";
15
21
  import { MaintenanceService } from "./service";
16
22
  import { createRouter } from "./router";
17
23
  import { CatalogApi } from "@checkstack/catalog-common";
@@ -59,6 +65,10 @@ export default createBackendPlugin({
59
65
  metadata: pluginMetadata,
60
66
  register(env) {
61
67
  env.registerAccessRules(maintenanceAccessRules);
68
+ env.registerSubscriptionSpecs([
69
+ maintenanceSystemSubscription,
70
+ maintenanceGroupSubscription,
71
+ ]);
62
72
 
63
73
  // Register hooks as integration events
64
74
  const integrationEvents = env.getExtensionPoint(
@@ -92,6 +102,7 @@ export default createBackendPlugin({
92
102
  // Store clients for afterPluginsReady
93
103
  let catalogClient: InferClient<typeof CatalogApi>;
94
104
  let maintenanceClient: InferClient<typeof MaintenanceApi>;
105
+ let notificationClient: InferClient<typeof NotificationApi>;
95
106
 
96
107
  env.registerInit({
97
108
  schema,
@@ -115,6 +126,7 @@ export default createBackendPlugin({
115
126
 
116
127
  catalogClient = rpcClient.forPlugin(CatalogApi);
117
128
  maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
129
+ notificationClient = rpcClient.forPlugin(NotificationApi);
118
130
  const authClient = rpcClient.forPlugin(AuthApi);
119
131
 
120
132
  maintenanceService = new MaintenanceService(
@@ -125,6 +137,7 @@ export default createBackendPlugin({
125
137
  maintenanceService,
126
138
  signalService,
127
139
  catalogClient,
140
+ notificationClient,
128
141
  authClient,
129
142
  logger,
130
143
  cache,
@@ -160,6 +173,18 @@ export default createBackendPlugin({
160
173
  logger.debug("✅ Maintenance Backend initialized.");
161
174
  },
162
175
  afterPluginsReady: async ({ queueManager, logger }) => {
176
+ // Notification subscription specs. Per-resource group lifecycle
177
+ // is platform-managed by notification-backend — maintenance just
178
+ // declares the specs.
179
+ await Promise.all([
180
+ notificationClient.registerSubscriptionSpec(
181
+ specToRegistration(maintenanceSystemSubscription),
182
+ ),
183
+ notificationClient.registerSubscriptionSpec(
184
+ specToRegistration(maintenanceGroupSubscription),
185
+ ),
186
+ ]);
187
+
163
188
  // Schedule the recurring status transition check job
164
189
  const queue = queueManager.getQueue<Record<string, never>>(
165
190
  STATUS_TRANSITION_QUEUE,
@@ -1,14 +1,21 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import { notifyAffectedSystems } from "./notifications";
3
+ import { maintenanceCollapseKey } from "@checkstack/maintenance-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,119 +27,180 @@ function createMockLogger() {
20
27
 
21
28
  describe("notifyAffectedSystems (maintenance)", () => {
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
 
30
- describe("system name inclusion", () => {
31
- it("should include system name in title when systemNames map is provided", async () => {
32
- const systemNames = new Map([
33
- ["sys-1", "Production Database"],
34
- ]);
35
-
39
+ describe("title + body", () => {
40
+ it("titles use the maintenance name and action verb (no per-system suffix)", async () => {
36
41
  await notifyAffectedSystems({
37
42
  catalogClient: mockCatalogClient as never,
43
+ notificationClient: mockNotificationClient as never,
38
44
  logger: mockLogger as never,
39
45
  maintenanceId: "maint-1",
40
- maintenanceTitle: "Scheduled DB Upgrade",
46
+ maintenanceTitle: "DB Upgrade",
41
47
  systemIds: ["sys-1"],
42
- systemNames,
43
48
  action: "created",
44
49
  });
45
50
 
46
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
51
+ expect(
52
+ mockNotificationClient.notifyForSubscription,
53
+ ).toHaveBeenCalledWith(
47
54
  expect.objectContaining({
48
- title: "Maintenance scheduled: Production Database",
49
- body: expect.stringContaining("**Production Database**"),
55
+ title: "Maintenance scheduled: DB Upgrade",
56
+ body: expect.stringContaining(`**"DB Upgrade"**`),
50
57
  }),
51
58
  );
52
59
  });
60
+ });
53
61
 
54
- it("should fall back to systemId when systemNames map is not provided", async () => {
62
+ describe("action text mapping", () => {
63
+ it("uses 'scheduled' for created action", async () => {
55
64
  await notifyAffectedSystems({
56
65
  catalogClient: mockCatalogClient as never,
66
+ notificationClient: mockNotificationClient as never,
57
67
  logger: mockLogger as never,
58
68
  maintenanceId: "maint-1",
59
- maintenanceTitle: "Scheduled DB Upgrade",
69
+ maintenanceTitle: "Test",
60
70
  systemIds: ["sys-1"],
61
- action: "started",
71
+ action: "created",
62
72
  });
63
73
 
64
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
74
+ expect(
75
+ mockNotificationClient.notifyForSubscription,
76
+ ).toHaveBeenCalledWith(
65
77
  expect.objectContaining({
66
- title: "Maintenance started: sys-1",
67
- body: expect.stringContaining("**sys-1**"),
78
+ title: expect.stringContaining("scheduled"),
68
79
  }),
69
80
  );
70
81
  });
71
- });
72
82
 
73
- describe("action text mapping", () => {
74
- it("should use 'scheduled' for created action", async () => {
83
+ it("uses 'completed' for completed action", async () => {
75
84
  await notifyAffectedSystems({
76
85
  catalogClient: mockCatalogClient as never,
86
+ notificationClient: mockNotificationClient as never,
77
87
  logger: mockLogger as never,
78
88
  maintenanceId: "maint-1",
79
89
  maintenanceTitle: "Test",
80
90
  systemIds: ["sys-1"],
81
- action: "created",
91
+ action: "completed",
82
92
  });
83
93
 
84
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
94
+ expect(
95
+ mockNotificationClient.notifyForSubscription,
96
+ ).toHaveBeenCalledWith(
85
97
  expect.objectContaining({
86
- title: expect.stringContaining("scheduled"),
98
+ title: expect.stringContaining("completed"),
87
99
  }),
88
100
  );
89
101
  });
102
+ });
90
103
 
91
- it("should use 'completed' for completed action", async () => {
104
+ describe("importance", () => {
105
+ it("always uses 'info' importance", async () => {
92
106
  await notifyAffectedSystems({
93
107
  catalogClient: mockCatalogClient as never,
108
+ notificationClient: mockNotificationClient as never,
94
109
  logger: mockLogger as never,
95
110
  maintenanceId: "maint-1",
96
111
  maintenanceTitle: "Test",
97
112
  systemIds: ["sys-1"],
98
- action: "completed",
113
+ action: "started",
99
114
  });
100
115
 
101
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
116
+ expect(
117
+ mockNotificationClient.notifyForSubscription,
118
+ ).toHaveBeenCalledWith(
102
119
  expect.objectContaining({
103
- title: expect.stringContaining("completed"),
120
+ importance: "info",
104
121
  }),
105
122
  );
106
123
  });
107
124
  });
108
125
 
109
- describe("importance", () => {
110
- it("should always use 'info' importance", async () => {
126
+ describe("bulking + subjects + collapseKey", () => {
127
+ it("emits a single batched call regardless of how many affected systems", async () => {
111
128
  await notifyAffectedSystems({
112
129
  catalogClient: mockCatalogClient as never,
130
+ notificationClient: mockNotificationClient as never,
113
131
  logger: mockLogger as never,
114
132
  maintenanceId: "maint-1",
115
133
  maintenanceTitle: "Test",
134
+ systemIds: ["sys-1", "sys-2", "sys-3"],
135
+ action: "created",
136
+ });
137
+
138
+ expect(
139
+ mockNotificationClient.notifyForSubscription,
140
+ ).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it("uses a stable collapseKey derived from the maintenance id", async () => {
144
+ await notifyAffectedSystems({
145
+ catalogClient: mockCatalogClient as never,
146
+ notificationClient: mockNotificationClient as never,
147
+ logger: mockLogger as never,
148
+ maintenanceId: "maint-42",
149
+ maintenanceTitle: "Test",
116
150
  systemIds: ["sys-1"],
117
151
  action: "started",
118
152
  });
119
153
 
120
- expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
154
+ expect(
155
+ mockNotificationClient.notifyForSubscription,
156
+ ).toHaveBeenCalledWith(
121
157
  expect.objectContaining({
122
- importance: "info",
158
+ collapseKey: maintenanceCollapseKey("maint-42"),
123
159
  }),
124
160
  );
125
161
  });
162
+
163
+ it("includes one subject per affected system", async () => {
164
+ const systemNames = new Map([
165
+ ["sys-1", "API Gateway"],
166
+ ["sys-2", "Database"],
167
+ ]);
168
+
169
+ await notifyAffectedSystems({
170
+ catalogClient: mockCatalogClient as never,
171
+ notificationClient: mockNotificationClient as never,
172
+ logger: mockLogger as never,
173
+ maintenanceId: "maint-1",
174
+ maintenanceTitle: "Test",
175
+ systemIds: ["sys-1", "sys-2"],
176
+ systemNames,
177
+ action: "created",
178
+ });
179
+
180
+ const call = (
181
+ mockNotificationClient.notifyForSubscription.mock
182
+ .calls[0] as unknown as [
183
+ { subjects?: Array<Record<string, unknown>> },
184
+ ]
185
+ )[0];
186
+ expect(call?.subjects).toHaveLength(2);
187
+ expect(call?.subjects?.[0]).toMatchObject({
188
+ kind: "catalog.system",
189
+ id: "sys-1",
190
+ name: "API Gateway",
191
+ });
192
+ });
126
193
  });
127
194
 
128
195
  describe("error handling", () => {
129
- it("should log warning but not throw when notification fails", async () => {
130
- mockCatalogClient.notifySystemSubscribers.mockRejectedValue(
196
+ it("logs a warning but does not throw when the notify call fails", async () => {
197
+ mockNotificationClient.notifyForSubscription.mockRejectedValue(
131
198
  new Error("Network error"),
132
199
  );
133
200
 
134
201
  await notifyAffectedSystems({
135
202
  catalogClient: mockCatalogClient as never,
203
+ notificationClient: mockNotificationClient as never,
136
204
  logger: mockLogger as never,
137
205
  maintenanceId: "maint-1",
138
206
  maintenanceTitle: "Test",
@@ -1,16 +1,21 @@
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 { maintenanceRoutes } from "@checkstack/maintenance-common";
9
+ import type { NotificationApi } from "@checkstack/notification-common";
10
+ import {
11
+ maintenanceRoutes,
12
+ maintenanceCollapseKey,
13
+ maintenanceSystemSubscription,
14
+ } from "@checkstack/maintenance-common";
6
15
 
7
- /**
8
- * Helper to notify subscribers of affected systems about a maintenance event.
9
- * Each system triggers a separate notification call, but within each call
10
- * the subscribers are deduplicated (system + its groups).
11
- */
12
16
  export async function notifyAffectedSystems(props: {
13
17
  catalogClient: InferClient<typeof CatalogApi>;
18
+ notificationClient: InferClient<typeof NotificationApi>;
14
19
  logger: Logger;
15
20
  maintenanceId: string;
16
21
  maintenanceTitle: string;
@@ -19,7 +24,7 @@ export async function notifyAffectedSystems(props: {
19
24
  action: "created" | "updated" | "started" | "completed";
20
25
  }): Promise<void> {
21
26
  const {
22
- catalogClient,
27
+ notificationClient,
23
28
  logger,
24
29
  maintenanceId,
25
30
  maintenanceTitle,
@@ -27,6 +32,10 @@ export async function notifyAffectedSystems(props: {
27
32
  systemNames,
28
33
  action,
29
34
  } = props;
35
+ void props.catalogClient;
36
+
37
+ const uniqueSystemIds = [...new Set(systemIds)];
38
+ if (uniqueSystemIds.length === 0) return;
30
39
 
31
40
  const actionText = {
32
41
  created: "scheduled",
@@ -38,27 +47,29 @@ export async function notifyAffectedSystems(props: {
38
47
  const maintenanceDetailPath = resolveRoute(maintenanceRoutes.routes.detail, {
39
48
  maintenanceId,
40
49
  });
50
+ const subjects = uniqueSystemIds.map((systemId) =>
51
+ createSystemSubject({
52
+ id: systemId,
53
+ name: systemNames?.get(systemId) ?? systemId,
54
+ url: resolveRoute(catalogRoutes.routes.systemDetail, { systemId }),
55
+ }),
56
+ );
41
57
 
42
- for (const systemId of systemIds) {
43
- // Resolve system name from provided map, or fall back to systemId
44
- const systemName = systemNames?.get(systemId) ?? systemId;
45
-
46
- try {
47
- await catalogClient.notifySystemSubscribers({
48
- systemId,
49
- title: `Maintenance ${actionText}: ${systemName}`,
50
- body: `Maintenance **"${maintenanceTitle}"** has been ${actionText} for **${systemName}**.`,
51
- importance: "info",
52
- action: { label: "View Maintenance", url: maintenanceDetailPath },
53
- includeGroupSubscribers: true,
54
- });
55
- } catch (error) {
56
- // Log but don't fail the operation - notifications are best-effort
57
- logger.warn(
58
- `Failed to notify subscribers for system ${systemId}:`,
59
- error,
60
- );
61
- }
58
+ try {
59
+ await notificationClient.notifyForSubscription({
60
+ specId: maintenanceSystemSubscription.specId,
61
+ resourceKeys: uniqueSystemIds,
62
+ title: `Maintenance ${actionText}: ${maintenanceTitle}`,
63
+ body: `Maintenance **"${maintenanceTitle}"** has been ${actionText}.`,
64
+ importance: "info",
65
+ action: { label: "View Maintenance", url: maintenanceDetailPath },
66
+ collapseKey: maintenanceCollapseKey(maintenanceId),
67
+ subjects,
68
+ });
69
+ } catch (error) {
70
+ logger.warn(
71
+ `Failed to notify subscribers for maintenance ${maintenanceId}:`,
72
+ error,
73
+ );
62
74
  }
63
75
  }
64
-
package/src/router.ts CHANGED
@@ -22,6 +22,9 @@ export function createRouter(
22
22
  service: MaintenanceService,
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: MaintenanceCache,
@@ -171,6 +174,7 @@ export function createRouter(
171
174
  const systemNames = await resolveSystemNames(result.systemIds);
172
175
  await notifyAffectedSystems({
173
176
  catalogClient,
177
+ notificationClient,
174
178
  logger,
175
179
  maintenanceId: result.id,
176
180
  maintenanceTitle: result.title,
@@ -280,6 +284,7 @@ export function createRouter(
280
284
  const systemNames = await resolveSystemNames(maintenance.systemIds);
281
285
  await notifyAffectedSystems({
282
286
  catalogClient,
287
+ notificationClient,
283
288
  logger,
284
289
  maintenanceId: input.maintenanceId,
285
290
  maintenanceTitle: maintenance.title,
@@ -333,6 +338,7 @@ export function createRouter(
333
338
  const systemNames = await resolveSystemNames(result.systemIds);
334
339
  await notifyAffectedSystems({
335
340
  catalogClient,
341
+ notificationClient,
336
342
  logger,
337
343
  maintenanceId: result.id,
338
344
  maintenanceTitle: result.title,