From da3c681ea8c38294d93235d542d58ae239b514fb Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Mon, 28 Sep 2026 15:24:31 +0200 Subject: [PATCH] api: incident tRPC router + analytics Co-Authored-By: Claude Opus 5.5 --- packages/analytics/src/events.ts | 28 ++++ packages/api/src/edge.ts | 2 + packages/api/src/router/incident.test.ts | 74 +++++++++ packages/api/src/router/incident.ts | 201 +++++++++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 packages/api/src/router/incident.test.ts create mode 100644 packages/api/src/router/incident.ts diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index 020d6e82..24da4d7c 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -164,6 +164,34 @@ export const Events = { name: "incident_deleted", channel: "incident", }, + DeclareManagedIncident: { + name: "incident_declared", + channel: "incident_management", + }, + UpdateManagedIncident: { + name: "incident_edited", + channel: "incident_management", + }, + ChangeManagedIncidentStatus: { + name: "incident_status_changed", + channel: "incident_management", + }, + AddManagedIncidentNote: { + name: "incident_note_added", + channel: "incident_management", + }, + LinkManagedIncidentReport: { + name: "incident_report_linked", + channel: "incident_management", + }, + CloseManagedIncident: { + name: "incident_closed", + channel: "incident_management", + }, + DeleteManagedIncident: { + name: "incident_removed", + channel: "incident_management", + }, InviteUser: { name: "user_invited", channel: "team", diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts index 07ff1bca..d9901e35 100644 --- a/packages/api/src/edge.ts +++ b/packages/api/src/edge.ts @@ -5,6 +5,7 @@ import { domainRouter } from "./router/domain"; import { externalServiceRouter } from "./router/externalService"; import { feedbackRouter } from "./router/feedback"; import { importRouter } from "./router/import"; +import { incidentRouter } from "./router/incident"; import { invitationRouter } from "./router/invitation"; import { maintenanceRouter } from "./router/maintenance"; import { memberRouter } from "./router/member"; @@ -37,6 +38,7 @@ export const edgeRouter = createTRPCRouter({ notification: notificationRouter, invitation: invitationRouter, monitorIncident: monitorIncidentRouter, + incident: incidentRouter, pageSubscriber: pageSubscriberRouter, tinybird: tinybirdRouter, monitorTag: monitorTagRouter, diff --git a/packages/api/src/router/incident.test.ts b/packages/api/src/router/incident.test.ts new file mode 100644 index 00000000..b464617c --- /dev/null +++ b/packages/api/src/router/incident.test.ts @@ -0,0 +1,74 @@ +import { db, eq, inArray } from "@openstatus/db"; +import { incident, incidentEvent } from "@openstatus/db/src/schema"; +import { createIncident } from "@openstatus/db/src/test/factories"; +import { clearAuditLogFor } from "@openstatus/services/test/helpers"; +import { expect } from "@std/expect"; +import { afterAll, test } from "@std/testing/bdd"; +import { TRPCError } from "@trpc/server"; + +import { edgeRouter } from "../edge"; +import { createInnerTRPCContext } from "../trpc"; + +const created: number[] = []; + +function caller() { + const ctx = createInnerTRPCContext({ + req: undefined, + session: { user: { id: "1" } }, + // @ts-expect-error - minimal workspace for test + workspace: { id: 1 }, + }); + return edgeRouter.createCaller(ctx); +} + +afterAll(async () => { + if (created.length === 0) return; + const events = await db + .delete(incidentEvent) + .where(inArray(incidentEvent.incidentId, created)) + .returning({ id: incidentEvent.id }); + await db.delete(incident).where(inArray(incident.id, created)); + await clearAuditLogFor({ entityType: "incident", entityIds: created }); + await clearAuditLogFor({ + entityType: "incident_event", + entityIds: events.map((e) => e.id), + }); +}); + +test("declare, move and read an incident through tRPC", async () => { + const api = caller(); + const row = await api.incident.declare({ + title: "Router test", + severity: "minor", + commanderId: 1, + }); + if (!row) throw new Error("declare returned nothing"); + created.push(row.id); + + await api.incident.setStatus({ id: row.id, status: "mitigated" }); + const got = await api.incident.get({ id: row.id }); + expect(got?.status).toBe("mitigated"); + expect(got?.allowedTransitions).toEqual(["resolved", "open", "canceled"]); + expect(got?.deletable).toBe(false); + + const events = await api.incident.listEvents({ id: row.id }); + expect(events?.map((e) => e.type)).toEqual(["status_changed", "declared"]); +}); + +test("another workspace's incident is not found", async () => { + const theirs = await createIncident(3); + created.push(theirs.id); + try { + await caller().incident.get({ id: theirs.id }); + throw new Error("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("NOT_FOUND"); + } + const stillThere = await db + .select() + .from(incident) + .where(eq(incident.id, theirs.id)) + .get(); + expect(stillThere).toBeDefined(); +}); diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts new file mode 100644 index 00000000..1ebdbd20 --- /dev/null +++ b/packages/api/src/router/incident.ts @@ -0,0 +1,201 @@ +import { Events } from "@openstatus/analytics"; +import { incidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { + AddIncidentNoteInput, + BindIncidentSlackChannelInput, + DeclareIncidentInput, + IncidentIdInput, + LinkIncidentStatusReportInput, + SetIncidentStatusInput, + UpdateIncidentInput, + addIncidentNote, + allowedTransitions, + bindIncidentSlackChannel, + closeIncident, + declareIncident, + deleteIncident, + getIncident, + isDeletable, + linkIncidentStatusReport, + listIncidentEvents, + listIncidents, + setIncidentStatus, + unbindIncidentSlackChannel, + unlinkIncidentStatusReport, + updateIncident, +} from "@openstatus/services/incident"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { toServiceCtx, toTRPCError } from "../service-adapter"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const incidentRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z + .object({ + status: z.array(z.enum(incidentStatus)).optional(), + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), + }) + .optional(), + ) + .query(async ({ ctx, input }) => { + try { + return await listIncidents({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + get: protectedProcedure + .input(IncidentIdInput) + .query(async ({ ctx, input }) => { + try { + const row = await getIncident({ ctx: toServiceCtx(ctx), input }); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Incident not found", + }); + } + return { + ...row, + allowedTransitions: allowedTransitions(row), + deletable: isDeletable(row), + }; + } catch (err) { + toTRPCError(err); + } + }), + + listEvents: protectedProcedure + .input(IncidentIdInput) + .query(async ({ ctx, input }) => { + try { + return await listIncidentEvents({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + declare: protectedProcedure + .meta({ track: Events.DeclareManagedIncident, trackProps: ["severity"] }) + .input(DeclareIncidentInput) + .mutation(async ({ ctx, input }) => { + try { + return await declareIncident({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + update: protectedProcedure + .meta({ track: Events.UpdateManagedIncident }) + .input(UpdateIncidentInput) + .mutation(async ({ ctx, input }) => { + try { + return await updateIncident({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + setStatus: protectedProcedure + .meta({ track: Events.ChangeManagedIncidentStatus, trackProps: ["status"] }) + .input(SetIncidentStatusInput) + .mutation(async ({ ctx, input }) => { + try { + return await setIncidentStatus({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + addNote: protectedProcedure + .meta({ track: Events.AddManagedIncidentNote }) + .input(AddIncidentNoteInput) + .mutation(async ({ ctx, input }) => { + try { + return await addIncidentNote({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + linkStatusReport: protectedProcedure + .meta({ track: Events.LinkManagedIncidentReport }) + .input(LinkIncidentStatusReportInput) + .mutation(async ({ ctx, input }) => { + try { + return await linkIncidentStatusReport({ + ctx: toServiceCtx(ctx), + input, + }); + } catch (err) { + toTRPCError(err); + } + }), + + unlinkStatusReport: protectedProcedure + .input(IncidentIdInput) + .mutation(async ({ ctx, input }) => { + try { + return await unlinkIncidentStatusReport({ + ctx: toServiceCtx(ctx), + input, + }); + } catch (err) { + toTRPCError(err); + } + }), + + bindSlackChannel: protectedProcedure + .input(BindIncidentSlackChannelInput) + .mutation(async ({ ctx, input }) => { + try { + return await bindIncidentSlackChannel({ + ctx: toServiceCtx(ctx), + input, + }); + } catch (err) { + toTRPCError(err); + } + }), + + unbindSlackChannel: protectedProcedure + .input(IncidentIdInput) + .mutation(async ({ ctx, input }) => { + try { + return await unbindIncidentSlackChannel({ + ctx: toServiceCtx(ctx), + input, + }); + } catch (err) { + toTRPCError(err); + } + }), + + close: protectedProcedure + .meta({ track: Events.CloseManagedIncident }) + .input(IncidentIdInput) + .mutation(async ({ ctx, input }) => { + try { + return await closeIncident({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), + + delete: protectedProcedure + .meta({ track: Events.DeleteManagedIncident }) + .input(IncidentIdInput) + .mutation(async ({ ctx, input }) => { + try { + await deleteIncident({ ctx: toServiceCtx(ctx), input }); + } catch (err) { + toTRPCError(err); + } + }), +}); -- 2.51.2