@checkstack/incident-backend 0.3.1 → 0.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 +34 -0
- package/package.json +15 -14
- package/src/index.ts +11 -8
- package/src/notifications.test.ts +183 -0
- package/src/notifications.ts +89 -0
- package/src/router.ts +82 -59
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c208a5b: ### @checkstack/incident-backend
|
|
8
|
+
|
|
9
|
+
Added notifications for incident status changes via the "Add Update" functionality:
|
|
10
|
+
|
|
11
|
+
- Notifications are now sent when an incident is reopened (status changed from resolved)
|
|
12
|
+
- Notifications are now sent when an incident status changes to any new value
|
|
13
|
+
- Notifications are now sent when an incident is resolved via addUpdate
|
|
14
|
+
- Extracted `notifyAffectedSystems` into a reusable module with proper importance logic:
|
|
15
|
+
- Resolved incidents always use "info" importance (good news)
|
|
16
|
+
- Reopened/created/updated incidents derive importance from severity
|
|
17
|
+
|
|
18
|
+
### @checkstack/maintenance-backend
|
|
19
|
+
|
|
20
|
+
Fixed missing notification in `closeMaintenance` handler - the "Close" button now sends a "completed" notification to subscribers.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- 9551fd7: Fix creator display in incident and maintenance status updates
|
|
25
|
+
|
|
26
|
+
- Show the creator's profile name instead of UUID in status updates
|
|
27
|
+
- For maintenances, now properly displays the creator name (was missing)
|
|
28
|
+
- For incidents, replaces UUID with human-readable profile name
|
|
29
|
+
- System-generated updates (automatic maintenance transitions) show no creator
|
|
30
|
+
|
|
31
|
+
- Updated dependencies [e5079e1]
|
|
32
|
+
- Updated dependencies [9551fd7]
|
|
33
|
+
- @checkstack/catalog-common@1.2.6
|
|
34
|
+
- @checkstack/incident-common@0.4.2
|
|
35
|
+
- @checkstack/catalog-backend@0.2.10
|
|
36
|
+
|
|
3
37
|
## 0.3.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -10,23 +10,24 @@
|
|
|
10
10
|
"lint:code": "eslint . --max-warnings 0"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@checkstack/backend-api": "0.5.
|
|
14
|
-
"@checkstack/incident-common": "0.4.
|
|
15
|
-
"@checkstack/catalog-common": "1.2.
|
|
16
|
-
"@checkstack/catalog-backend": "0.2.
|
|
17
|
-
"@checkstack/
|
|
18
|
-
"@checkstack/
|
|
19
|
-
"@checkstack/
|
|
20
|
-
"@checkstack/integration-
|
|
21
|
-
"@checkstack/common": "0.
|
|
13
|
+
"@checkstack/backend-api": "0.5.2",
|
|
14
|
+
"@checkstack/incident-common": "0.4.1",
|
|
15
|
+
"@checkstack/catalog-common": "1.2.5",
|
|
16
|
+
"@checkstack/catalog-backend": "0.2.9",
|
|
17
|
+
"@checkstack/auth-common": "0.5.4",
|
|
18
|
+
"@checkstack/command-backend": "0.1.8",
|
|
19
|
+
"@checkstack/signal-common": "0.1.5",
|
|
20
|
+
"@checkstack/integration-backend": "0.1.8",
|
|
21
|
+
"@checkstack/integration-common": "0.2.4",
|
|
22
|
+
"@checkstack/common": "0.6.1",
|
|
22
23
|
"drizzle-orm": "^0.45.1",
|
|
23
24
|
"zod": "^4.2.1"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
|
-
"@checkstack/drizzle-helper": "0.0.
|
|
27
|
-
"@checkstack/scripts": "0.1.
|
|
28
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
29
|
-
"@checkstack/tsconfig": "0.0.
|
|
27
|
+
"@checkstack/drizzle-helper": "0.0.3",
|
|
28
|
+
"@checkstack/scripts": "0.1.1",
|
|
29
|
+
"@checkstack/test-utils-backend": "0.1.8",
|
|
30
|
+
"@checkstack/tsconfig": "0.0.3",
|
|
30
31
|
"@orpc/server": "^1.13.2",
|
|
31
32
|
"@types/bun": "^1.0.0",
|
|
32
33
|
"drizzle-kit": "^0.31.8",
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { integrationEventExtensionPoint } from "@checkstack/integration-backend"
|
|
|
13
13
|
import { IncidentService } from "./service";
|
|
14
14
|
import { createRouter } from "./router";
|
|
15
15
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
16
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
16
17
|
import { catalogHooks } from "@checkstack/catalog-backend";
|
|
17
18
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
18
19
|
import { resolveRoute } from "@checkstack/common";
|
|
@@ -61,7 +62,7 @@ export default createBackendPlugin({
|
|
|
61
62
|
|
|
62
63
|
// Register hooks as integration events
|
|
63
64
|
const integrationEvents = env.getExtensionPoint(
|
|
64
|
-
integrationEventExtensionPoint
|
|
65
|
+
integrationEventExtensionPoint,
|
|
65
66
|
);
|
|
66
67
|
|
|
67
68
|
integrationEvents.registerEvent(
|
|
@@ -72,7 +73,7 @@ export default createBackendPlugin({
|
|
|
72
73
|
category: "Incidents",
|
|
73
74
|
payloadSchema: incidentCreatedPayloadSchema,
|
|
74
75
|
},
|
|
75
|
-
pluginMetadata
|
|
76
|
+
pluginMetadata,
|
|
76
77
|
);
|
|
77
78
|
|
|
78
79
|
integrationEvents.registerEvent(
|
|
@@ -84,7 +85,7 @@ export default createBackendPlugin({
|
|
|
84
85
|
category: "Incidents",
|
|
85
86
|
payloadSchema: incidentUpdatedPayloadSchema,
|
|
86
87
|
},
|
|
87
|
-
pluginMetadata
|
|
88
|
+
pluginMetadata,
|
|
88
89
|
);
|
|
89
90
|
|
|
90
91
|
integrationEvents.registerEvent(
|
|
@@ -95,7 +96,7 @@ export default createBackendPlugin({
|
|
|
95
96
|
category: "Incidents",
|
|
96
97
|
payloadSchema: incidentResolvedPayloadSchema,
|
|
97
98
|
},
|
|
98
|
-
pluginMetadata
|
|
99
|
+
pluginMetadata,
|
|
99
100
|
);
|
|
100
101
|
|
|
101
102
|
env.registerInit({
|
|
@@ -110,15 +111,17 @@ export default createBackendPlugin({
|
|
|
110
111
|
logger.debug("🔧 Initializing Incident Backend...");
|
|
111
112
|
|
|
112
113
|
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
114
|
+
const authClient = rpcClient.forPlugin(AuthApi);
|
|
113
115
|
|
|
114
116
|
const service = new IncidentService(
|
|
115
|
-
database as SafeDatabase<typeof schema
|
|
117
|
+
database as SafeDatabase<typeof schema>,
|
|
116
118
|
);
|
|
117
119
|
const router = createRouter(
|
|
118
120
|
service,
|
|
119
121
|
signalService,
|
|
120
122
|
catalogClient,
|
|
121
|
-
|
|
123
|
+
authClient,
|
|
124
|
+
logger,
|
|
122
125
|
);
|
|
123
126
|
rpc.registerRouter(router, incidentContract);
|
|
124
127
|
|
|
@@ -159,11 +162,11 @@ export default createBackendPlugin({
|
|
|
159
162
|
catalogHooks.systemDeleted,
|
|
160
163
|
async (payload) => {
|
|
161
164
|
logger.debug(
|
|
162
|
-
`Cleaning up incident associations for deleted system: ${payload.systemId}
|
|
165
|
+
`Cleaning up incident associations for deleted system: ${payload.systemId}`,
|
|
163
166
|
);
|
|
164
167
|
await service.removeSystemAssociations(payload.systemId);
|
|
165
168
|
},
|
|
166
|
-
{ mode: "work-queue", workerGroup: "incident-system-cleanup" }
|
|
169
|
+
{ mode: "work-queue", workerGroup: "incident-system-cleanup" },
|
|
167
170
|
);
|
|
168
171
|
|
|
169
172
|
logger.debug("✅ Incident Backend afterPluginsReady complete.");
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { notifyAffectedSystems } from "./notifications";
|
|
3
|
+
|
|
4
|
+
// Mock catalog client
|
|
5
|
+
function createMockCatalogClient() {
|
|
6
|
+
return {
|
|
7
|
+
notifySystemSubscribers: mock(() => Promise.resolve()),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Mock logger
|
|
12
|
+
function createMockLogger() {
|
|
13
|
+
return {
|
|
14
|
+
warn: mock(() => {}),
|
|
15
|
+
error: mock(() => {}),
|
|
16
|
+
info: mock(() => {}),
|
|
17
|
+
debug: mock(() => {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("notifyAffectedSystems", () => {
|
|
22
|
+
let mockCatalogClient: ReturnType<typeof createMockCatalogClient>;
|
|
23
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockCatalogClient = createMockCatalogClient();
|
|
27
|
+
mockLogger = createMockLogger();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("importance logic", () => {
|
|
31
|
+
it("should use 'info' importance for resolved action regardless of severity", async () => {
|
|
32
|
+
await notifyAffectedSystems({
|
|
33
|
+
catalogClient: mockCatalogClient as never,
|
|
34
|
+
logger: mockLogger as never,
|
|
35
|
+
incidentId: "inc-1",
|
|
36
|
+
incidentTitle: "Test Incident",
|
|
37
|
+
systemIds: ["sys-1"],
|
|
38
|
+
action: "resolved",
|
|
39
|
+
severity: "critical", // Even critical severity should be info when resolved
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
importance: "info",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should use 'critical' importance for reopened action with critical severity", async () => {
|
|
50
|
+
await notifyAffectedSystems({
|
|
51
|
+
catalogClient: mockCatalogClient as never,
|
|
52
|
+
logger: mockLogger as never,
|
|
53
|
+
incidentId: "inc-1",
|
|
54
|
+
incidentTitle: "Test Incident",
|
|
55
|
+
systemIds: ["sys-1"],
|
|
56
|
+
action: "reopened",
|
|
57
|
+
severity: "critical",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
importance: "critical",
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should use 'warning' importance for created action with major severity", async () => {
|
|
68
|
+
await notifyAffectedSystems({
|
|
69
|
+
catalogClient: mockCatalogClient as never,
|
|
70
|
+
logger: mockLogger as never,
|
|
71
|
+
incidentId: "inc-1",
|
|
72
|
+
incidentTitle: "Test Incident",
|
|
73
|
+
systemIds: ["sys-1"],
|
|
74
|
+
action: "created",
|
|
75
|
+
severity: "major",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
importance: "warning",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should use 'info' importance for updated action with minor severity", async () => {
|
|
86
|
+
await notifyAffectedSystems({
|
|
87
|
+
catalogClient: mockCatalogClient as never,
|
|
88
|
+
logger: mockLogger as never,
|
|
89
|
+
incidentId: "inc-1",
|
|
90
|
+
incidentTitle: "Test Incident",
|
|
91
|
+
systemIds: ["sys-1"],
|
|
92
|
+
action: "updated",
|
|
93
|
+
severity: "minor",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
importance: "info",
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("action text", () => {
|
|
105
|
+
it("should use 'reported' for created action", async () => {
|
|
106
|
+
await notifyAffectedSystems({
|
|
107
|
+
catalogClient: mockCatalogClient as never,
|
|
108
|
+
logger: mockLogger as never,
|
|
109
|
+
incidentId: "inc-1",
|
|
110
|
+
incidentTitle: "Test Incident",
|
|
111
|
+
systemIds: ["sys-1"],
|
|
112
|
+
action: "created",
|
|
113
|
+
severity: "minor",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
title: "Incident reported",
|
|
119
|
+
body: expect.stringContaining("reported"),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should use 'reopened' for reopened action", async () => {
|
|
125
|
+
await notifyAffectedSystems({
|
|
126
|
+
catalogClient: mockCatalogClient as never,
|
|
127
|
+
logger: mockLogger as never,
|
|
128
|
+
incidentId: "inc-1",
|
|
129
|
+
incidentTitle: "Test Incident",
|
|
130
|
+
systemIds: ["sys-1"],
|
|
131
|
+
action: "reopened",
|
|
132
|
+
severity: "minor",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
title: "Incident reopened",
|
|
138
|
+
body: expect.stringContaining("reopened"),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("system deduplication", () => {
|
|
145
|
+
it("should deduplicate system IDs", async () => {
|
|
146
|
+
await notifyAffectedSystems({
|
|
147
|
+
catalogClient: mockCatalogClient as never,
|
|
148
|
+
logger: mockLogger as never,
|
|
149
|
+
incidentId: "inc-1",
|
|
150
|
+
incidentTitle: "Test Incident",
|
|
151
|
+
systemIds: ["sys-1", "sys-1", "sys-2", "sys-2", "sys-1"],
|
|
152
|
+
action: "created",
|
|
153
|
+
severity: "minor",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Should only be called twice (for sys-1 and sys-2)
|
|
157
|
+
expect(mockCatalogClient.notifySystemSubscribers).toHaveBeenCalledTimes(
|
|
158
|
+
2,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
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"),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Should not throw
|
|
170
|
+
await notifyAffectedSystems({
|
|
171
|
+
catalogClient: mockCatalogClient as never,
|
|
172
|
+
logger: mockLogger as never,
|
|
173
|
+
incidentId: "inc-1",
|
|
174
|
+
incidentTitle: "Test Incident",
|
|
175
|
+
systemIds: ["sys-1"],
|
|
176
|
+
action: "created",
|
|
177
|
+
severity: "minor",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(mockLogger.warn).toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
+
import type { InferClient } from "@checkstack/common";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import { incidentRoutes } from "@checkstack/incident-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
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).
|
|
35
|
+
*/
|
|
36
|
+
export async function notifyAffectedSystems(props: {
|
|
37
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
38
|
+
logger: Logger;
|
|
39
|
+
incidentId: string;
|
|
40
|
+
incidentTitle: string;
|
|
41
|
+
systemIds: string[];
|
|
42
|
+
action: "created" | "updated" | "resolved" | "reopened";
|
|
43
|
+
severity: string;
|
|
44
|
+
}): Promise<void> {
|
|
45
|
+
const {
|
|
46
|
+
catalogClient,
|
|
47
|
+
logger,
|
|
48
|
+
incidentId,
|
|
49
|
+
incidentTitle,
|
|
50
|
+
systemIds,
|
|
51
|
+
action,
|
|
52
|
+
severity,
|
|
53
|
+
} = props;
|
|
54
|
+
|
|
55
|
+
const actionText = {
|
|
56
|
+
created: "reported",
|
|
57
|
+
updated: "updated",
|
|
58
|
+
resolved: "resolved",
|
|
59
|
+
reopened: "reopened",
|
|
60
|
+
}[action];
|
|
61
|
+
|
|
62
|
+
const importance = getImportance(action, severity);
|
|
63
|
+
|
|
64
|
+
const incidentDetailPath = resolveRoute(incidentRoutes.routes.detail, {
|
|
65
|
+
incidentId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Deduplicate: collect unique system IDs
|
|
69
|
+
const uniqueSystemIds = [...new Set(systemIds)];
|
|
70
|
+
|
|
71
|
+
for (const systemId of uniqueSystemIds) {
|
|
72
|
+
try {
|
|
73
|
+
await catalogClient.notifySystemSubscribers({
|
|
74
|
+
systemId,
|
|
75
|
+
title: `Incident ${actionText}`,
|
|
76
|
+
body: `Incident **"${incidentTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
77
|
+
importance,
|
|
78
|
+
action: { label: "View Incident", url: incidentDetailPath },
|
|
79
|
+
includeGroupSubscribers: true,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Log but don't fail the operation - notifications are best-effort
|
|
83
|
+
logger.warn(
|
|
84
|
+
`Failed to notify subscribers for system ${systemId}:`,
|
|
85
|
+
error,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { implement, ORPCError } from "@orpc/server";
|
|
|
2
2
|
import {
|
|
3
3
|
incidentContract,
|
|
4
4
|
INCIDENT_UPDATED,
|
|
5
|
-
incidentRoutes,
|
|
6
5
|
} from "@checkstack/incident-common";
|
|
7
6
|
import {
|
|
8
7
|
autoAuthMiddleware,
|
|
@@ -12,75 +11,57 @@ import {
|
|
|
12
11
|
import type { SignalService } from "@checkstack/signal-common";
|
|
13
12
|
import type { IncidentService } from "./service";
|
|
14
13
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
14
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
15
15
|
import type { InferClient } from "@checkstack/common";
|
|
16
|
-
import { resolveRoute } from "@checkstack/common";
|
|
17
16
|
import { incidentHooks } from "./hooks";
|
|
17
|
+
import { notifyAffectedSystems } from "./notifications";
|
|
18
|
+
import type { IncidentUpdate } from "@checkstack/incident-common";
|
|
18
19
|
|
|
19
20
|
export function createRouter(
|
|
20
21
|
service: IncidentService,
|
|
21
22
|
signalService: SignalService,
|
|
22
23
|
catalogClient: InferClient<typeof CatalogApi>,
|
|
24
|
+
authClient: InferClient<typeof AuthApi>,
|
|
23
25
|
logger: Logger,
|
|
24
26
|
) {
|
|
27
|
+
/**
|
|
28
|
+
* Resolve user IDs to profile names for a list of updates.
|
|
29
|
+
* Falls back to "Unknown User" if the user cannot be found.
|
|
30
|
+
*/
|
|
31
|
+
async function resolveUserNames(
|
|
32
|
+
updates: IncidentUpdate[],
|
|
33
|
+
): Promise<IncidentUpdate[]> {
|
|
34
|
+
const userIds = [
|
|
35
|
+
...new Set(updates.map((u) => u.createdBy).filter(Boolean)),
|
|
36
|
+
];
|
|
37
|
+
if (userIds.length === 0) return updates;
|
|
38
|
+
|
|
39
|
+
const userMap = new Map<string, string>();
|
|
40
|
+
await Promise.all(
|
|
41
|
+
userIds.map(async (userId) => {
|
|
42
|
+
try {
|
|
43
|
+
const user = await authClient.getUserById({ userId: userId! });
|
|
44
|
+
if (user?.name) {
|
|
45
|
+
userMap.set(userId!, user.name);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// User not found, skip
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return updates.map((update) => ({
|
|
54
|
+
...update,
|
|
55
|
+
createdByName: update.createdBy
|
|
56
|
+
? (userMap.get(update.createdBy) ?? undefined)
|
|
57
|
+
: undefined,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
25
61
|
const os = implement(incidentContract)
|
|
26
62
|
.$context<RpcContext>()
|
|
27
63
|
.use(autoAuthMiddleware);
|
|
28
64
|
|
|
29
|
-
/**
|
|
30
|
-
* Helper to notify subscribers of affected systems about an incident event.
|
|
31
|
-
* Each system triggers a separate notification call, but within each call
|
|
32
|
-
* the subscribers are deduplicated (system + its groups).
|
|
33
|
-
*/
|
|
34
|
-
const notifyAffectedSystems = async (props: {
|
|
35
|
-
incidentId: string;
|
|
36
|
-
incidentTitle: string;
|
|
37
|
-
systemIds: string[];
|
|
38
|
-
action: "created" | "updated" | "resolved";
|
|
39
|
-
severity: string;
|
|
40
|
-
}) => {
|
|
41
|
-
const { incidentId, incidentTitle, systemIds, action, severity } = props;
|
|
42
|
-
|
|
43
|
-
const actionText =
|
|
44
|
-
action === "created"
|
|
45
|
-
? "reported"
|
|
46
|
-
: action === "resolved"
|
|
47
|
-
? "resolved"
|
|
48
|
-
: "updated";
|
|
49
|
-
|
|
50
|
-
const importance =
|
|
51
|
-
severity === "critical"
|
|
52
|
-
? "critical"
|
|
53
|
-
: severity === "major"
|
|
54
|
-
? "warning"
|
|
55
|
-
: "info";
|
|
56
|
-
|
|
57
|
-
const incidentDetailPath = resolveRoute(incidentRoutes.routes.detail, {
|
|
58
|
-
incidentId,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Deduplicate: collect unique system IDs
|
|
62
|
-
const uniqueSystemIds = [...new Set(systemIds)];
|
|
63
|
-
|
|
64
|
-
for (const systemId of uniqueSystemIds) {
|
|
65
|
-
try {
|
|
66
|
-
await catalogClient.notifySystemSubscribers({
|
|
67
|
-
systemId,
|
|
68
|
-
title: `Incident ${actionText}`,
|
|
69
|
-
body: `Incident **"${incidentTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
70
|
-
importance: importance as "info" | "warning" | "critical",
|
|
71
|
-
action: { label: "View Incident", url: incidentDetailPath },
|
|
72
|
-
includeGroupSubscribers: true,
|
|
73
|
-
});
|
|
74
|
-
} catch (error) {
|
|
75
|
-
// Log but don't fail the operation - notifications are best-effort
|
|
76
|
-
logger.warn(
|
|
77
|
-
`Failed to notify subscribers for system ${systemId}:`,
|
|
78
|
-
error,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
65
|
return os.router({
|
|
85
66
|
listIncidents: os.listIncidents.handler(async ({ input }) => {
|
|
86
67
|
return { incidents: await service.listIncidents(input ?? {}) };
|
|
@@ -88,8 +69,13 @@ export function createRouter(
|
|
|
88
69
|
|
|
89
70
|
getIncident: os.getIncident.handler(async ({ input }) => {
|
|
90
71
|
const result = await service.getIncident(input.id);
|
|
91
|
-
|
|
92
|
-
|
|
72
|
+
if (!result) {
|
|
73
|
+
// eslint-disable-next-line unicorn/no-null -- oRPC contract requires null for missing values
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
// Resolve user names for updates
|
|
77
|
+
const updatesWithNames = await resolveUserNames(result.updates);
|
|
78
|
+
return { ...result, updates: updatesWithNames };
|
|
93
79
|
}),
|
|
94
80
|
|
|
95
81
|
getIncidentsForSystem: os.getIncidentsForSystem.handler(
|
|
@@ -141,6 +127,8 @@ export function createRouter(
|
|
|
141
127
|
|
|
142
128
|
// Send notifications to system subscribers
|
|
143
129
|
await notifyAffectedSystems({
|
|
130
|
+
catalogClient,
|
|
131
|
+
logger,
|
|
144
132
|
incidentId: result.id,
|
|
145
133
|
incidentTitle: result.title,
|
|
146
134
|
systemIds: result.systemIds,
|
|
@@ -176,6 +164,8 @@ export function createRouter(
|
|
|
176
164
|
|
|
177
165
|
// Send notifications to system subscribers
|
|
178
166
|
await notifyAffectedSystems({
|
|
167
|
+
catalogClient,
|
|
168
|
+
logger,
|
|
179
169
|
incidentId: result.id,
|
|
180
170
|
incidentTitle: result.title,
|
|
181
171
|
systemIds: result.systemIds,
|
|
@@ -189,6 +179,13 @@ export function createRouter(
|
|
|
189
179
|
addUpdate: os.addUpdate.handler(async ({ input, context }) => {
|
|
190
180
|
const userId =
|
|
191
181
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
182
|
+
|
|
183
|
+
// Get previous status before update for reopening detection
|
|
184
|
+
const previousIncident = input.statusChange
|
|
185
|
+
? await service.getIncident(input.incidentId)
|
|
186
|
+
: undefined;
|
|
187
|
+
const previousStatus = previousIncident?.status;
|
|
188
|
+
|
|
192
189
|
const result = await service.addUpdate(input, userId);
|
|
193
190
|
|
|
194
191
|
// Get incident to broadcast with correct systemIds
|
|
@@ -221,6 +218,30 @@ export function createRouter(
|
|
|
221
218
|
resolvedAt: new Date().toISOString(),
|
|
222
219
|
});
|
|
223
220
|
}
|
|
221
|
+
|
|
222
|
+
// Send notifications when status changes
|
|
223
|
+
if (input.statusChange && previousStatus !== input.statusChange) {
|
|
224
|
+
// Determine notification action based on status transition
|
|
225
|
+
let notificationAction: "resolved" | "reopened" | "updated";
|
|
226
|
+
if (input.statusChange === "resolved") {
|
|
227
|
+
notificationAction = "resolved";
|
|
228
|
+
} else if (previousStatus === "resolved") {
|
|
229
|
+
// Reopening: was resolved, now not resolved
|
|
230
|
+
notificationAction = "reopened";
|
|
231
|
+
} else {
|
|
232
|
+
notificationAction = "updated";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await notifyAffectedSystems({
|
|
236
|
+
catalogClient,
|
|
237
|
+
logger,
|
|
238
|
+
incidentId: input.incidentId,
|
|
239
|
+
incidentTitle: incident.title,
|
|
240
|
+
systemIds: incident.systemIds,
|
|
241
|
+
action: notificationAction,
|
|
242
|
+
severity: incident.severity,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
224
245
|
}
|
|
225
246
|
|
|
226
247
|
return result;
|
|
@@ -256,6 +277,8 @@ export function createRouter(
|
|
|
256
277
|
|
|
257
278
|
// Send notifications to system subscribers
|
|
258
279
|
await notifyAffectedSystems({
|
|
280
|
+
catalogClient,
|
|
281
|
+
logger,
|
|
259
282
|
incidentId: result.id,
|
|
260
283
|
incidentTitle: result.title,
|
|
261
284
|
systemIds: result.systemIds,
|