@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 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.8.6",
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.0",
18
- "@checkstack/ai-common": "0.6.2",
19
- "@checkstack/backend-api": "0.26.1",
20
- "@checkstack/cache-api": "0.3.15",
21
- "@checkstack/cache-utils": "0.2.20",
22
- "@checkstack/incident-common": "1.6.4",
23
- "@checkstack/catalog-common": "2.5.0",
24
- "@checkstack/catalog-backend": "1.6.0",
25
- "@checkstack/notification-common": "1.4.2",
26
- "@checkstack/auth-common": "0.11.2",
27
- "@checkstack/command-backend": "0.2.14",
28
- "@checkstack/signal-common": "0.2.13",
29
- "@checkstack/status-page-backend": "0.4.1",
30
- "@checkstack/status-page-common": "0.4.1",
31
- "@checkstack/integration-backend": "0.6.7",
32
- "@checkstack/integration-common": "0.9.4",
33
- "@checkstack/automation-backend": "0.10.2",
34
- "@checkstack/automation-common": "0.8.2",
35
- "@checkstack/common": "0.18.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.6.5",
44
- "@checkstack/test-utils-backend": "0.1.48",
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
- const exists = await service.getIncident(input.id);
365
- if (!exists) {
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
- // Get incident before deleting to get systemIds.
422
- const incident = await service.getIncident(input.id);
423
- if (!incident) {
424
- return { success: false };
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
- if (success) {
442
- await cache.invalidateForMutation({
443
- incidentId: input.id,
444
- systemIds: incident.systemIds,
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
- await signalService.broadcast(INCIDENT_UPDATED, {
448
- incidentId: input.id,
449
- systemIds: incident.systemIds,
450
- action: "deleted",
451
- });
452
- }
453
- return { success };
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 }) => {