@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 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
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1767395825368,
9
+ "tag": "0000_sticky_unus",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }