@checkmate-monitor/incident-backend 0.0.2
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 +22 -0
- package/drizzle/0000_sticky_unus.sql +29 -0
- package/drizzle/meta/0000_snapshot.json +213 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +35 -0
- package/src/hooks.ts +47 -0
- package/src/index.ts +179 -0
- package/src/router.ts +265 -0
- package/src/schema.ts +70 -0
- package/src/service.ts +331 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @checkmate-monitor/incident-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [ffc28f6]
|
|
8
|
+
- Updated dependencies [4dd644d]
|
|
9
|
+
- Updated dependencies [71275dd]
|
|
10
|
+
- Updated dependencies [ae19ff6]
|
|
11
|
+
- Updated dependencies [b55fae6]
|
|
12
|
+
- Updated dependencies [b354ab3]
|
|
13
|
+
- Updated dependencies [81f3f85]
|
|
14
|
+
- @checkmate-monitor/common@0.1.0
|
|
15
|
+
- @checkmate-monitor/backend-api@1.0.0
|
|
16
|
+
- @checkmate-monitor/catalog-common@0.1.0
|
|
17
|
+
- @checkmate-monitor/incident-common@0.1.0
|
|
18
|
+
- @checkmate-monitor/integration-common@0.1.0
|
|
19
|
+
- @checkmate-monitor/signal-common@0.1.0
|
|
20
|
+
- @checkmate-monitor/catalog-backend@0.0.2
|
|
21
|
+
- @checkmate-monitor/command-backend@0.0.2
|
|
22
|
+
- @checkmate-monitor/integration-backend@0.0.2
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
CREATE TYPE "incident_severity" AS ENUM('minor', 'major', 'critical');--> statement-breakpoint
|
|
2
|
+
CREATE TYPE "incident_status" AS ENUM('investigating', 'identified', 'fixing', 'monitoring', 'resolved');--> statement-breakpoint
|
|
3
|
+
CREATE TABLE "incident_systems" (
|
|
4
|
+
"incident_id" text NOT NULL,
|
|
5
|
+
"system_id" text NOT NULL,
|
|
6
|
+
CONSTRAINT "incident_systems_incident_id_system_id_pk" PRIMARY KEY("incident_id","system_id")
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE TABLE "incident_updates" (
|
|
10
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
11
|
+
"incident_id" text NOT NULL,
|
|
12
|
+
"message" text NOT NULL,
|
|
13
|
+
"status_change" "incident_status",
|
|
14
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
15
|
+
"created_by" text
|
|
16
|
+
);
|
|
17
|
+
--> statement-breakpoint
|
|
18
|
+
CREATE TABLE "incidents" (
|
|
19
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
20
|
+
"title" text NOT NULL,
|
|
21
|
+
"description" text,
|
|
22
|
+
"status" "incident_status" DEFAULT 'investigating' NOT NULL,
|
|
23
|
+
"severity" "incident_severity" DEFAULT 'major' NOT NULL,
|
|
24
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
25
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
26
|
+
);
|
|
27
|
+
--> statement-breakpoint
|
|
28
|
+
ALTER TABLE "incident_systems" ADD CONSTRAINT "incident_systems_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
29
|
+
ALTER TABLE "incident_updates" ADD CONSTRAINT "incident_updates_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "incidents"("id") ON DELETE cascade ON UPDATE no action;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "b27ece12-64f5-454b-aef9-e0f95aeda850",
|
|
3
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.incident_systems": {
|
|
8
|
+
"name": "incident_systems",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"incident_id": {
|
|
12
|
+
"name": "incident_id",
|
|
13
|
+
"type": "text",
|
|
14
|
+
"primaryKey": false,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"system_id": {
|
|
18
|
+
"name": "system_id",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"indexes": {},
|
|
25
|
+
"foreignKeys": {
|
|
26
|
+
"incident_systems_incident_id_incidents_id_fk": {
|
|
27
|
+
"name": "incident_systems_incident_id_incidents_id_fk",
|
|
28
|
+
"tableFrom": "incident_systems",
|
|
29
|
+
"tableTo": "incidents",
|
|
30
|
+
"columnsFrom": [
|
|
31
|
+
"incident_id"
|
|
32
|
+
],
|
|
33
|
+
"columnsTo": [
|
|
34
|
+
"id"
|
|
35
|
+
],
|
|
36
|
+
"onDelete": "cascade",
|
|
37
|
+
"onUpdate": "no action"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"compositePrimaryKeys": {
|
|
41
|
+
"incident_systems_incident_id_system_id_pk": {
|
|
42
|
+
"name": "incident_systems_incident_id_system_id_pk",
|
|
43
|
+
"columns": [
|
|
44
|
+
"incident_id",
|
|
45
|
+
"system_id"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"uniqueConstraints": {},
|
|
50
|
+
"policies": {},
|
|
51
|
+
"checkConstraints": {},
|
|
52
|
+
"isRLSEnabled": false
|
|
53
|
+
},
|
|
54
|
+
"public.incident_updates": {
|
|
55
|
+
"name": "incident_updates",
|
|
56
|
+
"schema": "",
|
|
57
|
+
"columns": {
|
|
58
|
+
"id": {
|
|
59
|
+
"name": "id",
|
|
60
|
+
"type": "text",
|
|
61
|
+
"primaryKey": true,
|
|
62
|
+
"notNull": true
|
|
63
|
+
},
|
|
64
|
+
"incident_id": {
|
|
65
|
+
"name": "incident_id",
|
|
66
|
+
"type": "text",
|
|
67
|
+
"primaryKey": false,
|
|
68
|
+
"notNull": true
|
|
69
|
+
},
|
|
70
|
+
"message": {
|
|
71
|
+
"name": "message",
|
|
72
|
+
"type": "text",
|
|
73
|
+
"primaryKey": false,
|
|
74
|
+
"notNull": true
|
|
75
|
+
},
|
|
76
|
+
"status_change": {
|
|
77
|
+
"name": "status_change",
|
|
78
|
+
"type": "incident_status",
|
|
79
|
+
"typeSchema": "public",
|
|
80
|
+
"primaryKey": false,
|
|
81
|
+
"notNull": false
|
|
82
|
+
},
|
|
83
|
+
"created_at": {
|
|
84
|
+
"name": "created_at",
|
|
85
|
+
"type": "timestamp",
|
|
86
|
+
"primaryKey": false,
|
|
87
|
+
"notNull": true,
|
|
88
|
+
"default": "now()"
|
|
89
|
+
},
|
|
90
|
+
"created_by": {
|
|
91
|
+
"name": "created_by",
|
|
92
|
+
"type": "text",
|
|
93
|
+
"primaryKey": false,
|
|
94
|
+
"notNull": false
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"indexes": {},
|
|
98
|
+
"foreignKeys": {
|
|
99
|
+
"incident_updates_incident_id_incidents_id_fk": {
|
|
100
|
+
"name": "incident_updates_incident_id_incidents_id_fk",
|
|
101
|
+
"tableFrom": "incident_updates",
|
|
102
|
+
"tableTo": "incidents",
|
|
103
|
+
"columnsFrom": [
|
|
104
|
+
"incident_id"
|
|
105
|
+
],
|
|
106
|
+
"columnsTo": [
|
|
107
|
+
"id"
|
|
108
|
+
],
|
|
109
|
+
"onDelete": "cascade",
|
|
110
|
+
"onUpdate": "no action"
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"compositePrimaryKeys": {},
|
|
114
|
+
"uniqueConstraints": {},
|
|
115
|
+
"policies": {},
|
|
116
|
+
"checkConstraints": {},
|
|
117
|
+
"isRLSEnabled": false
|
|
118
|
+
},
|
|
119
|
+
"public.incidents": {
|
|
120
|
+
"name": "incidents",
|
|
121
|
+
"schema": "",
|
|
122
|
+
"columns": {
|
|
123
|
+
"id": {
|
|
124
|
+
"name": "id",
|
|
125
|
+
"type": "text",
|
|
126
|
+
"primaryKey": true,
|
|
127
|
+
"notNull": true
|
|
128
|
+
},
|
|
129
|
+
"title": {
|
|
130
|
+
"name": "title",
|
|
131
|
+
"type": "text",
|
|
132
|
+
"primaryKey": false,
|
|
133
|
+
"notNull": true
|
|
134
|
+
},
|
|
135
|
+
"description": {
|
|
136
|
+
"name": "description",
|
|
137
|
+
"type": "text",
|
|
138
|
+
"primaryKey": false,
|
|
139
|
+
"notNull": false
|
|
140
|
+
},
|
|
141
|
+
"status": {
|
|
142
|
+
"name": "status",
|
|
143
|
+
"type": "incident_status",
|
|
144
|
+
"typeSchema": "public",
|
|
145
|
+
"primaryKey": false,
|
|
146
|
+
"notNull": true,
|
|
147
|
+
"default": "'investigating'"
|
|
148
|
+
},
|
|
149
|
+
"severity": {
|
|
150
|
+
"name": "severity",
|
|
151
|
+
"type": "incident_severity",
|
|
152
|
+
"typeSchema": "public",
|
|
153
|
+
"primaryKey": false,
|
|
154
|
+
"notNull": true,
|
|
155
|
+
"default": "'major'"
|
|
156
|
+
},
|
|
157
|
+
"created_at": {
|
|
158
|
+
"name": "created_at",
|
|
159
|
+
"type": "timestamp",
|
|
160
|
+
"primaryKey": false,
|
|
161
|
+
"notNull": true,
|
|
162
|
+
"default": "now()"
|
|
163
|
+
},
|
|
164
|
+
"updated_at": {
|
|
165
|
+
"name": "updated_at",
|
|
166
|
+
"type": "timestamp",
|
|
167
|
+
"primaryKey": false,
|
|
168
|
+
"notNull": true,
|
|
169
|
+
"default": "now()"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
"indexes": {},
|
|
173
|
+
"foreignKeys": {},
|
|
174
|
+
"compositePrimaryKeys": {},
|
|
175
|
+
"uniqueConstraints": {},
|
|
176
|
+
"policies": {},
|
|
177
|
+
"checkConstraints": {},
|
|
178
|
+
"isRLSEnabled": false
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
"enums": {
|
|
182
|
+
"public.incident_severity": {
|
|
183
|
+
"name": "incident_severity",
|
|
184
|
+
"schema": "public",
|
|
185
|
+
"values": [
|
|
186
|
+
"minor",
|
|
187
|
+
"major",
|
|
188
|
+
"critical"
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
"public.incident_status": {
|
|
192
|
+
"name": "incident_status",
|
|
193
|
+
"schema": "public",
|
|
194
|
+
"values": [
|
|
195
|
+
"investigating",
|
|
196
|
+
"identified",
|
|
197
|
+
"fixing",
|
|
198
|
+
"monitoring",
|
|
199
|
+
"resolved"
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"schemas": {},
|
|
204
|
+
"sequences": {},
|
|
205
|
+
"roles": {},
|
|
206
|
+
"policies": {},
|
|
207
|
+
"views": {},
|
|
208
|
+
"_meta": {
|
|
209
|
+
"columns": {},
|
|
210
|
+
"schemas": {},
|
|
211
|
+
"tables": {}
|
|
212
|
+
}
|
|
213
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/incident-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"generate": "drizzle-kit generate",
|
|
9
|
+
"lint": "bun run lint:code",
|
|
10
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@checkmate-monitor/backend-api": "workspace:*",
|
|
14
|
+
"@checkmate-monitor/incident-common": "workspace:*",
|
|
15
|
+
"@checkmate-monitor/catalog-common": "workspace:*",
|
|
16
|
+
"@checkmate-monitor/catalog-backend": "workspace:*",
|
|
17
|
+
"@checkmate-monitor/command-backend": "workspace:*",
|
|
18
|
+
"@checkmate-monitor/signal-common": "workspace:*",
|
|
19
|
+
"@checkmate-monitor/integration-backend": "workspace:*",
|
|
20
|
+
"@checkmate-monitor/integration-common": "workspace:*",
|
|
21
|
+
"@checkmate-monitor/common": "workspace:*",
|
|
22
|
+
"drizzle-orm": "^0.45.1",
|
|
23
|
+
"zod": "^4.2.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@checkmate-monitor/drizzle-helper": "workspace:*",
|
|
27
|
+
"@checkmate-monitor/scripts": "workspace:*",
|
|
28
|
+
"@checkmate-monitor/test-utils-backend": "workspace:*",
|
|
29
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
30
|
+
"@orpc/server": "^1.13.2",
|
|
31
|
+
"@types/bun": "^1.0.0",
|
|
32
|
+
"drizzle-kit": "^0.31.8",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHook } from "@checkmate-monitor/backend-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Incident hooks for cross-plugin communication.
|
|
5
|
+
* Other plugins can subscribe to these hooks to react to incident lifecycle events.
|
|
6
|
+
*/
|
|
7
|
+
export const incidentHooks = {
|
|
8
|
+
/**
|
|
9
|
+
* Emitted when a new incident is created.
|
|
10
|
+
* Plugins can subscribe (work-queue mode) to react to new incidents.
|
|
11
|
+
*/
|
|
12
|
+
incidentCreated: createHook<{
|
|
13
|
+
incidentId: string;
|
|
14
|
+
systemIds: string[];
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
severity: string;
|
|
18
|
+
status: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
}>("incident.created"),
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Emitted when an incident is updated (info or status change).
|
|
24
|
+
* Plugins can subscribe (work-queue mode) to react to updates.
|
|
25
|
+
*/
|
|
26
|
+
incidentUpdated: createHook<{
|
|
27
|
+
incidentId: string;
|
|
28
|
+
systemIds: string[];
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
severity: string;
|
|
32
|
+
status: string;
|
|
33
|
+
statusChange?: string;
|
|
34
|
+
}>("incident.updated"),
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Emitted when an incident is resolved.
|
|
38
|
+
* Plugins can subscribe to clean up or log incident resolutions.
|
|
39
|
+
*/
|
|
40
|
+
incidentResolved: createHook<{
|
|
41
|
+
incidentId: string;
|
|
42
|
+
systemIds: string[];
|
|
43
|
+
title: string;
|
|
44
|
+
severity: string;
|
|
45
|
+
resolvedAt: string;
|
|
46
|
+
}>("incident.resolved"),
|
|
47
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as schema from "./schema";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
permissionList,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
incidentContract,
|
|
8
|
+
incidentRoutes,
|
|
9
|
+
permissions,
|
|
10
|
+
} from "@checkmate-monitor/incident-common";
|
|
11
|
+
import {
|
|
12
|
+
createBackendPlugin,
|
|
13
|
+
coreServices,
|
|
14
|
+
} from "@checkmate-monitor/backend-api";
|
|
15
|
+
import { integrationEventExtensionPoint } from "@checkmate-monitor/integration-backend";
|
|
16
|
+
import { IncidentService } from "./service";
|
|
17
|
+
import { createRouter } from "./router";
|
|
18
|
+
import { CatalogApi } from "@checkmate-monitor/catalog-common";
|
|
19
|
+
import { catalogHooks } from "@checkmate-monitor/catalog-backend";
|
|
20
|
+
import { registerSearchProvider } from "@checkmate-monitor/command-backend";
|
|
21
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
22
|
+
import { incidentHooks } from "./hooks";
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Integration Event Payload Schemas
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const incidentCreatedPayloadSchema = z.object({
|
|
29
|
+
incidentId: z.string(),
|
|
30
|
+
systemIds: z.array(z.string()),
|
|
31
|
+
title: z.string(),
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
severity: z.string(),
|
|
34
|
+
status: z.string(),
|
|
35
|
+
createdAt: z.string(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const incidentUpdatedPayloadSchema = z.object({
|
|
39
|
+
incidentId: z.string(),
|
|
40
|
+
systemIds: z.array(z.string()),
|
|
41
|
+
title: z.string(),
|
|
42
|
+
description: z.string().optional(),
|
|
43
|
+
severity: z.string(),
|
|
44
|
+
status: z.string(),
|
|
45
|
+
statusChange: z.string().optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const incidentResolvedPayloadSchema = z.object({
|
|
49
|
+
incidentId: z.string(),
|
|
50
|
+
systemIds: z.array(z.string()),
|
|
51
|
+
title: z.string(),
|
|
52
|
+
severity: z.string(),
|
|
53
|
+
resolvedAt: z.string(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Plugin Definition
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
export default createBackendPlugin({
|
|
61
|
+
metadata: pluginMetadata,
|
|
62
|
+
register(env) {
|
|
63
|
+
env.registerPermissions(permissionList);
|
|
64
|
+
|
|
65
|
+
// Register hooks as integration events
|
|
66
|
+
const integrationEvents = env.getExtensionPoint(
|
|
67
|
+
integrationEventExtensionPoint
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
integrationEvents.registerEvent(
|
|
71
|
+
{
|
|
72
|
+
hook: incidentHooks.incidentCreated,
|
|
73
|
+
displayName: "Incident Created",
|
|
74
|
+
description: "Fired when a new incident is created",
|
|
75
|
+
category: "Incidents",
|
|
76
|
+
payloadSchema: incidentCreatedPayloadSchema,
|
|
77
|
+
},
|
|
78
|
+
pluginMetadata
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
integrationEvents.registerEvent(
|
|
82
|
+
{
|
|
83
|
+
hook: incidentHooks.incidentUpdated,
|
|
84
|
+
displayName: "Incident Updated",
|
|
85
|
+
description:
|
|
86
|
+
"Fired when an incident is updated (info or status change)",
|
|
87
|
+
category: "Incidents",
|
|
88
|
+
payloadSchema: incidentUpdatedPayloadSchema,
|
|
89
|
+
},
|
|
90
|
+
pluginMetadata
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
integrationEvents.registerEvent(
|
|
94
|
+
{
|
|
95
|
+
hook: incidentHooks.incidentResolved,
|
|
96
|
+
displayName: "Incident Resolved",
|
|
97
|
+
description: "Fired when an incident is marked as resolved",
|
|
98
|
+
category: "Incidents",
|
|
99
|
+
payloadSchema: incidentResolvedPayloadSchema,
|
|
100
|
+
},
|
|
101
|
+
pluginMetadata
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
env.registerInit({
|
|
105
|
+
schema,
|
|
106
|
+
deps: {
|
|
107
|
+
logger: coreServices.logger,
|
|
108
|
+
rpc: coreServices.rpc,
|
|
109
|
+
rpcClient: coreServices.rpcClient,
|
|
110
|
+
signalService: coreServices.signalService,
|
|
111
|
+
},
|
|
112
|
+
init: async ({ logger, database, rpc, rpcClient, signalService }) => {
|
|
113
|
+
logger.debug("🔧 Initializing Incident Backend...");
|
|
114
|
+
|
|
115
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
116
|
+
|
|
117
|
+
const service = new IncidentService(
|
|
118
|
+
database as NodePgDatabase<typeof schema>
|
|
119
|
+
);
|
|
120
|
+
const router = createRouter(
|
|
121
|
+
service,
|
|
122
|
+
signalService,
|
|
123
|
+
catalogClient,
|
|
124
|
+
logger
|
|
125
|
+
);
|
|
126
|
+
rpc.registerRouter(router, incidentContract);
|
|
127
|
+
|
|
128
|
+
// Register "Create Incident" command in the command palette
|
|
129
|
+
registerSearchProvider({
|
|
130
|
+
pluginMetadata,
|
|
131
|
+
commands: [
|
|
132
|
+
{
|
|
133
|
+
id: "create",
|
|
134
|
+
title: "Create Incident",
|
|
135
|
+
subtitle: "Report a new incident affecting systems",
|
|
136
|
+
iconName: "AlertCircle",
|
|
137
|
+
route:
|
|
138
|
+
resolveRoute(incidentRoutes.routes.config) + "?action=create",
|
|
139
|
+
requiredPermissions: [permissions.incidentManage],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "manage",
|
|
143
|
+
title: "Manage Incidents",
|
|
144
|
+
subtitle: "Manage incidents affecting systems",
|
|
145
|
+
iconName: "AlertCircle",
|
|
146
|
+
shortcuts: ["meta+shift+i", "ctrl+shift+i"],
|
|
147
|
+
route: resolveRoute(incidentRoutes.routes.config),
|
|
148
|
+
requiredPermissions: [permissions.incidentManage],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
logger.debug("✅ Incident Backend initialized.");
|
|
154
|
+
},
|
|
155
|
+
// Phase 3: Subscribe to catalog events for cleanup
|
|
156
|
+
afterPluginsReady: async ({ database, logger, onHook }) => {
|
|
157
|
+
const typedDb = database as NodePgDatabase<typeof schema>;
|
|
158
|
+
const service = new IncidentService(typedDb);
|
|
159
|
+
|
|
160
|
+
// Subscribe to catalog system deletion to clean up associations
|
|
161
|
+
onHook(
|
|
162
|
+
catalogHooks.systemDeleted,
|
|
163
|
+
async (payload) => {
|
|
164
|
+
logger.debug(
|
|
165
|
+
`Cleaning up incident associations for deleted system: ${payload.systemId}`
|
|
166
|
+
);
|
|
167
|
+
await service.removeSystemAssociations(payload.systemId);
|
|
168
|
+
},
|
|
169
|
+
{ mode: "work-queue", workerGroup: "incident-system-cleanup" }
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
logger.debug("✅ Incident Backend afterPluginsReady complete.");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Re-export hooks for other plugins to use
|
|
179
|
+
export { incidentHooks } from "./hooks";
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
incidentContract,
|
|
4
|
+
INCIDENT_UPDATED,
|
|
5
|
+
incidentRoutes,
|
|
6
|
+
} from "@checkmate-monitor/incident-common";
|
|
7
|
+
import {
|
|
8
|
+
autoAuthMiddleware,
|
|
9
|
+
Logger,
|
|
10
|
+
type RpcContext,
|
|
11
|
+
} from "@checkmate-monitor/backend-api";
|
|
12
|
+
import type { SignalService } from "@checkmate-monitor/signal-common";
|
|
13
|
+
import type { IncidentService } from "./service";
|
|
14
|
+
import { CatalogApi } from "@checkmate-monitor/catalog-common";
|
|
15
|
+
import type { InferClient } from "@checkmate-monitor/common";
|
|
16
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
17
|
+
import { incidentHooks } from "./hooks";
|
|
18
|
+
|
|
19
|
+
export function createRouter(
|
|
20
|
+
service: IncidentService,
|
|
21
|
+
signalService: SignalService,
|
|
22
|
+
catalogClient: InferClient<typeof CatalogApi>,
|
|
23
|
+
logger: Logger
|
|
24
|
+
) {
|
|
25
|
+
const os = implement(incidentContract)
|
|
26
|
+
.$context<RpcContext>()
|
|
27
|
+
.use(autoAuthMiddleware);
|
|
28
|
+
|
|
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
|
+
return os.router({
|
|
85
|
+
listIncidents: os.listIncidents.handler(async ({ input }) => {
|
|
86
|
+
return service.listIncidents(input ?? {});
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
getIncident: os.getIncident.handler(async ({ input }) => {
|
|
90
|
+
const result = await service.getIncident(input.id);
|
|
91
|
+
// eslint-disable-next-line unicorn/no-null -- oRPC contract requires null for missing values
|
|
92
|
+
return result ?? null;
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
getIncidentsForSystem: os.getIncidentsForSystem.handler(
|
|
96
|
+
async ({ input }) => {
|
|
97
|
+
return service.getIncidentsForSystem(input.systemId);
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
|
|
101
|
+
createIncident: os.createIncident.handler(async ({ input, context }) => {
|
|
102
|
+
const userId =
|
|
103
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
104
|
+
const result = await service.createIncident(input, userId);
|
|
105
|
+
|
|
106
|
+
// Broadcast signal for realtime updates
|
|
107
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
108
|
+
incidentId: result.id,
|
|
109
|
+
systemIds: result.systemIds,
|
|
110
|
+
action: "created",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Emit hook for cross-plugin coordination
|
|
114
|
+
await context.emitHook(incidentHooks.incidentCreated, {
|
|
115
|
+
incidentId: result.id,
|
|
116
|
+
systemIds: result.systemIds,
|
|
117
|
+
title: result.title,
|
|
118
|
+
description: result.description,
|
|
119
|
+
severity: result.severity,
|
|
120
|
+
status: result.status,
|
|
121
|
+
createdAt: result.createdAt.toISOString(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Send notifications to system subscribers
|
|
125
|
+
await notifyAffectedSystems({
|
|
126
|
+
incidentId: result.id,
|
|
127
|
+
incidentTitle: result.title,
|
|
128
|
+
systemIds: result.systemIds,
|
|
129
|
+
action: "created",
|
|
130
|
+
severity: result.severity,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}),
|
|
135
|
+
|
|
136
|
+
updateIncident: os.updateIncident.handler(async ({ input, context }) => {
|
|
137
|
+
const result = await service.updateIncident(input);
|
|
138
|
+
if (!result) {
|
|
139
|
+
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Broadcast signal for realtime updates
|
|
143
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
144
|
+
incidentId: result.id,
|
|
145
|
+
systemIds: result.systemIds,
|
|
146
|
+
action: "updated",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Emit hook for cross-plugin coordination
|
|
150
|
+
await context.emitHook(incidentHooks.incidentUpdated, {
|
|
151
|
+
incidentId: result.id,
|
|
152
|
+
systemIds: result.systemIds,
|
|
153
|
+
title: result.title,
|
|
154
|
+
description: result.description,
|
|
155
|
+
severity: result.severity,
|
|
156
|
+
status: result.status,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Send notifications to system subscribers
|
|
160
|
+
await notifyAffectedSystems({
|
|
161
|
+
incidentId: result.id,
|
|
162
|
+
incidentTitle: result.title,
|
|
163
|
+
systemIds: result.systemIds,
|
|
164
|
+
action: "updated",
|
|
165
|
+
severity: result.severity,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
addUpdate: os.addUpdate.handler(async ({ input, context }) => {
|
|
172
|
+
const userId =
|
|
173
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
174
|
+
const result = await service.addUpdate(input, userId);
|
|
175
|
+
|
|
176
|
+
// Get incident to broadcast with correct systemIds
|
|
177
|
+
const incident = await service.getIncident(input.incidentId);
|
|
178
|
+
if (incident) {
|
|
179
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
180
|
+
incidentId: input.incidentId,
|
|
181
|
+
systemIds: incident.systemIds,
|
|
182
|
+
action: "updated",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Emit hook for cross-plugin coordination
|
|
186
|
+
await context.emitHook(incidentHooks.incidentUpdated, {
|
|
187
|
+
incidentId: input.incidentId,
|
|
188
|
+
systemIds: incident.systemIds,
|
|
189
|
+
title: incident.title,
|
|
190
|
+
description: incident.description,
|
|
191
|
+
severity: incident.severity,
|
|
192
|
+
status: incident.status,
|
|
193
|
+
statusChange: input.statusChange,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// If status changed to resolved, emit resolved hook
|
|
197
|
+
if (input.statusChange === "resolved") {
|
|
198
|
+
await context.emitHook(incidentHooks.incidentResolved, {
|
|
199
|
+
incidentId: input.incidentId,
|
|
200
|
+
systemIds: incident.systemIds,
|
|
201
|
+
title: incident.title,
|
|
202
|
+
severity: incident.severity,
|
|
203
|
+
resolvedAt: new Date().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return result;
|
|
209
|
+
}),
|
|
210
|
+
|
|
211
|
+
resolveIncident: os.resolveIncident.handler(async ({ input, context }) => {
|
|
212
|
+
const userId =
|
|
213
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
214
|
+
const result = await service.resolveIncident(
|
|
215
|
+
input.id,
|
|
216
|
+
input.message,
|
|
217
|
+
userId
|
|
218
|
+
);
|
|
219
|
+
if (!result) {
|
|
220
|
+
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Broadcast signal for realtime updates
|
|
224
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
225
|
+
incidentId: result.id,
|
|
226
|
+
systemIds: result.systemIds,
|
|
227
|
+
action: "resolved",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Emit hook for cross-plugin coordination
|
|
231
|
+
await context.emitHook(incidentHooks.incidentResolved, {
|
|
232
|
+
incidentId: result.id,
|
|
233
|
+
systemIds: result.systemIds,
|
|
234
|
+
title: result.title,
|
|
235
|
+
severity: result.severity,
|
|
236
|
+
resolvedAt: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Send notifications to system subscribers
|
|
240
|
+
await notifyAffectedSystems({
|
|
241
|
+
incidentId: result.id,
|
|
242
|
+
incidentTitle: result.title,
|
|
243
|
+
systemIds: result.systemIds,
|
|
244
|
+
action: "resolved",
|
|
245
|
+
severity: result.severity,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}),
|
|
250
|
+
|
|
251
|
+
deleteIncident: os.deleteIncident.handler(async ({ input }) => {
|
|
252
|
+
// Get incident before deleting to get systemIds
|
|
253
|
+
const incident = await service.getIncident(input.id);
|
|
254
|
+
const success = await service.deleteIncident(input.id);
|
|
255
|
+
if (success && incident) {
|
|
256
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
257
|
+
incidentId: input.id,
|
|
258
|
+
systemIds: incident.systemIds,
|
|
259
|
+
action: "deleted",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { success };
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
pgEnum,
|
|
4
|
+
text,
|
|
5
|
+
timestamp,
|
|
6
|
+
primaryKey,
|
|
7
|
+
} from "drizzle-orm/pg-core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Incident status enum
|
|
11
|
+
*/
|
|
12
|
+
export const incidentStatusEnum = pgEnum("incident_status", [
|
|
13
|
+
"investigating",
|
|
14
|
+
"identified",
|
|
15
|
+
"fixing",
|
|
16
|
+
"monitoring",
|
|
17
|
+
"resolved",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Incident severity enum
|
|
22
|
+
*/
|
|
23
|
+
export const incidentSeverityEnum = pgEnum("incident_severity", [
|
|
24
|
+
"minor",
|
|
25
|
+
"major",
|
|
26
|
+
"critical",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Main incidents table
|
|
31
|
+
*/
|
|
32
|
+
export const incidents = pgTable("incidents", {
|
|
33
|
+
id: text("id").primaryKey(),
|
|
34
|
+
title: text("title").notNull(),
|
|
35
|
+
description: text("description"),
|
|
36
|
+
status: incidentStatusEnum("status").notNull().default("investigating"),
|
|
37
|
+
severity: incidentSeverityEnum("severity").notNull().default("major"),
|
|
38
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
39
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Junction table for incident-system many-to-many relationship
|
|
44
|
+
*/
|
|
45
|
+
export const incidentSystems = pgTable(
|
|
46
|
+
"incident_systems",
|
|
47
|
+
{
|
|
48
|
+
incidentId: text("incident_id")
|
|
49
|
+
.notNull()
|
|
50
|
+
.references(() => incidents.id, { onDelete: "cascade" }),
|
|
51
|
+
systemId: text("system_id").notNull(),
|
|
52
|
+
},
|
|
53
|
+
(t) => ({
|
|
54
|
+
pk: primaryKey(t.incidentId, t.systemId),
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Status updates for incidents
|
|
60
|
+
*/
|
|
61
|
+
export const incidentUpdates = pgTable("incident_updates", {
|
|
62
|
+
id: text("id").primaryKey(),
|
|
63
|
+
incidentId: text("incident_id")
|
|
64
|
+
.notNull()
|
|
65
|
+
.references(() => incidents.id, { onDelete: "cascade" }),
|
|
66
|
+
message: text("message").notNull(),
|
|
67
|
+
statusChange: incidentStatusEnum("status_change"),
|
|
68
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
69
|
+
createdBy: text("created_by"),
|
|
70
|
+
});
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { eq, and, inArray, ne } from "drizzle-orm";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import { incidents, incidentSystems, incidentUpdates } from "./schema";
|
|
5
|
+
import type {
|
|
6
|
+
IncidentWithSystems,
|
|
7
|
+
IncidentDetail,
|
|
8
|
+
IncidentUpdate,
|
|
9
|
+
CreateIncidentInput,
|
|
10
|
+
UpdateIncidentInput,
|
|
11
|
+
AddIncidentUpdateInput,
|
|
12
|
+
IncidentStatus,
|
|
13
|
+
} from "@checkmate-monitor/incident-common";
|
|
14
|
+
|
|
15
|
+
type Db = NodePgDatabase<typeof schema>;
|
|
16
|
+
|
|
17
|
+
function generateId(): string {
|
|
18
|
+
return crypto.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class IncidentService {
|
|
22
|
+
constructor(private db: Db) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List incidents with optional filters
|
|
26
|
+
*/
|
|
27
|
+
async listIncidents(filters?: {
|
|
28
|
+
status?: IncidentStatus;
|
|
29
|
+
systemId?: string;
|
|
30
|
+
includeResolved?: boolean;
|
|
31
|
+
}): Promise<IncidentWithSystems[]> {
|
|
32
|
+
let incidentRows;
|
|
33
|
+
|
|
34
|
+
if (filters?.systemId) {
|
|
35
|
+
// Filter by system - need to join
|
|
36
|
+
const systemIncidentIds = await this.db
|
|
37
|
+
.select({ incidentId: incidentSystems.incidentId })
|
|
38
|
+
.from(incidentSystems)
|
|
39
|
+
.where(eq(incidentSystems.systemId, filters.systemId));
|
|
40
|
+
|
|
41
|
+
const ids = systemIncidentIds.map((r) => r.incidentId);
|
|
42
|
+
if (ids.length === 0) return [];
|
|
43
|
+
|
|
44
|
+
const statusFilter = filters.status
|
|
45
|
+
? eq(incidents.status, filters.status)
|
|
46
|
+
: filters.includeResolved
|
|
47
|
+
? undefined
|
|
48
|
+
: ne(incidents.status, "resolved");
|
|
49
|
+
|
|
50
|
+
incidentRows = await this.db
|
|
51
|
+
.select()
|
|
52
|
+
.from(incidents)
|
|
53
|
+
.where(and(inArray(incidents.id, ids), statusFilter));
|
|
54
|
+
} else {
|
|
55
|
+
const statusFilter = filters?.status
|
|
56
|
+
? eq(incidents.status, filters.status)
|
|
57
|
+
: filters?.includeResolved
|
|
58
|
+
? undefined
|
|
59
|
+
: ne(incidents.status, "resolved");
|
|
60
|
+
|
|
61
|
+
incidentRows = await this.db.select().from(incidents).where(statusFilter);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fetch all system associations
|
|
65
|
+
const result: IncidentWithSystems[] = [];
|
|
66
|
+
for (const i of incidentRows) {
|
|
67
|
+
const systems = await this.db
|
|
68
|
+
.select({ systemId: incidentSystems.systemId })
|
|
69
|
+
.from(incidentSystems)
|
|
70
|
+
.where(eq(incidentSystems.incidentId, i.id));
|
|
71
|
+
|
|
72
|
+
result.push({
|
|
73
|
+
...i,
|
|
74
|
+
description: i.description ?? undefined,
|
|
75
|
+
systemIds: systems.map((s) => s.systemId),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get single incident with full details
|
|
84
|
+
*/
|
|
85
|
+
async getIncident(id: string): Promise<IncidentDetail | undefined> {
|
|
86
|
+
const [incident] = await this.db
|
|
87
|
+
.select()
|
|
88
|
+
.from(incidents)
|
|
89
|
+
.where(eq(incidents.id, id));
|
|
90
|
+
|
|
91
|
+
if (!incident) return undefined;
|
|
92
|
+
|
|
93
|
+
const systems = await this.db
|
|
94
|
+
.select({ systemId: incidentSystems.systemId })
|
|
95
|
+
.from(incidentSystems)
|
|
96
|
+
.where(eq(incidentSystems.incidentId, id));
|
|
97
|
+
|
|
98
|
+
const updates = await this.db
|
|
99
|
+
.select()
|
|
100
|
+
.from(incidentUpdates)
|
|
101
|
+
.where(eq(incidentUpdates.incidentId, id));
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...incident,
|
|
105
|
+
description: incident.description ?? undefined,
|
|
106
|
+
systemIds: systems.map((s) => s.systemId),
|
|
107
|
+
updates: updates.map((u) => ({
|
|
108
|
+
...u,
|
|
109
|
+
statusChange: u.statusChange ?? undefined,
|
|
110
|
+
createdBy: u.createdBy ?? undefined,
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get active incidents for a system
|
|
117
|
+
*/
|
|
118
|
+
async getIncidentsForSystem(
|
|
119
|
+
systemId: string
|
|
120
|
+
): Promise<IncidentWithSystems[]> {
|
|
121
|
+
// Get incident IDs for this system
|
|
122
|
+
const systemIncidents = await this.db
|
|
123
|
+
.select({ incidentId: incidentSystems.incidentId })
|
|
124
|
+
.from(incidentSystems)
|
|
125
|
+
.where(eq(incidentSystems.systemId, systemId));
|
|
126
|
+
|
|
127
|
+
const ids = systemIncidents.map((r) => r.incidentId);
|
|
128
|
+
if (ids.length === 0) return [];
|
|
129
|
+
|
|
130
|
+
// Get only non-resolved incidents
|
|
131
|
+
const rows = await this.db
|
|
132
|
+
.select()
|
|
133
|
+
.from(incidents)
|
|
134
|
+
.where(and(inArray(incidents.id, ids), ne(incidents.status, "resolved")));
|
|
135
|
+
|
|
136
|
+
// Fetch system IDs for each
|
|
137
|
+
const result: IncidentWithSystems[] = [];
|
|
138
|
+
for (const i of rows) {
|
|
139
|
+
const systems = await this.db
|
|
140
|
+
.select({ systemId: incidentSystems.systemId })
|
|
141
|
+
.from(incidentSystems)
|
|
142
|
+
.where(eq(incidentSystems.incidentId, i.id));
|
|
143
|
+
|
|
144
|
+
result.push({
|
|
145
|
+
...i,
|
|
146
|
+
description: i.description ?? undefined,
|
|
147
|
+
systemIds: systems.map((s) => s.systemId),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create a new incident
|
|
156
|
+
*/
|
|
157
|
+
async createIncident(
|
|
158
|
+
input: CreateIncidentInput,
|
|
159
|
+
userId?: string
|
|
160
|
+
): Promise<IncidentWithSystems> {
|
|
161
|
+
const id = generateId();
|
|
162
|
+
|
|
163
|
+
await this.db.insert(incidents).values({
|
|
164
|
+
id,
|
|
165
|
+
title: input.title,
|
|
166
|
+
description: input.description,
|
|
167
|
+
status: "investigating",
|
|
168
|
+
severity: input.severity,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Insert system associations
|
|
172
|
+
for (const systemId of input.systemIds) {
|
|
173
|
+
await this.db.insert(incidentSystems).values({
|
|
174
|
+
incidentId: id,
|
|
175
|
+
systemId,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Add initial update if provided
|
|
180
|
+
if (input.initialMessage) {
|
|
181
|
+
await this.db.insert(incidentUpdates).values({
|
|
182
|
+
id: generateId(),
|
|
183
|
+
incidentId: id,
|
|
184
|
+
message: input.initialMessage,
|
|
185
|
+
statusChange: "investigating",
|
|
186
|
+
createdBy: userId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (await this.getIncident(id))!;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update an existing incident
|
|
195
|
+
*/
|
|
196
|
+
async updateIncident(
|
|
197
|
+
input: UpdateIncidentInput
|
|
198
|
+
): Promise<IncidentWithSystems | undefined> {
|
|
199
|
+
const [existing] = await this.db
|
|
200
|
+
.select()
|
|
201
|
+
.from(incidents)
|
|
202
|
+
.where(eq(incidents.id, input.id));
|
|
203
|
+
|
|
204
|
+
if (!existing) return undefined;
|
|
205
|
+
|
|
206
|
+
// Build update object
|
|
207
|
+
const updateData: Partial<typeof incidents.$inferInsert> = {
|
|
208
|
+
updatedAt: new Date(),
|
|
209
|
+
};
|
|
210
|
+
if (input.title !== undefined) updateData.title = input.title;
|
|
211
|
+
if (input.description !== undefined)
|
|
212
|
+
updateData.description = input.description;
|
|
213
|
+
if (input.severity !== undefined) updateData.severity = input.severity;
|
|
214
|
+
|
|
215
|
+
await this.db
|
|
216
|
+
.update(incidents)
|
|
217
|
+
.set(updateData)
|
|
218
|
+
.where(eq(incidents.id, input.id));
|
|
219
|
+
|
|
220
|
+
// Update system associations if provided
|
|
221
|
+
if (input.systemIds !== undefined) {
|
|
222
|
+
await this.db
|
|
223
|
+
.delete(incidentSystems)
|
|
224
|
+
.where(eq(incidentSystems.incidentId, input.id));
|
|
225
|
+
|
|
226
|
+
for (const systemId of input.systemIds) {
|
|
227
|
+
await this.db.insert(incidentSystems).values({
|
|
228
|
+
incidentId: input.id,
|
|
229
|
+
systemId,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (await this.getIncident(input.id))!;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Add a status update to an incident
|
|
239
|
+
*/
|
|
240
|
+
async addUpdate(
|
|
241
|
+
input: AddIncidentUpdateInput,
|
|
242
|
+
userId?: string
|
|
243
|
+
): Promise<IncidentUpdate> {
|
|
244
|
+
const id = generateId();
|
|
245
|
+
|
|
246
|
+
// If status change is provided, update the incident status
|
|
247
|
+
if (input.statusChange) {
|
|
248
|
+
await this.db
|
|
249
|
+
.update(incidents)
|
|
250
|
+
.set({ status: input.statusChange, updatedAt: new Date() })
|
|
251
|
+
.where(eq(incidents.id, input.incidentId));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await this.db.insert(incidentUpdates).values({
|
|
255
|
+
id,
|
|
256
|
+
incidentId: input.incidentId,
|
|
257
|
+
message: input.message,
|
|
258
|
+
statusChange: input.statusChange,
|
|
259
|
+
createdBy: userId,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const [update] = await this.db
|
|
263
|
+
.select()
|
|
264
|
+
.from(incidentUpdates)
|
|
265
|
+
.where(eq(incidentUpdates.id, id));
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
...update,
|
|
269
|
+
statusChange: update.statusChange ?? undefined,
|
|
270
|
+
createdBy: update.createdBy ?? undefined,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve an incident
|
|
276
|
+
*/
|
|
277
|
+
async resolveIncident(
|
|
278
|
+
id: string,
|
|
279
|
+
message?: string,
|
|
280
|
+
userId?: string
|
|
281
|
+
): Promise<IncidentWithSystems | undefined> {
|
|
282
|
+
const [existing] = await this.db
|
|
283
|
+
.select()
|
|
284
|
+
.from(incidents)
|
|
285
|
+
.where(eq(incidents.id, id));
|
|
286
|
+
|
|
287
|
+
if (!existing) return undefined;
|
|
288
|
+
|
|
289
|
+
await this.db
|
|
290
|
+
.update(incidents)
|
|
291
|
+
.set({ status: "resolved", updatedAt: new Date() })
|
|
292
|
+
.where(eq(incidents.id, id));
|
|
293
|
+
|
|
294
|
+
// Add resolution update entry
|
|
295
|
+
await this.db.insert(incidentUpdates).values({
|
|
296
|
+
id: generateId(),
|
|
297
|
+
incidentId: id,
|
|
298
|
+
message: message ?? "Incident resolved",
|
|
299
|
+
statusChange: "resolved",
|
|
300
|
+
createdBy: userId,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return (await this.getIncident(id))!;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Delete an incident
|
|
308
|
+
*/
|
|
309
|
+
async deleteIncident(id: string): Promise<boolean> {
|
|
310
|
+
const [existing] = await this.db
|
|
311
|
+
.select()
|
|
312
|
+
.from(incidents)
|
|
313
|
+
.where(eq(incidents.id, id));
|
|
314
|
+
|
|
315
|
+
if (!existing) return false;
|
|
316
|
+
|
|
317
|
+
// Cascade delete handles junctions and updates
|
|
318
|
+
await this.db.delete(incidents).where(eq(incidents.id, id));
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Remove all incident associations for a system.
|
|
324
|
+
* Called when a system is deleted from the catalog.
|
|
325
|
+
*/
|
|
326
|
+
async removeSystemAssociations(systemId: string): Promise<void> {
|
|
327
|
+
await this.db
|
|
328
|
+
.delete(incidentSystems)
|
|
329
|
+
.where(eq(incidentSystems.systemId, systemId));
|
|
330
|
+
}
|
|
331
|
+
}
|