diff --git a/packages/services/package.json b/packages/services/package.json index 483920fd..db5cd30a 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -85,6 +85,10 @@ "import": "./src/import/index.ts", "types": "./src/import/index.ts" }, + "./incident": { + "import": "./src/incident/index.ts", + "types": "./src/incident/index.ts" + }, "./audit": { "import": "./src/audit/index.ts", "types": "./src/audit/index.ts" @@ -156,7 +160,7 @@ }, "scripts": { "check": "deno check --sloppy-imports .", - "test": "NODE_ENV=test RESEND_API_KEY=test-key deno test --parallel -A --no-check --sloppy-imports", + "test": "NODE_ENV=test RESEND_API_KEY=test-key OPENSTATUS_FEATURES=incident-management deno test --parallel -A --no-check --sloppy-imports", "tsc": "tsc --noEmit" }, "dependencies": { diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts new file mode 100644 index 00000000..00646ce3 --- /dev/null +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -0,0 +1,698 @@ +import { and, db, eq, sql } from "@openstatus/db"; +import { + auditLog, + type IncidentStatus, + incident, + incidentEvent, + incidentStatus, + monitorIncidentTable, + statusReport, +} from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createIncident, + createMonitor, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + clearAuditLogFor, + createWorkspaceFixture, + expectAuditRow, + makeApiKeyCtx, + makeUserCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { + ConflictError, + ForbiddenError, + NotFoundError, + ValidationError, +} from "../../errors"; +import type { Workspace } from "../../types"; +import { + addIncidentNote, + allowedTransitions, + bindIncidentSlackChannel, + closeIncident, + declareIncident, + deleteIncident, + getIncident, + linkIncidentStatusReport, + listIncidentEvents, + listIncidents, + setIncidentStatus, + unbindIncidentSlackChannel, + unlinkIncidentStatusReport, + updateIncident, +} from "../index"; + +let workspace: Workspace; +let ownerId: number; +let adminId: number; +let memberId: number; +let outsiderId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + ownerId = fixture.userId; + adminId = (await createUser()).id; + memberId = (await createUser()).id; + outsiderId = (await createUser()).id; + await addUserToWorkspace(adminId, workspace.id, "admin"); + await addUserToWorkspace(memberId, workspace.id, "member"); +}); + +const as = (userId: number, tx: DB): ServiceContext => ({ + ...makeUserCtx(workspace, { userId }), + db: tx, +}); + +async function declare(tx: DB, userId = memberId) { + return declareIncident({ + ctx: as(userId, tx), + input: { title: "API down", severity: "major", commanderId: memberId }, + }); +} + +async function eventCount(tx: DB, incidentId: number) { + const rows = await tx + .select({ id: incidentEvent.id }) + .from(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)) + .all(); + return rows.length; +} + +async function eventAuditCount(tx: DB, incidentId: number) { + const rows = await tx + .select({ id: auditLog.id }) + .from(auditLog) + .where( + and( + eq(auditLog.workspaceId, workspace.id), + eq(auditLog.action, "incident_event.create"), + sql`json_extract(${auditLog.metadata}, '$.incidentId') = ${incidentId}`, + ), + ) + .all(); + return rows.length; +} + +async function expectInvariant(tx: DB, incidentId: number) { + expect(await eventAuditCount(tx, incidentId)).toBe( + await eventCount(tx, incidentId), + ); +} + +async function incidentAudits(tx: DB, incidentId: number) { + return readAuditLog({ + workspaceId: workspace.id, + entityType: "incident", + entityId: incidentId, + db: tx, + }); +} + +describe("declareIncident", () => { + test("creates the incident, its declared event and both audit rows", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + expect(row.status).toBe("open"); + expect(row.declaredBy).toBe(memberId); + expect(row.startedAt.getTime()).toBe(row.declaredAt.getTime()); + await expectAuditRow({ + workspaceId: workspace.id, + action: "incident.create", + entityType: "incident", + entityId: row.id, + actorType: "user", + db: tx, + }); + const events = await listIncidentEvents({ + ctx: as(memberId, tx), + input: { id: row.id }, + }); + expect(events.map((e) => e.type)).toEqual(["declared"]); + expect(events[0].createdByUser?.id).toBe(memberId); + await expectInvariant(tx, row.id); + }); + }); + + test("declaring from a monitor incident takes its start and records the source", async () => { + await withTestTransaction(async (tx) => { + const monitor = await createMonitor(workspace.id, {}, tx); + const startedAt = new Date(Date.now() - 3 * 60 * 60 * 1000); + startedAt.setMilliseconds(0); + const downtime = await tx + .insert(monitorIncidentTable) + .values({ workspaceId: workspace.id, monitorId: monitor.id, startedAt }) + .returning() + .get(); + const row = await declareIncident({ + ctx: as(memberId, tx), + input: { + title: "Checkout down", + severity: "critical", + source: { type: "monitor_incident", id: downtime.id }, + }, + }); + expect(row.startedAt.getTime()).toBe(startedAt.getTime()); + const [created] = await incidentAudits(tx, row.id); + expect(created.metadata).toEqual({ + source: "monitor_incident", + ref: downtime.id, + }); + }); + }); + + test("links a status report at declare time, once", async () => { + await withTestTransaction(async (tx) => { + const report = await tx + .insert(statusReport) + .values({ + workspaceId: workspace.id, + title: "Degraded", + status: "investigating", + }) + .returning() + .get(); + const row = await declareIncident({ + ctx: as(memberId, tx), + input: { + title: "Linked", + severity: "minor", + statusReportId: report.id, + }, + }); + expect(row.statusReportId).toBe(report.id); + const events = await listIncidentEvents({ + ctx: as(memberId, tx), + input: { id: row.id }, + }); + expect(events.map((e) => e.type).sort()).toEqual([ + "declared", + "status_report_linked", + ]); + await expect( + declareIncident({ + ctx: as(memberId, tx), + input: { + title: "Second", + severity: "minor", + statusReportId: report.id, + }, + }), + ).rejects.toThrow(ConflictError); + await expectInvariant(tx, row.id); + }); + }); + + test("a commander must be a member", async () => { + await withTestTransaction(async (tx) => { + await expect( + declareIncident({ + ctx: as(memberId, tx), + input: { title: "x", severity: "minor", commanderId: outsiderId }, + }), + ).rejects.toThrow(ValidationError); + }); + }); + + test("rejects a read-only API key", async () => { + await expect( + declareIncident({ + ctx: makeApiKeyCtx(workspace, { keyId: "k", scopes: ["read"] }), + input: { title: "x", severity: "minor" }, + }), + ).rejects.toThrow(ForbiddenError); + }); +}); + +describe("updateIncident", () => { + test("one event per tracked field, one incident.update", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const startedAt = new Date(row.startedAt.getTime() - 60_000); + await updateIncident({ + ctx: as(memberId, tx), + input: { + id: row.id, + severity: "critical", + commanderId: adminId, + startedAt, + title: "API fully down", + }, + }); + const events = await listIncidentEvents({ + ctx: as(memberId, tx), + input: { id: row.id }, + }); + expect(events.map((e) => e.type).sort()).toEqual([ + "commander_changed", + "declared", + "severity_changed", + "started_at_changed", + ]); + const updates = (await incidentAudits(tx, row.id)).filter( + (a) => a.action === "incident.update", + ); + expect(updates).toHaveLength(1); + expect(updates[0].changedFields?.sort()).toEqual([ + "commanderId", + "severity", + "startedAt", + "title", + ]); + await expectInvariant(tx, row.id); + }); + }); + + test("a title-only edit writes no event, a no-op writes nothing", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + await updateIncident({ + ctx: as(memberId, tx), + input: { id: row.id, title: "Renamed" }, + }); + await updateIncident({ + ctx: as(memberId, tx), + input: { id: row.id, title: "Renamed" }, + }); + expect(await eventCount(tx, row.id)).toBe(1); + const updates = (await incidentAudits(tx, row.id)).filter( + (a) => a.action === "incident.update", + ); + expect(updates).toHaveLength(1); + }); + }); +}); + +describe("setIncidentStatus", () => { + const cases: Array<[IncidentStatus, IncidentStatus, boolean]> = []; + for (const from of incidentStatus) { + for (const to of incidentStatus) { + cases.push([ + from, + to, + allowedTransitions({ status: from, closedAt: null }).includes(to), + ]); + } + } + + test("follows the transition table", async () => { + const expected: Record = { + open: ["mitigated", "resolved", "canceled"], + mitigated: ["open", "resolved", "canceled"], + resolved: ["open"], + canceled: [], + }; + for (const [from, to, allowed] of cases) { + expect(allowed).toBe(expected[from].includes(to)); + await withTestTransaction(async (tx) => { + const row = await createIncident( + workspace.id, + { + status: from, + closedAt: from === "canceled" ? new Date() : null, + }, + tx, + ); + const run = setIncidentStatus({ + ctx: as(memberId, tx), + input: { id: row.id, status: to }, + }); + if (allowed) { + expect((await run).status).toBe(to); + } else { + await expect(run).rejects.toThrow(ConflictError); + } + }); + } + }); + + test("a closed incident accepts no transition", async () => { + await withTestTransaction(async (tx) => { + const row = await createIncident( + workspace.id, + { status: "resolved", resolvedAt: new Date(), closedAt: new Date() }, + tx, + ); + await expect( + setIncidentStatus({ + ctx: as(memberId, tx), + input: { id: row.id, status: "open" }, + }), + ).rejects.toThrow(ConflictError); + }); + }); + + test("timestamps: mitigate once, resolve, reopen keeps resolved_at, cancel closes", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const ctx = as(memberId, tx); + const mitigated = await setIncidentStatus({ + ctx, + input: { id: row.id, status: "mitigated", note: "Rolled back" }, + }); + expect(mitigated.mitigatedAt).not.toBeNull(); + await setIncidentStatus({ ctx, input: { id: row.id, status: "open" } }); + const again = await setIncidentStatus({ + ctx, + input: { id: row.id, status: "mitigated" }, + }); + expect(again.mitigatedAt?.getTime()).toBe( + mitigated.mitigatedAt?.getTime(), + ); + const resolved = await setIncidentStatus({ + ctx, + input: { id: row.id, status: "resolved" }, + }); + expect(resolved.resolvedBy).toBe(memberId); + const reopened = await setIncidentStatus({ + ctx, + input: { id: row.id, status: "open" }, + }); + expect(reopened.resolvedBy).toBeNull(); + expect(reopened.resolvedAt).not.toBeNull(); + const canceled = await setIncidentStatus({ + ctx, + input: { id: row.id, status: "canceled", note: "False alarm" }, + }); + expect(canceled.closedAt).not.toBeNull(); + + const events = await listIncidentEvents({ ctx, input: { id: row.id } }); + const noted = events.find((e) => e.message?.includes("Rolled back")); + expect(noted?.type).toBe("status_changed"); + expect(events[0].type).toBe("canceled"); + expect(events[0].message).toBe("False alarm"); + await expectInvariant(tx, row.id); + }); + }); + + test("two concurrent resolves: one wins, one conflicts, one event", async () => { + const row = await createIncident(workspace.id); + try { + const ctx = makeUserCtx(workspace, { userId: memberId }); + const results = await Promise.allSettled([ + setIncidentStatus({ ctx, input: { id: row.id, status: "resolved" } }), + setIncidentStatus({ ctx, input: { id: row.id, status: "resolved" } }), + ]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((r) => r.status === "rejected"); + expect( + rejected?.status === "rejected" && + rejected.reason instanceof ConflictError, + ).toBe(true); + const resolvedEvents = await db + .select() + .from(incidentEvent) + .where( + and( + eq(incidentEvent.incidentId, row.id), + eq(incidentEvent.type, "resolved"), + ), + ) + .all(); + expect(resolvedEvents).toHaveLength(1); + } finally { + const events = await db + .delete(incidentEvent) + .where(eq(incidentEvent.incidentId, row.id)) + .returning({ id: incidentEvent.id }); + await db.delete(incident).where(eq(incident.id, row.id)); + await clearAuditLogFor({ entityType: "incident", entityIds: [row.id] }); + await clearAuditLogFor({ + entityType: "incident_event", + entityIds: events.map((e) => e.id), + }); + } + }); +}); + +describe("addIncidentNote", () => { + test("appends a note with a single audit row", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const before = (await incidentAudits(tx, row.id)).length; + const note = await addIncidentNote({ + ctx: as(memberId, tx), + input: { id: row.id, message: "Looking at the load balancer" }, + }); + expect(note.type).toBe("note"); + expect((await incidentAudits(tx, row.id)).length).toBe(before); + await expectAuditRow({ + workspaceId: workspace.id, + action: "incident_event.create", + entityType: "incident_event", + entityId: note.id, + db: tx, + }); + await expectInvariant(tx, row.id); + }); + }); + + test("a closed incident takes no notes", async () => { + await withTestTransaction(async (tx) => { + const row = await createIncident( + workspace.id, + { status: "canceled", closedAt: new Date() }, + tx, + ); + await expect( + addIncidentNote({ + ctx: as(memberId, tx), + input: { id: row.id, message: "late" }, + }), + ).rejects.toThrow(ConflictError); + }); + }); +}); + +describe("status report link", () => { + test("link and unlink, each audited with an event", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const report = await tx + .insert(statusReport) + .values({ + workspaceId: workspace.id, + title: "Outage", + status: "investigating", + }) + .returning() + .get(); + const ctx = as(memberId, tx); + const linked = await linkIncidentStatusReport({ + ctx, + input: { id: row.id, statusReportId: report.id }, + }); + expect(linked.statusReportId).toBe(report.id); + const unlinked = await unlinkIncidentStatusReport({ + ctx, + input: { id: row.id }, + }); + expect(unlinked.statusReportId).toBeNull(); + const types = (await listIncidentEvents({ ctx, input: { id: row.id } })) + .map((e) => e.type) + .sort(); + expect(types).toEqual([ + "declared", + "status_report_linked", + "status_report_unlinked", + ]); + await expectInvariant(tx, row.id); + }); + }); + + test("a report from another workspace is not found", async () => { + const other = await createWorkspaceFixture("team"); + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const report = await tx + .insert(statusReport) + .values({ + workspaceId: other.workspace.id, + title: "Theirs", + status: "investigating", + }) + .returning() + .get(); + await expect( + linkIncidentStatusReport({ + ctx: as(memberId, tx), + input: { id: row.id, statusReportId: report.id }, + }), + ).rejects.toThrow(NotFoundError); + }); + }); +}); + +describe("slack channel binding", () => { + test("bind, conflict on a second incident, unbind even when closed", async () => { + await withTestTransaction(async (tx) => { + const ctx = as(memberId, tx); + const first = await declare(tx); + const second = await declare(tx); + const bound = await bindIncidentSlackChannel({ + ctx, + input: { id: first.id, teamId: "T1", channelId: "C_INC" }, + }); + expect(bound.slackChannelId).toBe("C_INC"); + await expect( + bindIncidentSlackChannel({ + ctx, + input: { id: second.id, teamId: "T1", channelId: "C_INC" }, + }), + ).rejects.toThrow(ConflictError); + await setIncidentStatus({ + ctx, + input: { id: first.id, status: "canceled" }, + }); + const unbound = await unbindIncidentSlackChannel({ + ctx, + input: { id: first.id }, + }); + expect(unbound.slackChannelId).toBeNull(); + await expectInvariant(tx, first.id); + }); + }); +}); + +describe("closeIncident", () => { + test("a member who isn't commander cannot close; the commander can", async () => { + await withTestTransaction(async (tx) => { + const row = await declareIncident({ + ctx: as(ownerId, tx), + input: { title: "x", severity: "minor", commanderId: adminId }, + }); + await setIncidentStatus({ + ctx: as(memberId, tx), + input: { id: row.id, status: "resolved" }, + }); + await expect( + closeIncident({ ctx: as(memberId, tx), input: { id: row.id } }), + ).rejects.toThrow(ForbiddenError); + const closed = await closeIncident({ + ctx: as(adminId, tx), + input: { id: row.id }, + }); + expect(closed.closedAt).not.toBeNull(); + await expectInvariant(tx, row.id); + }); + }); + + test("the commander closes even as a plain member", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + await setIncidentStatus({ + ctx: as(memberId, tx), + input: { id: row.id, status: "resolved" }, + }); + const closed = await closeIncident({ + ctx: as(memberId, tx), + input: { id: row.id }, + }); + expect(closed.closedAt).not.toBeNull(); + }); + }); + + test("only a resolved incident closes", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + await expect( + closeIncident({ ctx: as(ownerId, tx), input: { id: row.id } }), + ).rejects.toThrow(ConflictError); + }); + }); +}); + +describe("deleteIncident", () => { + test("an admin deletes a fresh incident, timeline included", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + await deleteIncident({ ctx: as(adminId, tx), input: { id: row.id } }); + expect(await eventCount(tx, row.id)).toBe(0); + await expectAuditRow({ + workspaceId: workspace.id, + action: "incident.delete", + entityType: "incident", + entityId: row.id, + db: tx, + }); + await expect( + getIncident({ ctx: as(adminId, tx), input: { id: row.id } }), + ).resolves.toBeUndefined(); + }); + }); + + test("a member cannot delete", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + await expect( + deleteIncident({ ctx: as(memberId, tx), input: { id: row.id } }), + ).rejects.toThrow(ForbiddenError); + }); + }); + + test("an incident that was ever mitigated is history", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx); + const ctx = as(memberId, tx); + await setIncidentStatus({ + ctx, + input: { id: row.id, status: "mitigated" }, + }); + await setIncidentStatus({ ctx, input: { id: row.id, status: "open" } }); + await expect( + deleteIncident({ ctx: as(ownerId, tx), input: { id: row.id } }), + ).rejects.toThrow(ConflictError); + }); + }); +}); + +describe("reads", () => { + test("list puts open incidents first and filters by status", async () => { + await withTestTransaction(async (tx) => { + const resolved = await createIncident( + workspace.id, + { status: "resolved", declaredAt: new Date() }, + tx, + ); + const open = await createIncident( + workspace.id, + { status: "open", declaredAt: new Date(Date.now() - 86_400_000) }, + tx, + ); + const ctx = as(memberId, tx); + const ids = (await listIncidents({ ctx })).map((i) => i.id); + expect(ids.indexOf(open.id)).toBeLessThan(ids.indexOf(resolved.id)); + const onlyResolved = await listIncidents({ + ctx, + input: { status: ["resolved"] }, + }); + expect(onlyResolved.every((i) => i.status === "resolved")).toBe(true); + }); + }); + + test("another workspace's incident is not visible", async () => { + const other = await createWorkspaceFixture("team"); + await withTestTransaction(async (tx) => { + const theirs = await createIncident(other.workspace.id, {}, tx); + expect( + await getIncident({ ctx: as(memberId, tx), input: { id: theirs.id } }), + ).toBeUndefined(); + await expect( + addIncidentNote({ + ctx: as(memberId, tx), + input: { id: theirs.id, message: "x" }, + }), + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/packages/services/src/incident/add-note.ts b/packages/services/src/incident/add-note.ts new file mode 100644 index 00000000..d518408c --- /dev/null +++ b/packages/services/src/incident/add-note.ts @@ -0,0 +1,37 @@ +import type { IncidentEvent } from "@openstatus/db/src/schema"; + +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { + appendIncidentEvent, + assertNotClosed, + getIncidentInWorkspace, + requireIncidentFeature, +} from "./internal"; +import { AddIncidentNoteInput } from "./schemas"; + +// The audit row is `incident_event.create`, written by `appendIncidentEvent`. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function addIncidentNote(args: { + ctx: ServiceContext; + input: AddIncidentNoteInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = AddIncidentNoteInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + return appendIncidentEvent(tx, ctx, { + incidentId: existing.id, + type: "note", + message: input.message, + }); + }); +} diff --git a/packages/services/src/incident/close.ts b/packages/services/src/incident/close.ts new file mode 100644 index 00000000..614b1cb0 --- /dev/null +++ b/packages/services/src/incident/close.ts @@ -0,0 +1,65 @@ +import { eq } from "@openstatus/db"; +import { type Incident, incident } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { requireRole } from "../auth/require-role"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError } from "../errors"; +import { + appendIncidentEvent, + assertNotClosed, + getIncidentInWorkspace, + requireIncidentFeature, +} from "./internal"; +import { IncidentIdInput } from "./schemas"; + +/** Ends the incident's life: only a resolved one, by an admin, owner or its commander. */ +export async function closeIncident(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + await requireRole(tx, ctx, ["owner", "admin"], { + orUserId: existing.commanderId, + }); + if (existing.status !== "resolved") { + throw new ConflictError( + `Incident #${existing.id} must be resolved before it is closed`, + ); + } + + const now = new Date(); + const updated = await tx + .update(incident) + .set({ closedAt: now, updatedAt: now }) + .where(eq(incident.id, existing.id)) + .returning() + .get(); + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "closed", + message: "Incident closed", + createdAt: now, + }); + return updated; + }); +} diff --git a/packages/services/src/incident/declare.ts b/packages/services/src/incident/declare.ts new file mode 100644 index 00000000..52770e9f --- /dev/null +++ b/packages/services/src/incident/declare.ts @@ -0,0 +1,126 @@ +import { and, eq } from "@openstatus/db"; +import { + type Incident, + incident, + monitorIncidentTable, + statusReport, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { + type DB, + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { NotFoundError } from "../errors"; +import { + appendIncidentEvent, + assertMember, + requireIncidentFeature, +} from "./internal"; +import { assertStatusReportLinkable } from "./link-status-report"; +import { DeclareIncidentInput } from "./schemas"; + +async function sourceStartedAt( + tx: DB, + workspaceId: number, + source: DeclareIncidentInput["source"], +): Promise { + if (!source) return null; + if (source.type === "monitor_incident") { + const row = await tx + .select({ startedAt: monitorIncidentTable.startedAt }) + .from(monitorIncidentTable) + .where( + and( + eq(monitorIncidentTable.id, source.id), + eq(monitorIncidentTable.workspaceId, workspaceId), + ), + ) + .get(); + if (!row) throw new NotFoundError("monitor_incident", source.id); + return row.startedAt; + } + const row = await tx + .select({ createdAt: statusReport.createdAt }) + .from(statusReport) + .where( + and( + eq(statusReport.id, source.id), + eq(statusReport.workspaceId, workspaceId), + ), + ) + .get(); + if (!row) throw new NotFoundError("status_report", source.id); + return row.createdAt; +} + +export async function declareIncident(args: { + ctx: ServiceContext; + input: DeclareIncidentInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = DeclareIncidentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + if (input.commanderId != null) { + await assertMember(tx, ctx.workspace.id, input.commanderId); + } + if (input.statusReportId !== undefined) { + await assertStatusReportLinkable(tx, ctx.workspace.id, { + statusReportId: input.statusReportId, + }); + } + + const declaredAt = new Date(); + const startedAt = + input.startedAt ?? + (await sourceStartedAt(tx, ctx.workspace.id, input.source)) ?? + declaredAt; + + const record = await tx + .insert(incident) + .values({ + workspaceId: ctx.workspace.id, + title: input.title, + severity: input.severity, + summary: input.summary ?? null, + commanderId: input.commanderId ?? null, + declaredBy: tryGetActorUserId(ctx.actor), + declaredAt, + startedAt, + statusReportId: input.statusReportId ?? null, + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "incident.create", + entityType: "incident", + entityId: record.id, + after: record, + ...(input.source + ? { metadata: { source: input.source.type, ref: input.source.id } } + : {}), + }); + + await appendIncidentEvent(tx, ctx, { + incidentId: record.id, + type: "declared", + message: `Declared as ${input.severity}: ${input.title}`, + createdAt: declaredAt, + }); + if (input.statusReportId !== undefined) { + await appendIncidentEvent(tx, ctx, { + incidentId: record.id, + type: "status_report_linked", + message: `Linked status report #${input.statusReportId}`, + }); + } + return record; + }); +} diff --git a/packages/services/src/incident/delete.ts b/packages/services/src/incident/delete.ts new file mode 100644 index 00000000..4442c8a1 --- /dev/null +++ b/packages/services/src/incident/delete.ts @@ -0,0 +1,63 @@ +import { eq } from "@openstatus/db"; +import { incident, incidentEvent } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { requireRole } from "../auth/require-role"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError } from "../errors"; +import { getIncidentInWorkspace, requireIncidentFeature } from "./internal"; +import { IncidentIdInput } from "./schemas"; + +/** + * For incidents declared by mistake: only while open and never mitigated or + * resolved. Anything further along is history. The timeline goes with it; its + * events are already in the audit log. + */ +export async function deleteIncident(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + await requireRole(tx, ctx, ["owner", "admin"]); + if (!isDeletable(existing)) { + throw new ConflictError( + `Incident #${existing.id} has progressed and can no longer be deleted`, + ); + } + await tx + .delete(incidentEvent) + .where(eq(incidentEvent.incidentId, existing.id)); + await tx.delete(incident).where(eq(incident.id, existing.id)); + await emitAudit(tx, ctx, { + action: "incident.delete", + entityType: "incident", + entityId: existing.id, + before: existing, + }); + }); +} + +export function isDeletable(row: { + status: string; + mitigatedAt: Date | null; + resolvedAt: Date | null; + closedAt: Date | null; +}): boolean { + return ( + row.status === "open" && + row.mitigatedAt === null && + row.resolvedAt === null && + row.closedAt === null + ); +} diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts new file mode 100644 index 00000000..4f95532b --- /dev/null +++ b/packages/services/src/incident/index.ts @@ -0,0 +1,28 @@ +export { addIncidentNote } from "./add-note"; +export { closeIncident } from "./close"; +export { declareIncident } from "./declare"; +export { deleteIncident, isDeletable } from "./delete"; +export { allowedTransitions, displayName } from "./internal"; +export { + linkIncidentStatusReport, + unlinkIncidentFromStatusReport, + unlinkIncidentStatusReport, +} from "./link-status-report"; +export { listIncidentEvents } from "./list-events"; +export { getIncident, listIncidents } from "./list"; +export { + AddIncidentNoteInput, + BindIncidentSlackChannelInput, + DeclareIncidentInput, + IncidentIdInput, + LinkIncidentStatusReportInput, + ListIncidentsInput, + SetIncidentStatusInput, + UpdateIncidentInput, +} from "./schemas"; +export { setIncidentStatus } from "./set-status"; +export { + bindIncidentSlackChannel, + unbindIncidentSlackChannel, +} from "./slack-channel"; +export { updateIncident } from "./update"; diff --git a/packages/services/src/incident/internal.ts b/packages/services/src/incident/internal.ts new file mode 100644 index 00000000..47f4aa33 --- /dev/null +++ b/packages/services/src/incident/internal.ts @@ -0,0 +1,144 @@ +import { and, eq } from "@openstatus/db"; +import { + type Incident, + type IncidentEvent, + type IncidentEventType, + type IncidentStatus, + incident, + incidentEvent, + user, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { type DB, type ServiceContext, tryGetActorUserId } from "../context"; +import { ConflictError, NotFoundError, ValidationError } from "../errors"; +import { requireFeature } from "../features"; +import { getMembership } from "../member/membership"; + +export const INCIDENT_FEATURE = "incident-management"; + +export function requireIncidentFeature(ctx: ServiceContext): void { + requireFeature(ctx, INCIDENT_FEATURE); +} + +export async function getIncidentInWorkspace( + tx: DB, + workspaceId: number, + id: number, +): Promise { + const row = await tx + .select() + .from(incident) + .where(and(eq(incident.id, id), eq(incident.workspaceId, workspaceId))) + .get(); + if (!row) throw new NotFoundError("incident", id); + return row; +} + +export function assertNotClosed(row: Incident): void { + if (row.closedAt) { + throw new ConflictError(`Incident #${row.id} is closed`); + } +} + +const TRANSITIONS: Record> = { + open: ["mitigated", "resolved", "canceled"], + mitigated: ["resolved", "open", "canceled"], + resolved: ["open"], + canceled: [], +}; + +export function allowedTransitions( + row: Pick, +): ReadonlyArray { + if (row.closedAt) return []; + return TRANSITIONS[row.status]; +} + +export function assertTransition( + row: Pick, + to: IncidentStatus, +): void { + if (row.status === to) { + throw new ConflictError(`Incident #${row.id} is already ${to}`); + } + if (!allowedTransitions(row).includes(to)) { + throw new ConflictError( + `Incident #${row.id} cannot go from ${row.status} to ${to}`, + ); + } +} + +/** The only way an event is written: the row and its audit entry together. */ +export async function appendIncidentEvent( + tx: DB, + ctx: ServiceContext, + args: { + incidentId: number; + type: IncidentEventType; + message?: string | null; + createdAt?: Date; + }, +): Promise { + const event = await tx + .insert(incidentEvent) + .values({ + incidentId: args.incidentId, + type: args.type, + message: args.message ?? null, + createdBy: tryGetActorUserId(ctx.actor), + createdAt: args.createdAt ?? new Date(), + }) + .returning() + .get(); + await emitAudit(tx, ctx, { + action: "incident_event.create", + entityType: "incident_event", + entityId: event.id, + after: event, + metadata: { incidentId: args.incidentId, type: args.type }, + }); + return event; +} + +export async function assertMember( + tx: DB, + workspaceId: number, + userId: number, +): Promise { + const membership = await getMembership(tx, userId, workspaceId); + if (!membership) { + throw new ValidationError( + `User ${userId} is not a member of this workspace`, + ); + } +} + +export async function userDisplayName( + tx: DB, + userId: number | null, +): Promise { + if (userId === null) return null; + const row = await tx + .select({ + name: user.name, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + .from(user) + .where(eq(user.id, userId)) + .get(); + if (!row) return null; + return displayName(row); +} + +export function displayName(row: { + name: string | null; + firstName: string | null; + lastName: string | null; + email: string | null; +}): string { + const full = [row.firstName, row.lastName].filter(Boolean).join(" "); + return row.name || full || row.email || "Unknown user"; +} diff --git a/packages/services/src/incident/link-status-report.ts b/packages/services/src/incident/link-status-report.ts new file mode 100644 index 00000000..633d3b2d --- /dev/null +++ b/packages/services/src/incident/link-status-report.ts @@ -0,0 +1,166 @@ +import { and, eq, ne } from "@openstatus/db"; +import { + type Incident, + incident, + statusReport, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type DB, type ServiceContext, withTransaction } from "../context"; +import { ConflictError, NotFoundError } from "../errors"; +import { + appendIncidentEvent, + assertNotClosed, + getIncidentInWorkspace, + requireIncidentFeature, +} from "./internal"; +import { IncidentIdInput, LinkIncidentStatusReportInput } from "./schemas"; + +export async function assertStatusReportLinkable( + tx: DB, + workspaceId: number, + args: { statusReportId: number; incidentId?: number }, +): Promise { + const report = await tx + .select({ id: statusReport.id }) + .from(statusReport) + .where( + and( + eq(statusReport.id, args.statusReportId), + eq(statusReport.workspaceId, workspaceId), + ), + ) + .get(); + if (!report) throw new NotFoundError("status_report", args.statusReportId); + + const holder = await tx + .select({ id: incident.id }) + .from(incident) + .where( + and( + eq(incident.statusReportId, args.statusReportId), + ...(args.incidentId !== undefined + ? [ne(incident.id, args.incidentId)] + : []), + ), + ) + .get(); + if (holder) { + throw new ConflictError( + `Status report #${args.statusReportId} is already linked to incident #${holder.id}`, + ); + } +} + +async function setStatusReport( + tx: DB, + ctx: ServiceContext, + existing: Incident, + statusReportId: number | null, +): Promise { + const updated = await tx + .update(incident) + .set({ statusReportId, updatedAt: new Date() }) + .where(eq(incident.id, existing.id)) + .returning() + .get(); + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: + statusReportId === null + ? "status_report_unlinked" + : "status_report_linked", + message: + statusReportId === null + ? `Unlinked status report #${existing.statusReportId}` + : `Linked status report #${statusReportId}`, + }); + return updated; +} + +// The update and its `incident.update` audit row live in `setStatusReport`. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function linkIncidentStatusReport(args: { + ctx: ServiceContext; + input: LinkIncidentStatusReportInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = LinkIncidentStatusReportInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + if (existing.statusReportId === input.statusReportId) return existing; + if (existing.statusReportId !== null) { + throw new ConflictError( + `Incident #${existing.id} is already linked to status report #${existing.statusReportId}`, + ); + } + await assertStatusReportLinkable(tx, ctx.workspace.id, { + statusReportId: input.statusReportId, + incidentId: existing.id, + }); + return setStatusReport(tx, ctx, existing, input.statusReportId); + }); +} + +// The update and its `incident.update` audit row live in `setStatusReport`. +// oxlint-disable-next-line openstatus/services-mutation-guards +export async function unlinkIncidentStatusReport(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + if (existing.statusReportId === null) return existing; + return setStatusReport(tx, ctx, existing, null); + }); +} + +/** + * Detaches whichever incident holds the report, closed or not. Runs inside + * the status-report delete so the FK's set-null is never silent. + */ +export async function unlinkIncidentFromStatusReport(args: { + tx: DB; + ctx: ServiceContext; + statusReportId: number; +}): Promise { + const { tx, ctx } = args; + const holder = await tx + .select() + .from(incident) + .where( + and( + eq(incident.statusReportId, args.statusReportId), + eq(incident.workspaceId, ctx.workspace.id), + ), + ) + .get(); + if (!holder) return; + await setStatusReport(tx, ctx, holder, null); +} diff --git a/packages/services/src/incident/list-events.ts b/packages/services/src/incident/list-events.ts new file mode 100644 index 00000000..9675a774 --- /dev/null +++ b/packages/services/src/incident/list-events.ts @@ -0,0 +1,33 @@ +import { desc, eq } from "@openstatus/db"; +import { incidentEvent } from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { getIncidentInWorkspace, requireIncidentFeature } from "./internal"; +import { IncidentIdInput } from "./schemas"; + +/** The incident's timeline, newest first. */ +export async function listIncidentEvents(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}) { + const { ctx } = args; + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + const db = getReadDb(ctx); + const existing = await getIncidentInWorkspace(db, ctx.workspace.id, input.id); + return db.query.incidentEvent.findMany({ + where: eq(incidentEvent.incidentId, existing.id), + orderBy: [desc(incidentEvent.createdAt), desc(incidentEvent.id)], + with: { + createdByUser: { + columns: { + id: true, + name: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); +} diff --git a/packages/services/src/incident/list.ts b/packages/services/src/incident/list.ts new file mode 100644 index 00000000..764e1132 --- /dev/null +++ b/packages/services/src/incident/list.ts @@ -0,0 +1,64 @@ +import { and, desc, eq, inArray, sql } from "@openstatus/db"; +import { incident } from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { requireIncidentFeature } from "./internal"; +import { IncidentIdInput, ListIncidentsInput } from "./schemas"; + +const userColumns = { + id: true, + name: true, + firstName: true, + lastName: true, + email: true, +} as const; + +const statusOrder = sql`case ${incident.status} when 'open' then 0 when 'mitigated' then 1 when 'resolved' then 2 else 3 end`; + +/** Open incidents first, then newest declared. */ +export async function listIncidents(args: { + ctx: ServiceContext; + input?: ListIncidentsInput; +}) { + const { ctx } = args; + requireIncidentFeature(ctx); + const input = ListIncidentsInput.parse(args.input ?? {}); + + const where = and( + eq(incident.workspaceId, ctx.workspace.id), + input.status?.length ? inArray(incident.status, input.status) : undefined, + ); + return getReadDb(ctx).query.incident.findMany({ + where, + orderBy: [statusOrder, desc(incident.declaredAt)], + limit: input.limit, + offset: input.offset, + with: { + commander: { columns: userColumns }, + statusReport: { columns: { id: true, title: true, status: true } }, + }, + }); +} + +export async function getIncident(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}) { + const { ctx } = args; + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + return getReadDb(ctx).query.incident.findFirst({ + where: and( + eq(incident.id, input.id), + eq(incident.workspaceId, ctx.workspace.id), + ), + with: { + commander: { columns: userColumns }, + declaredByUser: { columns: userColumns }, + resolvedByUser: { columns: userColumns }, + statusReport: { + columns: { id: true, title: true, status: true, pageId: true }, + }, + }, + }); +} diff --git a/packages/services/src/incident/schemas.ts b/packages/services/src/incident/schemas.ts new file mode 100644 index 00000000..3b86c648 --- /dev/null +++ b/packages/services/src/incident/schemas.ts @@ -0,0 +1,73 @@ +import { + incidentSeverity, + incidentStatus, +} from "@openstatus/db/src/schema/incidents/constants"; +import { z } from "zod"; + +const id = z.number().int(); +const title = z.string().trim().min(1).max(256); +const summary = z.string().trim().max(4000); +const note = z.string().trim().min(1).max(10_000); + +export const DeclareIncidentInput = z.object({ + title, + severity: z.enum(incidentSeverity), + summary: summary.optional(), + commanderId: id.nullish(), + startedAt: z.coerce.date().optional(), + statusReportId: id.optional(), + source: z + .object({ + type: z.enum(["monitor_incident", "status_report"]), + id, + }) + .optional(), +}); +export type DeclareIncidentInput = z.infer; + +export const UpdateIncidentInput = z.object({ + id, + title: title.optional(), + severity: z.enum(incidentSeverity).optional(), + summary: summary.nullish(), + commanderId: id.nullish(), + startedAt: z.coerce.date().optional(), +}); +export type UpdateIncidentInput = z.infer; + +export const SetIncidentStatusInput = z.object({ + id, + status: z.enum(incidentStatus), + note: note.optional(), +}); +export type SetIncidentStatusInput = z.infer; + +export const AddIncidentNoteInput = z.object({ id, message: note }); +export type AddIncidentNoteInput = z.infer; + +export const LinkIncidentStatusReportInput = z.object({ + id, + statusReportId: id, +}); +export type LinkIncidentStatusReportInput = z.infer< + typeof LinkIncidentStatusReportInput +>; + +export const IncidentIdInput = z.object({ id }); +export type IncidentIdInput = z.infer; + +export const BindIncidentSlackChannelInput = z.object({ + id, + teamId: z.string().min(1), + channelId: z.string().min(1), +}); +export type BindIncidentSlackChannelInput = z.infer< + typeof BindIncidentSlackChannelInput +>; + +export const ListIncidentsInput = z.object({ + status: z.array(z.enum(incidentStatus)).optional(), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}); +export type ListIncidentsInput = z.input; diff --git a/packages/services/src/incident/set-status.ts b/packages/services/src/incident/set-status.ts new file mode 100644 index 00000000..0d906608 --- /dev/null +++ b/packages/services/src/incident/set-status.ts @@ -0,0 +1,109 @@ +import { and, eq } from "@openstatus/db"; +import { + type Incident, + type IncidentEventType, + type IncidentStatus, + incident, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { + type ServiceContext, + tryGetActorUserId, + withTransaction, +} from "../context"; +import { ConflictError } from "../errors"; +import { + appendIncidentEvent, + assertTransition, + getIncidentInWorkspace, + requireIncidentFeature, +} from "./internal"; +import { SetIncidentStatusInput } from "./schemas"; + +function eventFor( + from: IncidentStatus, + to: IncidentStatus, + note: string | undefined, +): { type: IncidentEventType; message: string } { + if (to === "resolved") { + return { type: "resolved", message: note ?? "Incident resolved" }; + } + if (to === "canceled") { + return { type: "canceled", message: note ?? "Incident canceled" }; + } + const summary = `Status changed from ${from} to ${to}`; + return { + type: "status_changed", + message: note ? `${summary}\n\n${note}` : summary, + }; +} + +/** + * Moves the incident along the transition table. The update is conditional on + * the status read, so of two concurrent calls only one lands. + */ +export async function setIncidentStatus(args: { + ctx: ServiceContext; + input: SetIncidentStatusInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = SetIncidentStatusInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertTransition(existing, input.status); + + const now = new Date(); + const patch: Partial = { + status: input.status, + updatedAt: now, + }; + if (input.status === "mitigated") { + patch.mitigatedAt = existing.mitigatedAt ?? now; + } + if (input.status === "resolved") { + patch.resolvedAt = now; + patch.resolvedBy = tryGetActorUserId(ctx.actor); + } + if (input.status === "open") { + patch.resolvedBy = null; + } + if (input.status === "canceled") { + patch.closedAt = now; + } + + const updated = await tx + .update(incident) + .set(patch) + .where( + and(eq(incident.id, existing.id), eq(incident.status, existing.status)), + ) + .returning() + .get(); + if (!updated) { + throw new ConflictError(`Incident #${existing.id} changed concurrently`); + } + + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + ...eventFor(existing.status, input.status, input.note), + createdAt: now, + }); + return updated; + }); +} diff --git a/packages/services/src/incident/slack-channel.ts b/packages/services/src/incident/slack-channel.ts new file mode 100644 index 00000000..7e1a66c5 --- /dev/null +++ b/packages/services/src/incident/slack-channel.ts @@ -0,0 +1,120 @@ +import { and, eq, ne } from "@openstatus/db"; +import { type Incident, incident } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError } from "../errors"; +import { + appendIncidentEvent, + assertNotClosed, + getIncidentInWorkspace, + requireIncidentFeature, +} from "./internal"; +import { BindIncidentSlackChannelInput, IncidentIdInput } from "./schemas"; + +export async function bindIncidentSlackChannel(args: { + ctx: ServiceContext; + input: BindIncidentSlackChannelInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = BindIncidentSlackChannelInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + if ( + existing.slackTeamId === input.teamId && + existing.slackChannelId === input.channelId + ) { + return existing; + } + const holder = await tx + .select({ id: incident.id }) + .from(incident) + .where( + and( + eq(incident.slackTeamId, input.teamId), + eq(incident.slackChannelId, input.channelId), + ne(incident.id, existing.id), + ), + ) + .get(); + if (holder) { + throw new ConflictError( + `This Slack channel is already bound to incident #${holder.id}`, + ); + } + + const updated = await tx + .update(incident) + .set({ + slackTeamId: input.teamId, + slackChannelId: input.channelId, + updatedAt: new Date(), + }) + .where(eq(incident.id, existing.id)) + .returning() + .get(); + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + metadata: { slackTeamId: input.teamId, slackChannelId: input.channelId }, + }); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "slack_channel_bound", + message: `Slack channel <#${input.channelId}> bound`, + }); + return updated; + }); +} + +/** Allowed on a closed incident: it runs when its channel is archived. */ +export async function unbindIncidentSlackChannel(args: { + ctx: ServiceContext; + input: IncidentIdInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = IncidentIdInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + if (existing.slackChannelId === null) return existing; + + const updated = await tx + .update(incident) + .set({ slackTeamId: null, slackChannelId: null, updatedAt: new Date() }) + .where(eq(incident.id, existing.id)) + .returning() + .get(); + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "slack_channel_unbound", + message: `Slack channel <#${existing.slackChannelId}> unbound`, + }); + return updated; + }); +} diff --git a/packages/services/src/incident/update.ts b/packages/services/src/incident/update.ts new file mode 100644 index 00000000..0d64b863 --- /dev/null +++ b/packages/services/src/incident/update.ts @@ -0,0 +1,101 @@ +import { eq } from "@openstatus/db"; +import { type Incident, incident } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { + appendIncidentEvent, + assertMember, + assertNotClosed, + getIncidentInWorkspace, + requireIncidentFeature, + userDisplayName, +} from "./internal"; +import { UpdateIncidentInput } from "./schemas"; + +/** Edits title, severity, summary, commander and start time. */ +export async function updateIncident(args: { + ctx: ServiceContext; + input: UpdateIncidentInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + requireIncidentFeature(ctx); + const input = UpdateIncidentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getIncidentInWorkspace( + tx, + ctx.workspace.id, + input.id, + ); + assertNotClosed(existing); + + const patch: Partial = {}; + if (input.title !== undefined && input.title !== existing.title) { + patch.title = input.title; + } + if (input.severity !== undefined && input.severity !== existing.severity) { + patch.severity = input.severity; + } + if (input.summary !== undefined && input.summary !== existing.summary) { + patch.summary = input.summary; + } + if ( + input.commanderId !== undefined && + input.commanderId !== existing.commanderId + ) { + if (input.commanderId !== null) { + await assertMember(tx, ctx.workspace.id, input.commanderId); + } + patch.commanderId = input.commanderId; + } + if ( + input.startedAt !== undefined && + input.startedAt.getTime() !== existing.startedAt.getTime() + ) { + patch.startedAt = input.startedAt; + } + if (Object.keys(patch).length === 0) return existing; + + const updated = await tx + .update(incident) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(incident.id, existing.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "incident.update", + entityType: "incident", + entityId: updated.id, + before: existing, + after: updated, + }); + + if (patch.severity !== undefined) { + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "severity_changed", + message: `Severity changed from ${existing.severity} to ${updated.severity}`, + }); + } + if (patch.commanderId !== undefined) { + const name = await userDisplayName(tx, updated.commanderId); + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "commander_changed", + message: name ? `Commander set to ${name}` : "Commander removed", + }); + } + if (patch.startedAt !== undefined) { + await appendIncidentEvent(tx, ctx, { + incidentId: updated.id, + type: "started_at_changed", + message: `Start time changed to ${updated.startedAt.toISOString()}`, + }); + } + return updated; + }); +} diff --git a/packages/services/src/types.ts b/packages/services/src/types.ts index 6579d93a..29feaa32 100644 --- a/packages/services/src/types.ts +++ b/packages/services/src/types.ts @@ -34,3 +34,11 @@ export type { } from "@openstatus/db/src/schema"; export type { ApiKey, Invitation, User } from "@openstatus/db/src/schema"; + +export type { + Incident, + IncidentEvent, + IncidentEventType, + IncidentSeverity, + IncidentStatus, +} from "@openstatus/db/src/schema";