@checkstack/incident-backend 1.8.6 → 1.9.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 +75 -0
- package/package.json +22 -22
- package/src/router.test.ts +181 -0
- package/src/router.ts +168 -84
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
# @checkstack/incident-backend
|
|
2
2
|
|
|
3
|
+
## 1.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e430fbe: Add "Mass delete" and "Mass resolve" to the Incidents and Maintenances lists,
|
|
8
|
+
authorized per item (RLAC).
|
|
9
|
+
|
|
10
|
+
The incidents and maintenances list pages now support multi-select with a bulk
|
|
11
|
+
action bar. A user may only select and act on entries they are allowed to
|
|
12
|
+
MANAGE: a row's checkbox appears only when the caller can manage it (the same
|
|
13
|
+
`canAccess(id)` gate as the per-row actions), so a team-scoped member sees
|
|
14
|
+
checkboxes only for their team's entries. Mass delete confirms before running;
|
|
15
|
+
mass resolve (incidents) and mass complete (maintenances, the "resolve"
|
|
16
|
+
equivalent = close, status -> completed) skip entries that are already
|
|
17
|
+
resolved/completed. Each action reports a per-id partial-success summary
|
|
18
|
+
(e.g. "3 deleted, 1 skipped").
|
|
19
|
+
|
|
20
|
+
New backend procedures: `incident.bulkDeleteIncidents`,
|
|
21
|
+
`incident.bulkResolveIncidents`, `maintenance.bulkDeleteMaintenances`, and
|
|
22
|
+
`maintenance.bulkCloseMaintenances`. Each authorizes EACH id against the
|
|
23
|
+
caller's manage grant and never fails open: unauthorized ids are filtered out
|
|
24
|
+
before the handler runs and returned as `forbidden`; missing ids as `notFound`;
|
|
25
|
+
a per-id failure is isolated as `error` without aborting the batch. Per-id cache
|
|
26
|
+
invalidation, realtime signals, and subscriber notifications run for every
|
|
27
|
+
success so dashboards and status pages stay consistent.
|
|
28
|
+
|
|
29
|
+
Platform: a new `instanceAccess` mode `bulkManage: { idsParam }` is the
|
|
30
|
+
enforcement point for bulk writes. Before the handler runs, `autoAuthMiddleware`
|
|
31
|
+
partitions the input id array into the caller's manageable subset and the denied
|
|
32
|
+
remainder and exposes both on `context.bulkAccess` (fail-closed on an S2S
|
|
33
|
+
error). The boot-time contract validator (`validateContractInstanceAccess`)
|
|
34
|
+
accepts `bulkManage` as one of the mutually-exclusive scoping modes, marks its
|
|
35
|
+
type team-scopable, and cross-checks `idsParam` against the input schema.
|
|
36
|
+
|
|
37
|
+
State and scale: authorization is derived per request from the shared team-grant
|
|
38
|
+
store via the existing auth S2S path (no process-local state); the read returns
|
|
39
|
+
the same answer on every pod. No database migration.
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [d1b71b6]
|
|
44
|
+
- Updated dependencies [7c18b25]
|
|
45
|
+
- Updated dependencies [e430fbe]
|
|
46
|
+
- Updated dependencies [eab80e3]
|
|
47
|
+
- Updated dependencies [53666a7]
|
|
48
|
+
- Updated dependencies [0d912a3]
|
|
49
|
+
- @checkstack/notification-common@1.5.0
|
|
50
|
+
- @checkstack/ai-backend@0.10.2
|
|
51
|
+
- @checkstack/common@0.19.0
|
|
52
|
+
- @checkstack/backend-api@0.27.0
|
|
53
|
+
- @checkstack/incident-common@1.7.0
|
|
54
|
+
- @checkstack/auth-common@0.12.0
|
|
55
|
+
- @checkstack/catalog-common@2.6.0
|
|
56
|
+
- @checkstack/automation-common@0.9.0
|
|
57
|
+
- @checkstack/status-page-common@0.5.0
|
|
58
|
+
- @checkstack/catalog-backend@1.6.2
|
|
59
|
+
- @checkstack/automation-backend@0.10.4
|
|
60
|
+
- @checkstack/ai-common@0.6.3
|
|
61
|
+
- @checkstack/cache-api@0.3.16
|
|
62
|
+
- @checkstack/cache-utils@0.2.21
|
|
63
|
+
- @checkstack/command-backend@0.2.15
|
|
64
|
+
- @checkstack/integration-backend@0.6.8
|
|
65
|
+
- @checkstack/integration-common@0.9.5
|
|
66
|
+
- @checkstack/signal-common@0.2.14
|
|
67
|
+
- @checkstack/status-page-backend@0.4.2
|
|
68
|
+
|
|
69
|
+
## 1.8.7
|
|
70
|
+
|
|
71
|
+
### Patch Changes
|
|
72
|
+
|
|
73
|
+
- Updated dependencies [baf9b6e]
|
|
74
|
+
- @checkstack/ai-backend@0.10.1
|
|
75
|
+
- @checkstack/automation-backend@0.10.3
|
|
76
|
+
- @checkstack/catalog-backend@1.6.1
|
|
77
|
+
|
|
3
78
|
## 1.8.6
|
|
4
79
|
|
|
5
80
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,25 +14,25 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.10.
|
|
18
|
-
"@checkstack/ai-common": "0.6.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/cache-api": "0.3.
|
|
21
|
-
"@checkstack/cache-utils": "0.2.
|
|
22
|
-
"@checkstack/incident-common": "1.
|
|
23
|
-
"@checkstack/catalog-common": "2.
|
|
24
|
-
"@checkstack/catalog-backend": "1.6.
|
|
25
|
-
"@checkstack/notification-common": "1.
|
|
26
|
-
"@checkstack/auth-common": "0.
|
|
27
|
-
"@checkstack/command-backend": "0.2.
|
|
28
|
-
"@checkstack/signal-common": "0.2.
|
|
29
|
-
"@checkstack/status-page-backend": "0.4.
|
|
30
|
-
"@checkstack/status-page-common": "0.
|
|
31
|
-
"@checkstack/integration-backend": "0.6.
|
|
32
|
-
"@checkstack/integration-common": "0.9.
|
|
33
|
-
"@checkstack/automation-backend": "0.10.
|
|
34
|
-
"@checkstack/automation-common": "0.
|
|
35
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.2",
|
|
18
|
+
"@checkstack/ai-common": "0.6.3",
|
|
19
|
+
"@checkstack/backend-api": "0.27.0",
|
|
20
|
+
"@checkstack/cache-api": "0.3.16",
|
|
21
|
+
"@checkstack/cache-utils": "0.2.21",
|
|
22
|
+
"@checkstack/incident-common": "1.7.0",
|
|
23
|
+
"@checkstack/catalog-common": "2.6.0",
|
|
24
|
+
"@checkstack/catalog-backend": "1.6.2",
|
|
25
|
+
"@checkstack/notification-common": "1.5.0",
|
|
26
|
+
"@checkstack/auth-common": "0.12.0",
|
|
27
|
+
"@checkstack/command-backend": "0.2.15",
|
|
28
|
+
"@checkstack/signal-common": "0.2.14",
|
|
29
|
+
"@checkstack/status-page-backend": "0.4.2",
|
|
30
|
+
"@checkstack/status-page-common": "0.5.0",
|
|
31
|
+
"@checkstack/integration-backend": "0.6.8",
|
|
32
|
+
"@checkstack/integration-common": "0.9.5",
|
|
33
|
+
"@checkstack/automation-backend": "0.10.4",
|
|
34
|
+
"@checkstack/automation-common": "0.9.0",
|
|
35
|
+
"@checkstack/common": "0.19.0",
|
|
36
36
|
"drizzle-orm": "^0.45.0",
|
|
37
37
|
"zod": "^4.2.1",
|
|
38
38
|
"@orpc/contract": "^1.14.4",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
43
|
-
"@checkstack/scripts": "0.
|
|
44
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
43
|
+
"@checkstack/scripts": "0.7.0",
|
|
44
|
+
"@checkstack/test-utils-backend": "0.1.49",
|
|
45
45
|
"@checkstack/tsconfig": "0.0.7",
|
|
46
46
|
"@types/bun": "^1.0.0",
|
|
47
47
|
"drizzle-kit": "^0.31.10",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { call } from "@orpc/server";
|
|
3
|
+
import { createMockRpcContext, type RpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { createRouter } from "./router";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bulk-action authorization + per-id partial-success tests for the incident
|
|
8
|
+
* router. These drive the REAL `bulkDeleteIncidents` / `bulkResolveIncidents`
|
|
9
|
+
* procedures through `call`, so the platform `bulkManage` middleware runs and
|
|
10
|
+
* pre-partitions `input.ids` into the caller's authorized subset BEFORE the
|
|
11
|
+
* handler. We assert:
|
|
12
|
+
* - only authorized ids are ever passed to the service (denied ids are never
|
|
13
|
+
* acted on — RLAC never-fail-open),
|
|
14
|
+
* - denied ids are reported `forbidden`, missing ids `notFound`, and a per-id
|
|
15
|
+
* service throw is isolated as `error` without aborting the batch.
|
|
16
|
+
*
|
|
17
|
+
* The caller is TEAM-SCOPED (no global rule); `listAccessibleObjectIds` returns
|
|
18
|
+
* the granted subset, exactly as the auth backend would.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface FakeIncident {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
status: string;
|
|
26
|
+
severity: string;
|
|
27
|
+
suppressNotifications: boolean;
|
|
28
|
+
systemIds: string[];
|
|
29
|
+
createdAt: Date;
|
|
30
|
+
updatedAt: Date;
|
|
31
|
+
updates: unknown[];
|
|
32
|
+
links: unknown[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeIncident(id: string, status = "investigating"): FakeIncident {
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
title: `Incident ${id}`,
|
|
39
|
+
status,
|
|
40
|
+
severity: "major",
|
|
41
|
+
suppressNotifications: false,
|
|
42
|
+
systemIds: ["sys-1"],
|
|
43
|
+
createdAt: new Date("2026-01-01T00:00:00Z"),
|
|
44
|
+
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
45
|
+
updates: [],
|
|
46
|
+
links: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ids that resolve to a real incident. "inc-missing" is absent; "inc-throw"
|
|
51
|
+
// exists but its mutate throws.
|
|
52
|
+
const PRESENT = new Set(["inc-ok", "inc-throw"]);
|
|
53
|
+
|
|
54
|
+
function buildRouter() {
|
|
55
|
+
const deleteIncident = mock(async (id: string) => {
|
|
56
|
+
if (id === "inc-throw") throw new Error("db exploded");
|
|
57
|
+
return PRESENT.has(id);
|
|
58
|
+
});
|
|
59
|
+
const resolveIncident = mock(async (id: string) => {
|
|
60
|
+
if (id === "inc-throw") throw new Error("db exploded");
|
|
61
|
+
return PRESENT.has(id) ? makeIncident(id, "resolved") : undefined;
|
|
62
|
+
});
|
|
63
|
+
const getIncident = mock(async (id: string) =>
|
|
64
|
+
PRESENT.has(id) ? makeIncident(id) : undefined,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const service = {
|
|
68
|
+
getIncident,
|
|
69
|
+
deleteIncident,
|
|
70
|
+
resolveIncident,
|
|
71
|
+
} as unknown as Parameters<typeof createRouter>[0]["service"];
|
|
72
|
+
|
|
73
|
+
const invalidateForMutation = mock(async () => {});
|
|
74
|
+
const broadcast = mock(async () => {});
|
|
75
|
+
const notifyForSubscription = mock(async () => {});
|
|
76
|
+
const getSystem = mock(async () => undefined);
|
|
77
|
+
const getUserById = mock(async () => undefined);
|
|
78
|
+
|
|
79
|
+
// Cast test doubles: the router only touches these members on the bulk paths.
|
|
80
|
+
const router = createRouter({
|
|
81
|
+
service,
|
|
82
|
+
signalService: { broadcast } as unknown as Parameters<
|
|
83
|
+
typeof createRouter
|
|
84
|
+
>[0]["signalService"],
|
|
85
|
+
catalogClient: { getSystem } as unknown as Parameters<
|
|
86
|
+
typeof createRouter
|
|
87
|
+
>[0]["catalogClient"],
|
|
88
|
+
notificationClient: { notifyForSubscription } as unknown as Parameters<
|
|
89
|
+
typeof createRouter
|
|
90
|
+
>[0]["notificationClient"],
|
|
91
|
+
authClient: { getUserById } as unknown as Parameters<
|
|
92
|
+
typeof createRouter
|
|
93
|
+
>[0]["authClient"],
|
|
94
|
+
logger: createMockRpcContext().logger,
|
|
95
|
+
cache: { invalidateForMutation } as unknown as Parameters<
|
|
96
|
+
typeof createRouter
|
|
97
|
+
>[0]["cache"],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return { router, deleteIncident, resolveIncident, invalidateForMutation };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Team-scoped context: no global rule; only `granted` ids are grant-covered. */
|
|
104
|
+
function teamScopedContext(granted: string[]): RpcContext {
|
|
105
|
+
const base = createMockRpcContext({
|
|
106
|
+
pluginMetadata: { pluginId: "incident" },
|
|
107
|
+
user: { type: "user", id: "u1", accessRules: [] },
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
auth: {
|
|
112
|
+
...base.auth,
|
|
113
|
+
listAccessibleObjectIds: (p: { objectIds: string[] }) =>
|
|
114
|
+
Promise.resolve(p.objectIds.filter((id) => granted.includes(id))),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("incident router bulkDeleteIncidents", () => {
|
|
120
|
+
it("deletes only authorized ids and reports forbidden/notFound/error per id", async () => {
|
|
121
|
+
const { router, deleteIncident } = buildRouter();
|
|
122
|
+
const ctx = teamScopedContext(["inc-ok", "inc-missing", "inc-throw"]);
|
|
123
|
+
|
|
124
|
+
const { results } = await call(
|
|
125
|
+
router.bulkDeleteIncidents,
|
|
126
|
+
{ ids: ["inc-ok", "inc-missing", "inc-throw", "inc-forbidden"] },
|
|
127
|
+
{ context: ctx },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const byId = Object.fromEntries(results.map((r) => [r.id, r.status]));
|
|
131
|
+
expect(byId["inc-ok"]).toBe("deleted");
|
|
132
|
+
expect(byId["inc-missing"]).toBe("notFound");
|
|
133
|
+
expect(byId["inc-throw"]).toBe("error");
|
|
134
|
+
expect(byId["inc-forbidden"]).toBe("forbidden");
|
|
135
|
+
|
|
136
|
+
// The unauthorized id was NEVER passed to the service (never-fail-open).
|
|
137
|
+
const deletedIds = deleteIncident.mock.calls.map((c) => c[0]);
|
|
138
|
+
expect(deletedIds).not.toContain("inc-forbidden");
|
|
139
|
+
expect(deletedIds).toContain("inc-ok");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reports every id forbidden when the caller has no grants at all", async () => {
|
|
143
|
+
const { router, deleteIncident } = buildRouter();
|
|
144
|
+
const ctx = teamScopedContext([]); // no grants
|
|
145
|
+
|
|
146
|
+
const { results } = await call(
|
|
147
|
+
router.bulkDeleteIncidents,
|
|
148
|
+
{ ids: ["inc-ok", "inc-throw"] },
|
|
149
|
+
{ context: ctx },
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(results.every((r) => r.status === "forbidden")).toBe(true);
|
|
153
|
+
expect(deleteIncident).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("incident router bulkResolveIncidents", () => {
|
|
158
|
+
it("resolves only authorized ids and reports forbidden/notFound/error per id", async () => {
|
|
159
|
+
const { router, resolveIncident } = buildRouter();
|
|
160
|
+
const ctx = teamScopedContext(["inc-ok", "inc-missing", "inc-throw"]);
|
|
161
|
+
|
|
162
|
+
const { results } = await call(
|
|
163
|
+
router.bulkResolveIncidents,
|
|
164
|
+
{
|
|
165
|
+
ids: ["inc-ok", "inc-missing", "inc-throw", "inc-forbidden"],
|
|
166
|
+
message: "batch resolve",
|
|
167
|
+
},
|
|
168
|
+
{ context: ctx },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const byId = Object.fromEntries(results.map((r) => [r.id, r.status]));
|
|
172
|
+
expect(byId["inc-ok"]).toBe("resolved");
|
|
173
|
+
expect(byId["inc-missing"]).toBe("notFound");
|
|
174
|
+
expect(byId["inc-throw"]).toBe("error");
|
|
175
|
+
expect(byId["inc-forbidden"]).toBe("forbidden");
|
|
176
|
+
|
|
177
|
+
const resolvedIds = resolveIncident.mock.calls.map((c) => c[0]);
|
|
178
|
+
expect(resolvedIds).not.toContain("inc-forbidden");
|
|
179
|
+
expect(resolvedIds).toContain("inc-ok");
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/router.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { notifyAffectedSystems } from "./notifications";
|
|
|
18
18
|
import type {
|
|
19
19
|
IncidentUpdate,
|
|
20
20
|
IncidentWithSystems,
|
|
21
|
+
BulkIncidentActionResult,
|
|
21
22
|
} from "@checkstack/incident-common";
|
|
22
23
|
import type { IncidentCache } from "./cache";
|
|
23
24
|
import type { EntityHandle } from "@checkstack/automation-backend";
|
|
@@ -110,6 +111,98 @@ export function createRouter({
|
|
|
110
111
|
return systemNames;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a single incident and run the full post-mutation sequence
|
|
116
|
+
* (entity write → cache invalidate → signal → notify). Returns `"notFound"`
|
|
117
|
+
* for a missing incident WITHOUT throwing, so the single-item handler (which
|
|
118
|
+
* turns that into a 404) and the bulk handler (which records it as a per-id
|
|
119
|
+
* outcome) share one code path.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveOneIncident({
|
|
122
|
+
id,
|
|
123
|
+
message,
|
|
124
|
+
userId,
|
|
125
|
+
}: {
|
|
126
|
+
id: string;
|
|
127
|
+
message?: string;
|
|
128
|
+
userId?: string;
|
|
129
|
+
}): Promise<{
|
|
130
|
+
status: "resolved" | "notFound";
|
|
131
|
+
incident?: IncidentWithSystems;
|
|
132
|
+
}> {
|
|
133
|
+
const existing = await service.getIncident(id);
|
|
134
|
+
if (!existing) return { status: "notFound" };
|
|
135
|
+
|
|
136
|
+
let resolved: IncidentWithSystems | undefined;
|
|
137
|
+
await writeIncidentEntity({
|
|
138
|
+
handle: getIncidentEntity?.(),
|
|
139
|
+
incidentId: id,
|
|
140
|
+
apply: async () => {
|
|
141
|
+
resolved = await service.resolveIncident(id, message, userId);
|
|
142
|
+
// A race could delete the incident between probe and write; fall back
|
|
143
|
+
// to the pre-write snapshot so the entity diff is a no-op.
|
|
144
|
+
return toIncidentEntityState(resolved ?? existing);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (!resolved) return { status: "notFound" };
|
|
148
|
+
|
|
149
|
+
await cache.invalidateForMutation({
|
|
150
|
+
incidentId: resolved.id,
|
|
151
|
+
systemIds: resolved.systemIds,
|
|
152
|
+
});
|
|
153
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
154
|
+
incidentId: resolved.id,
|
|
155
|
+
systemIds: resolved.systemIds,
|
|
156
|
+
action: "resolved",
|
|
157
|
+
});
|
|
158
|
+
const systemNames = await resolveSystemNames(resolved.systemIds);
|
|
159
|
+
await notifyAffectedSystems({
|
|
160
|
+
catalogClient,
|
|
161
|
+
notificationClient,
|
|
162
|
+
logger,
|
|
163
|
+
incidentId: resolved.id,
|
|
164
|
+
incidentTitle: resolved.title,
|
|
165
|
+
systemIds: resolved.systemIds,
|
|
166
|
+
systemNames,
|
|
167
|
+
action: "resolved",
|
|
168
|
+
severity: resolved.severity,
|
|
169
|
+
});
|
|
170
|
+
return { status: "resolved", incident: resolved };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Delete a single incident + post-mutation sequence (entity tombstone →
|
|
175
|
+
* cache invalidate → signal). Returns `"notFound"` when the row does not
|
|
176
|
+
* exist, matching the single-item handler's historical `{ success: false }`.
|
|
177
|
+
*/
|
|
178
|
+
async function deleteOneIncident(
|
|
179
|
+
id: string,
|
|
180
|
+
): Promise<"deleted" | "notFound"> {
|
|
181
|
+
const incident = await service.getIncident(id);
|
|
182
|
+
if (!incident) return "notFound";
|
|
183
|
+
|
|
184
|
+
let success = false;
|
|
185
|
+
await removeIncidentEntity({
|
|
186
|
+
handle: getIncidentEntity?.(),
|
|
187
|
+
incidentId: id,
|
|
188
|
+
apply: async () => {
|
|
189
|
+
success = await service.deleteIncident(id);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
if (!success) return "notFound";
|
|
193
|
+
|
|
194
|
+
await cache.invalidateForMutation({
|
|
195
|
+
incidentId: id,
|
|
196
|
+
systemIds: incident.systemIds,
|
|
197
|
+
});
|
|
198
|
+
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
199
|
+
incidentId: id,
|
|
200
|
+
systemIds: incident.systemIds,
|
|
201
|
+
action: "deleted",
|
|
202
|
+
});
|
|
203
|
+
return "deleted";
|
|
204
|
+
}
|
|
205
|
+
|
|
113
206
|
const os = implement(incidentContract)
|
|
114
207
|
.$context<RpcContext>()
|
|
115
208
|
.use(correlationMiddleware)
|
|
@@ -361,97 +454,88 @@ export function createRouter({
|
|
|
361
454
|
const userId =
|
|
362
455
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
363
456
|
|
|
364
|
-
|
|
365
|
-
|
|
457
|
+
// Drive the resolve through the shared per-id helper (entity write →
|
|
458
|
+
// cache → signal → notify). A missing incident surfaces as NOT_FOUND.
|
|
459
|
+
const outcome = await resolveOneIncident({
|
|
460
|
+
id: input.id,
|
|
461
|
+
message: input.message,
|
|
462
|
+
userId,
|
|
463
|
+
});
|
|
464
|
+
if (outcome.status !== "resolved" || !outcome.incident) {
|
|
366
465
|
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
367
466
|
}
|
|
368
|
-
|
|
369
|
-
// Drive the resolve through the reactive `incident` entity (§10.1);
|
|
370
|
-
// `apply` performs the REAL resolve (the plugin's own db/tx) and returns
|
|
371
|
-
// the new reactive state. The deriver fires `incident.resolved` from the
|
|
372
|
-
// status → resolved transition.
|
|
373
|
-
let result!: NonNullable<Awaited<ReturnType<typeof service.resolveIncident>>>;
|
|
374
|
-
await writeIncidentEntity({
|
|
375
|
-
handle: getIncidentEntity?.(),
|
|
376
|
-
incidentId: input.id,
|
|
377
|
-
apply: async () => {
|
|
378
|
-
const resolved = await service.resolveIncident(
|
|
379
|
-
input.id,
|
|
380
|
-
input.message,
|
|
381
|
-
userId,
|
|
382
|
-
);
|
|
383
|
-
if (!resolved) {
|
|
384
|
-
throw new ORPCError("NOT_FOUND", { message: "Incident not found" });
|
|
385
|
-
}
|
|
386
|
-
result = resolved;
|
|
387
|
-
return toIncidentEntityState(result);
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
await cache.invalidateForMutation({
|
|
392
|
-
incidentId: result.id,
|
|
393
|
-
systemIds: result.systemIds,
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
// Broadcast signal for realtime updates
|
|
397
|
-
await signalService.broadcast(INCIDENT_UPDATED, {
|
|
398
|
-
incidentId: result.id,
|
|
399
|
-
systemIds: result.systemIds,
|
|
400
|
-
action: "resolved",
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Send notifications to system subscribers
|
|
404
|
-
const systemNames = await resolveSystemNames(result.systemIds);
|
|
405
|
-
await notifyAffectedSystems({
|
|
406
|
-
catalogClient,
|
|
407
|
-
notificationClient,
|
|
408
|
-
logger,
|
|
409
|
-
incidentId: result.id,
|
|
410
|
-
incidentTitle: result.title,
|
|
411
|
-
systemIds: result.systemIds,
|
|
412
|
-
systemNames,
|
|
413
|
-
action: "resolved",
|
|
414
|
-
severity: result.severity,
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
return result;
|
|
467
|
+
return outcome.incident;
|
|
418
468
|
}),
|
|
419
469
|
|
|
420
470
|
deleteIncident: os.deleteIncident.handler(async ({ input }) => {
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Drive the delete through the reactive `incident` entity tombstone
|
|
428
|
-
// (§10.1). `apply` performs the REAL delete (the plugin's own db/tx);
|
|
429
|
-
// the framework records the tombstone transition and emits a tombstone
|
|
430
|
-
// change. No `incident.deleted` trigger event exists, so the deriver
|
|
431
|
-
// fires nothing. `success` tracks whether the row was actually deleted.
|
|
432
|
-
let success = false;
|
|
433
|
-
await removeIncidentEntity({
|
|
434
|
-
handle: getIncidentEntity?.(),
|
|
435
|
-
incidentId: input.id,
|
|
436
|
-
apply: async () => {
|
|
437
|
-
success = await service.deleteIncident(input.id);
|
|
438
|
-
},
|
|
439
|
-
});
|
|
471
|
+
// Shared per-id delete helper drives the reactive `incident` tombstone
|
|
472
|
+
// (§10.1) + cache invalidate + signal. `success` tracks whether the row
|
|
473
|
+
// actually existed and was deleted.
|
|
474
|
+
const status = await deleteOneIncident(input.id);
|
|
475
|
+
return { success: status === "deleted" };
|
|
476
|
+
}),
|
|
440
477
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
478
|
+
bulkDeleteIncidents: os.bulkDeleteIncidents.handler(
|
|
479
|
+
async ({ input, context }) => {
|
|
480
|
+
// The `bulkManage` instance-access mode pre-partitioned `input.ids`
|
|
481
|
+
// into the caller's manageable subset and the denied remainder. A
|
|
482
|
+
// missing partition means we cannot prove authorization, so treat the
|
|
483
|
+
// whole batch as denied (fail closed) rather than acting on any id.
|
|
484
|
+
const partition = context.bulkAccess?.ids ?? {
|
|
485
|
+
authorizedIds: [],
|
|
486
|
+
deniedIds: input.ids,
|
|
487
|
+
};
|
|
488
|
+
const results: BulkIncidentActionResult[] = [];
|
|
489
|
+
for (const id of partition.authorizedIds) {
|
|
490
|
+
try {
|
|
491
|
+
results.push({ id, status: await deleteOneIncident(id) });
|
|
492
|
+
} catch (error) {
|
|
493
|
+
// Isolate per-id failures so one bad row never aborts the batch.
|
|
494
|
+
logger.error(
|
|
495
|
+
`bulkDeleteIncidents: failed to delete incident ${id}`,
|
|
496
|
+
error,
|
|
497
|
+
);
|
|
498
|
+
results.push({ id, status: "error" });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const id of partition.deniedIds) {
|
|
502
|
+
results.push({ id, status: "forbidden" });
|
|
503
|
+
}
|
|
504
|
+
return { results };
|
|
505
|
+
},
|
|
506
|
+
),
|
|
446
507
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
508
|
+
bulkResolveIncidents: os.bulkResolveIncidents.handler(
|
|
509
|
+
async ({ input, context }) => {
|
|
510
|
+
const userId =
|
|
511
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
512
|
+
const partition = context.bulkAccess?.ids ?? {
|
|
513
|
+
authorizedIds: [],
|
|
514
|
+
deniedIds: input.ids,
|
|
515
|
+
};
|
|
516
|
+
const results: BulkIncidentActionResult[] = [];
|
|
517
|
+
for (const id of partition.authorizedIds) {
|
|
518
|
+
try {
|
|
519
|
+
const outcome = await resolveOneIncident({
|
|
520
|
+
id,
|
|
521
|
+
message: input.message,
|
|
522
|
+
userId,
|
|
523
|
+
});
|
|
524
|
+
results.push({ id, status: outcome.status });
|
|
525
|
+
} catch (error) {
|
|
526
|
+
logger.error(
|
|
527
|
+
`bulkResolveIncidents: failed to resolve incident ${id}`,
|
|
528
|
+
error,
|
|
529
|
+
);
|
|
530
|
+
results.push({ id, status: "error" });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
for (const id of partition.deniedIds) {
|
|
534
|
+
results.push({ id, status: "forbidden" });
|
|
535
|
+
}
|
|
536
|
+
return { results };
|
|
537
|
+
},
|
|
538
|
+
),
|
|
455
539
|
|
|
456
540
|
hasActiveIncidentWithSuppression:
|
|
457
541
|
os.hasActiveIncidentWithSuppression.handler(async ({ input }) => {
|