diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 73368577..4745cac3 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -1,5 +1,6 @@ import { Events } from "@openstatus/analytics"; import { incidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { sendIncidentCommander } from "@openstatus/emails"; import { AddIncidentNoteInput, BindIncidentSlackChannelInput, @@ -31,6 +32,37 @@ import { z } from "zod"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +const DASHBOARD_URL = + process.env.NODE_ENV === "production" + ? "https://app.openstatus.dev" + : "http://localhost:3001"; + +/** Best effort: a failed email never fails the mutation that assigned them. */ +async function notifyCommander( + ctx: Parameters[0], + incidentId: number, +) { + try { + const row = await getIncident({ + ctx: toServiceCtx(ctx), + input: { id: incidentId }, + }); + const commander = row?.commander; + if (!row || !commander?.email || commander.id === ctx.user.id) return; + await sendIncidentCommander({ + to: commander.email, + incidentTitle: row.title, + severity: row.severity, + workspaceName: ctx.workspace.name ?? ctx.workspace.slug, + assignedBy: ctx.user.name || ctx.user.email || "A teammate", + url: `${DASHBOARD_URL}/incidents/${row.id}`, + idempotencyKey: `incident-commander:${row.id}:${commander.id}:${row.updatedAt.getTime()}`, + }); + } catch (err) { + console.warn("incident commander email failed", { incidentId, err }); + } +} + export const incidentRouter = createTRPCRouter({ list: protectedProcedure .input( @@ -101,7 +133,9 @@ export const incidentRouter = createTRPCRouter({ .input(DeclareIncidentInput) .mutation(async ({ ctx, input }) => { try { - return await declareIncident({ ctx: toServiceCtx(ctx), input }); + const row = await declareIncident({ ctx: toServiceCtx(ctx), input }); + if (row.commanderId !== null) await notifyCommander(ctx, row.id); + return row; } catch (err) { toTRPCError(err); } @@ -112,7 +146,18 @@ export const incidentRouter = createTRPCRouter({ .input(UpdateIncidentInput) .mutation(async ({ ctx, input }) => { try { - return await updateIncident({ ctx: toServiceCtx(ctx), input }); + const before = await getIncident({ + ctx: toServiceCtx(ctx), + input: { id: input.id }, + }); + const row = await updateIncident({ ctx: toServiceCtx(ctx), input }); + if ( + row.commanderId !== null && + row.commanderId !== before?.commanderId + ) { + await notifyCommander(ctx, row.id); + } + return row; } catch (err) { toTRPCError(err); } diff --git a/packages/emails/emails/incident-commander.tsx b/packages/emails/emails/incident-commander.tsx new file mode 100644 index 00000000..abb65a53 --- /dev/null +++ b/packages/emails/emails/incident-commander.tsx @@ -0,0 +1,61 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Actions } from "./_components/actions"; +import { Footer } from "./_components/footer"; +import { Heading } from "./_components/heading"; +import { KeyValue } from "./_components/key-value"; +import { Layout } from "./_components/layout"; + +export interface IncidentCommanderProps { + incidentTitle: string; + severity: "critical" | "major" | "minor"; + workspaceName: string; + assignedBy: string; + url: string; +} + +export function incidentCommanderSubject( + props: IncidentCommanderProps, +): string { + return `You are commander of ${props.incidentTitle}`; +} + +const tone = { + critical: "danger", + major: "warning", + minor: "info", +} as const; + +const IncidentCommanderEmail = (props: IncidentCommanderProps) => { + return ( + + } + > + + {`${props.assignedBy} made you the commander of this incident in ${props.workspaceName}. You lead the response: keep the timeline current, decide when it is resolved, and close it with a postmortem.`} + + + + + ); +}; + +IncidentCommanderEmail.PreviewProps = { + incidentTitle: "Checkout is failing", + severity: "critical", + workspaceName: "Acme", + assignedBy: "Max", + url: "https://app.openstatus.dev/incidents/1", +} satisfies IncidentCommanderProps; + +export default IncidentCommanderEmail; diff --git a/packages/emails/src/incident.tsx b/packages/emails/src/incident.tsx new file mode 100644 index 00000000..40d96bea --- /dev/null +++ b/packages/emails/src/incident.tsx @@ -0,0 +1,26 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import IncidentCommanderEmail, { + type IncidentCommanderProps, + incidentCommanderSubject, +} from "../emails/incident-commander"; +import { sendEmail } from "./send"; + +const SYSTEM_FROM = "openstatus "; +const SUPPORT_EMAIL = "ping@openstatus.dev"; + +export async function sendIncidentCommander( + req: IncidentCommanderProps & { to: string; idempotencyKey?: string }, +) { + const { to, idempotencyKey, ...props } = req; + return sendEmail( + { + from: SYSTEM_FROM, + reply_to: SUPPORT_EMAIL, + to: [to], + subject: incidentCommanderSubject(props), + react: , + }, + { idempotencyKey }, + ); +} diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index 24220f1d..c5257906 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -27,4 +27,6 @@ export { stripeIdempotencyKey, } from "./billing"; +export { sendIncidentCommander } from "./incident"; + export { EmailClient } from "./client"; diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx index b56f5789..456cdc1a 100644 --- a/packages/emails/src/templates.test.tsx +++ b/packages/emails/src/templates.test.tsx @@ -13,6 +13,9 @@ import { renderMarkdown } from "../emails/_components/markdown"; import { Pill } from "../emails/_components/pill"; import { Steps } from "../emails/_components/steps"; import { tones } from "../emails/_components/styles"; +import IncidentCommanderEmail, { + incidentCommanderSubject, +} from "../emails/incident-commander"; import MonitorAlertEmail, { type MonitorAlertProps, monitorAlertPreheader, @@ -680,6 +683,9 @@ describe("every transactional template", () => { subscription: , magicLink: , welcome: , + incidentCommander: ( + + ), }; for (const [name, element] of Object.entries(all)) { @@ -707,3 +713,22 @@ describe("WelcomeEmail", () => { expect(withoutTrial).not.toContain("trial"); }); }); + +describe("IncidentCommanderEmail", () => { + test("names the incident, the assigner and links to it", async () => { + const props = { + incidentTitle: "Checkout is failing", + severity: "critical" as const, + workspaceName: "Acme", + assignedBy: "Max", + url: "https://app.openstatus.dev/incidents/7", + }; + const html = await render(); + expect(html).toContain("Checkout is failing"); + expect(html).toContain("Max made you the commander"); + expect(html).toContain("https://app.openstatus.dev/incidents/7"); + expect(incidentCommanderSubject(props)).toBe( + "You are commander of Checkout is failing", + ); + }); +});