diff --git a/packages/services/src/incident/__tests__/hooks.test.ts b/packages/services/src/incident/__tests__/hooks.test.ts new file mode 100644 index 00000000..d494ca81 --- /dev/null +++ b/packages/services/src/incident/__tests__/hooks.test.ts @@ -0,0 +1,171 @@ +import { eq } from "@openstatus/db"; +import { incident, slackUser } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createPage, + createSlackUser, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + expectAuditRow, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB } from "../../context"; +import { removeMemberInWorkspace } from "../../member/internal"; +import { createStatusReport } from "../../status-report/create"; +import { deleteStatusReport } from "../../status-report/delete"; +import type { Workspace } from "../../types"; +import { deleteAccount } from "../../user/delete"; +import { + closeIncident, + declareIncident, + listIncidentEvents, + setIncidentStatus, +} from "../index"; + +let workspace: Workspace; +let ownerId: number; +let pageId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + ownerId = fixture.userId; + pageId = (await createPage(workspace.id)).id; +}); + +const as = (userId: number, tx: DB) => ({ + ...makeUserCtx(workspace, { userId }), + db: tx, +}); + +async function incidentRow(tx: DB, id: number) { + return tx.select().from(incident).where(eq(incident.id, id)).get(); +} + +describe("member removal", () => { + test("clears the commander of open incidents, keeps closed ones, drops slack links", async () => { + const leaver = await createUser(); + await addUserToWorkspace(leaver.id, workspace.id, "member"); + await withTestTransaction(async (tx) => { + const ctx = as(ownerId, tx); + const open = await declareIncident({ + ctx, + input: { title: "open", severity: "major", commanderId: leaver.id }, + }); + const closed = await declareIncident({ + ctx, + input: { title: "closed", severity: "minor", commanderId: leaver.id }, + }); + await setIncidentStatus({ + ctx, + input: { id: closed.id, status: "resolved" }, + }); + await closeIncident({ ctx, input: { id: closed.id } }); + const link = await createSlackUser(workspace.id, leaver.id, {}, tx); + + await removeMemberInWorkspace({ tx, ctx, userId: leaver.id }); + + expect((await incidentRow(tx, open.id))?.commanderId).toBeNull(); + expect((await incidentRow(tx, closed.id))?.commanderId).toBe(leaver.id); + const events = await listIncidentEvents({ ctx, input: { id: open.id } }); + expect(events[0].type).toBe("commander_changed"); + expect(events[0].createdBy).toBe(ownerId); + expect( + await tx + .select() + .from(slackUser) + .where(eq(slackUser.id, link.id)) + .get(), + ).toBeUndefined(); + }); + }); +}); + +describe("account deletion", () => { + test("clears the deleted user's commands and slack links", async () => { + const leaver = await createUser(); + await addUserToWorkspace(leaver.id, workspace.id, "member"); + await withTestTransaction(async (tx) => { + const open = await declareIncident({ + ctx: as(ownerId, tx), + input: { title: "open", severity: "major", commanderId: leaver.id }, + }); + await createSlackUser(workspace.id, leaver.id, {}, tx); + await deleteAccount({ ctx: as(leaver.id, tx) }); + expect((await incidentRow(tx, open.id))?.commanderId).toBeNull(); + expect( + await tx + .select() + .from(slackUser) + .where(eq(slackUser.userId, leaver.id)) + .all(), + ).toHaveLength(0); + }); + }); +}); + +describe("status reports", () => { + test("creating a report for an incident links it in the same transaction", async () => { + await withTestTransaction(async (tx) => { + const ctx = as(ownerId, tx); + const inc = await declareIncident({ + ctx, + input: { title: "API", severity: "major" }, + }); + const { statusReport } = await createStatusReport({ + ctx, + input: { + title: "API degraded", + status: "investigating", + message: "Looking into it", + date: new Date(), + pageId, + pageComponentIds: [], + incidentId: inc.id, + }, + }); + expect((await incidentRow(tx, inc.id))?.statusReportId).toBe( + statusReport.id, + ); + }); + }); + + test("deleting a linked report unlinks the incident, audited", async () => { + await withTestTransaction(async (tx) => { + const ctx = as(ownerId, tx); + const inc = await declareIncident({ + ctx, + input: { title: "API", severity: "major" }, + }); + const { statusReport } = await createStatusReport({ + ctx, + input: { + title: "API degraded", + status: "investigating", + message: "Looking into it", + date: new Date(), + pageId, + pageComponentIds: [], + incidentId: inc.id, + }, + }); + await deleteStatusReport({ ctx, input: { id: statusReport.id } }); + expect((await incidentRow(tx, inc.id))?.statusReportId).toBeNull(); + const events = await listIncidentEvents({ ctx, input: { id: inc.id } }); + expect(events[0].type).toBe("status_report_unlinked"); + await expectAuditRow({ + workspaceId: workspace.id, + action: "incident.update", + entityType: "incident", + entityId: inc.id, + db: tx, + }); + }); + }); +}); diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index 4f95532b..6fa4819d 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -9,6 +9,7 @@ export { unlinkIncidentStatusReport, } from "./link-status-report"; export { listIncidentEvents } from "./list-events"; +export { clearIncidentCommander } from "./members"; export { getIncident, listIncidents } from "./list"; export { AddIncidentNoteInput, diff --git a/packages/services/src/incident/members.ts b/packages/services/src/incident/members.ts new file mode 100644 index 00000000..822f587d --- /dev/null +++ b/packages/services/src/incident/members.ts @@ -0,0 +1,50 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { incident } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import type { DB, ServiceContext } from "../context"; +import { appendIncidentEvent } from "./internal"; + +/** + * A member who leaves stops commanding the workspace's open incidents. Closed + * incidents keep their commander: that is history. + */ +export async function clearIncidentCommander(args: { + tx: DB; + ctx: ServiceContext; + userId: number; +}): Promise { + const { tx, ctx, userId } = args; + const commanded = await tx + .select() + .from(incident) + .where( + and( + eq(incident.workspaceId, ctx.workspace.id), + eq(incident.commanderId, userId), + isNull(incident.closedAt), + ), + ) + .all(); + + for (const existing of commanded) { + const updated = await tx + .update(incident) + .set({ commanderId: 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: "commander_changed", + message: "Commander removed: they left the workspace", + }); + } +} diff --git a/packages/services/src/member/internal.ts b/packages/services/src/member/internal.ts index 29b74d36..f8bd89c4 100644 --- a/packages/services/src/member/internal.ts +++ b/packages/services/src/member/internal.ts @@ -4,7 +4,9 @@ import { z } from "zod"; import { emitAudit } from "../audit"; import { type DB, type ServiceContext } from "../context"; +import { clearIncidentCommander } from "../incident/members"; import { revokeGrantsForUser } from "../oauth/revoke"; +import { deleteSlackUserMappings } from "../slack-user/internal"; // Composite-PK rows: drizzle's createSelectSchema would flatten the join, // but the membership row has no auto-generated columns we'd want to drop @@ -61,4 +63,6 @@ export async function removeMemberInWorkspace(args: { workspaceId: ctx.workspace.id, reason: "member_removed", }); + await clearIncidentCommander({ tx, ctx, userId }); + await deleteSlackUserMappings({ tx, ctx, where: { userId } }); } diff --git a/packages/services/src/status-report/create.ts b/packages/services/src/status-report/create.ts index 8b908125..f185778a 100644 --- a/packages/services/src/status-report/create.ts +++ b/packages/services/src/status-report/create.ts @@ -9,6 +9,7 @@ import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { ConflictError, NotFoundError } from "../errors"; +import { linkIncidentStatusReport } from "../incident/link-status-report"; import type { StatusReport, StatusReportUpdate } from "../types"; import { insertUpdateComponentImpacts, @@ -110,6 +111,13 @@ export async function createStatusReport(args: { metadata: { statusReportId: newReport.id }, }); + if (input.incidentId !== undefined) { + await linkIncidentStatusReport({ + ctx: { ...ctx, db: tx }, + input: { id: input.incidentId, statusReportId: newReport.id }, + }); + } + return { statusReport: newReport, initialUpdate }; }); } diff --git a/packages/services/src/status-report/delete.ts b/packages/services/src/status-report/delete.ts index d242e295..0ff50ed7 100644 --- a/packages/services/src/status-report/delete.ts +++ b/packages/services/src/status-report/delete.ts @@ -4,6 +4,7 @@ import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; +import { unlinkIncidentFromStatusReport } from "../incident/link-status-report"; import { recomputeReportStatus } from "./derive-status"; import { getReportInWorkspace, getReportUpdateInWorkspace } from "./internal"; import { @@ -30,6 +31,11 @@ export async function deleteStatusReport(args: { workspaceId: ctx.workspace.id, }); + await unlinkIncidentFromStatusReport({ + tx, + ctx, + statusReportId: report.id, + }); await tx.delete(statusReport).where(eq(statusReport.id, report.id)); await emitAudit(tx, ctx, { diff --git a/packages/services/src/status-report/schemas.ts b/packages/services/src/status-report/schemas.ts index b4e17a1b..2bed4e95 100644 --- a/packages/services/src/status-report/schemas.ts +++ b/packages/services/src/status-report/schemas.ts @@ -35,6 +35,8 @@ export const CreateStatusReportInput = z.object({ pageComponentIds: z.array(z.number().int()).default([]), /** Per-component impact set by the initial update. Absent ⇒ legacy report. */ componentImpacts: componentImpactsSchema.optional(), + /** Incident this report communicates; linked in the same transaction. */ + incidentId: z.number().int().optional(), }); export type CreateStatusReportInput = z.infer; diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts index 5f955c1c..e71cd313 100644 --- a/packages/services/src/user/delete.ts +++ b/packages/services/src/user/delete.ts @@ -22,10 +22,12 @@ import { PreconditionFailedError, UnauthorizedError, } from "../errors"; +import { clearIncidentCommander } from "../incident/members"; import { deleteMonitors } from "../monitor/delete"; import { deleteNotification } from "../notification/delete"; import { revokeGrantsForUser } from "../oauth/revoke"; import { deletePage } from "../page/delete"; +import { deleteSlackUserMappings } from "../slack-user/internal"; import { DeleteAccountInput } from "./schemas"; /** @@ -167,6 +169,20 @@ export async function deleteAccount(args: { await revokeGrantsForUser({ tx, ctx, userId, reason: "account_deleted" }); + const memberships = await tx.query.usersToWorkspaces.findMany({ + where: eq(usersToWorkspaces.userId, userId), + with: { workspace: true }, + }); + for (const { workspace: rawWorkspace } of memberships) { + const subCtx: ServiceContext = { + ...ctx, + workspace: selectWorkspaceSchema.parse(rawWorkspace), + db: tx, + }; + await clearIncidentCommander({ tx, ctx: subCtx, userId }); + await deleteSlackUserMappings({ tx, ctx: subCtx, where: { userId } }); + } + await tx .delete(usersToWorkspaces) .where(