diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx index 46d93092..62d01edd 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx @@ -32,17 +32,17 @@ export default function Page() { }), ); const sendMaintenanceUpdateMutation = useMutation( - trpc.emailRouter.sendMaintenance.mutationOptions(), + trpc.maintenance.notify.mutationOptions(), ); const createMaintenanceMutation = useMutation( trpc.maintenance.new.mutationOptions({ onSuccess: (maintenance) => { - // TODO: move to server + refetch(); if (maintenance.notifySubscribers) { - sendMaintenanceUpdateMutation.mutateAsync({ id: maintenance.id }); + sendMaintenanceUpdateMutation.mutate({ + id: maintenance.id, + }); } - // - refetch(); }, }), ); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx index a9ba537d..8bd09525 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx @@ -42,14 +42,14 @@ export default function Page() { ); const sendStatusReportUpdateMutation = useMutation( - trpc.emailRouter.sendStatusReport.mutationOptions(), + trpc.statusReport.notify.mutationOptions(), ); const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + sendStatusReportUpdateMutation.mutate({ id: update.id }); } refetch(); queryClient.invalidateQueries({ diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx index 0cd1b7c5..b860305f 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx @@ -34,18 +34,16 @@ export default function Page() { trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), ); const sendStatusReportUpdateMutation = useMutation( - trpc.emailRouter.sendStatusReport.mutationOptions(), + trpc.statusReport.notify.mutationOptions(), ); const createStatusReportMutation = useMutation( trpc.statusReport.create.mutationOptions({ - onSuccess: async (statusReport) => { - // TODO: move to server + onSuccess: (statusReport) => { if (statusReport.notifySubscribers) { - await sendStatusReportUpdateMutation.mutateAsync({ + sendStatusReportUpdateMutation.mutate({ id: statusReport.id, }); } - // refetch(); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx index 75a63056..1d452e02 100644 --- a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx +++ b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx @@ -49,16 +49,14 @@ export function DataTable({ const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const sendStatusReportUpdateMutation = useMutation( - trpc.emailRouter.sendStatusReport.mutationOptions(), + trpc.statusReport.notify.mutationOptions(), ); const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { - // TODO: move to server if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + sendStatusReportUpdateMutation.mutate({ id: update.id }); } - // queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: Number.parseInt(id), diff --git a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx index 34f19eb9..50f586df 100644 --- a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx @@ -55,7 +55,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const currentImpacts = currentImpactsFromUpdates(row.original.updates); const nextStatus = getNextStatus(row.original.status); const sendStatusReportUpdateMutation = useMutation( - trpc.emailRouter.sendStatusReport.mutationOptions(), + trpc.statusReport.notify.mutationOptions(), ); const updateStatusReportMutation = useMutation( trpc.statusReport.updateStatus.mutationOptions({ @@ -79,11 +79,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { - // TODO: move to server if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + sendStatusReportUpdateMutation.mutate({ id: update.id }); } - // queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: row.original.pageId ?? undefined, diff --git a/apps/dashboard/src/components/forms/subscriber/form.tsx b/apps/dashboard/src/components/forms/subscriber/form.tsx index a9af1623..4f923987 100644 --- a/apps/dashboard/src/components/forms/subscriber/form.tsx +++ b/apps/dashboard/src/components/forms/subscriber/form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { detectWebhookFlavor } from "@openstatus/subscriptions/client"; +import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, @@ -70,12 +71,6 @@ const formSchema = z path: ["webhookUrl"], message: "Please enter a valid URL", }); - } else if (detectWebhookFlavor(data.webhookUrl) === "generic") { - ctx.addIssue({ - code: "custom", - path: ["webhookUrl"], - message: "Only Slack and Discord webhook URLs are supported.", - }); } } }); @@ -297,8 +292,15 @@ export function FormSubscriber({ /> - Only Slack and Discord webhook URLs are supported for now - - more channels to come. + + Slack and Discord URLs receive channel-native messages; + any other URL receives a generic JSON payload. + + {/^https?:\/\//.test(field.value) ? ( + + {detectWebhookFlavor(field.value)} + + ) : null} diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index c71a76a4..e28573eb 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -104,6 +104,10 @@ export const Events = { name: "report_update_deleted", channel: "report", }, + NotifyReport: { + name: "report_notified", + channel: "report", + }, CreateMaintenance: { name: "maintenance_created", channel: "maintenance", @@ -116,6 +120,10 @@ export const Events = { name: "maintenance_deleted", channel: "maintenance", }, + NotifyMaintenance: { + name: "maintenance_notified", + channel: "maintenance", + }, CreateNotification: { name: "notification_created", channel: "notification", diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts index 636626ba..3dea10a3 100644 --- a/packages/api/src/router/email/index.ts +++ b/packages/api/src/router/email/index.ts @@ -5,14 +5,11 @@ import { selectWorkspaceSchema, } from "@openstatus/db/src/schema"; import { EmailClient } from "@openstatus/emails"; -import { notifyMaintenance } from "@openstatus/services/maintenance"; -import { notifyStatusReport } from "@openstatus/services/status-report"; import { getChannel } from "@openstatus/subscriptions"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { env } from "../../env"; -import { toServiceCtx, toTRPCError } from "../../service-adapter"; import { createTRPCRouter, protectedProcedure, @@ -122,40 +119,6 @@ export const emailRouter = createTRPCRouter({ return { success: true }; }), - /** - * PROTECTED: Send status report update notifications via dispatcher - */ - sendStatusReport: protectedProcedure - .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - try { - await notifyStatusReport({ - ctx: toServiceCtx(opts.ctx), - input: { statusReportUpdateId: opts.input.id }, - }); - return { success: true }; - } catch (err) { - toTRPCError(err); - } - }), - - /** - * PROTECTED: Send maintenance notifications via dispatcher - */ - sendMaintenance: protectedProcedure - .input(z.object({ id: z.number() })) - .mutation(async (opts) => { - try { - await notifyMaintenance({ - ctx: toServiceCtx(opts.ctx), - input: { maintenanceId: opts.input.id }, - }); - return { success: true }; - } catch (err) { - toTRPCError(err); - } - }), - sendTeamInvitation: protectedProcedure .input(z.object({ id: z.number(), baseUrl: z.string().optional() })) .mutation(async (opts) => { diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts index 12b46da7..697d523f 100644 --- a/packages/api/src/router/maintenance.ts +++ b/packages/api/src/router/maintenance.ts @@ -4,6 +4,7 @@ import { createMaintenance, deleteMaintenance, listMaintenances, + notifyMaintenance, updateMaintenance, } from "@openstatus/services/maintenance"; import { z } from "zod"; @@ -65,6 +66,21 @@ export const maintenanceRouter = createTRPCRouter({ } }), + notify: protectedProcedure + .meta({ track: Events.NotifyMaintenance }) + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + try { + await notifyMaintenance({ + ctx: toServiceCtx(ctx), + input: { maintenanceId: input.id }, + }); + return { success: true }; + } catch (err) { + toTRPCError(err); + } + }), + new: protectedProcedure .meta({ track: Events.CreateMaintenance }) .input( diff --git a/packages/api/src/router/pageSubscriber.ts b/packages/api/src/router/pageSubscriber.ts index 9af4c3ca..1e8c4750 100644 --- a/packages/api/src/router/pageSubscriber.ts +++ b/packages/api/src/router/pageSubscriber.ts @@ -13,7 +13,6 @@ import { upsertSelfSignupSubscriber, verifySelfSignupSubscriber, } from "@openstatus/services/page-subscriber"; -import { detectWebhookFlavor } from "@openstatus/subscriptions"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -30,11 +29,7 @@ const webhookHeadersSchema = z .max(20) .optional(); -const supportedWebhookUrlSchema = z - .url() - .refine((url) => detectWebhookFlavor(url) !== "generic", { - message: "Only Slack and Discord webhook URLs are supported.", - }); +const supportedWebhookUrlSchema = z.url(); // Public (status-page-facing) procedures use the same allow-list as // the protected procedures going through `toTRPCError`. Single source diff --git a/packages/api/src/router/statusReport.ts b/packages/api/src/router/statusReport.ts index 156df7f4..8074282c 100644 --- a/packages/api/src/router/statusReport.ts +++ b/packages/api/src/router/statusReport.ts @@ -10,6 +10,7 @@ import { deleteStatusReportUpdate, getStatusReport, listStatusReports, + notifyStatusReport, updateStatusReport, updateStatusReportUpdate, } from "@openstatus/services/status-report"; @@ -66,8 +67,9 @@ export const statusReportRouter = createTRPCRouter({ .input(createStatusReportTRPCInput) .mutation(async ({ ctx, input }) => { try { + const serviceCtx = toServiceCtx(ctx); const { initialUpdate } = await createStatusReport({ - ctx: toServiceCtx(ctx), + ctx: serviceCtx, input: { title: input.title, status: input.status, @@ -78,8 +80,8 @@ export const statusReportRouter = createTRPCRouter({ message: input.message, }, }); - // Preserve the original "return the initial update row with - // notifySubscribers merged in" shape the dashboard consumes. + // Notification is a separate, client-driven step (statusReport.notify) + // so creation and dispatch stay split. return { ...initialUpdate, notifySubscribers: input.notifySubscribers }; } catch (err) { toTRPCError(err); @@ -91,8 +93,9 @@ export const statusReportRouter = createTRPCRouter({ .input(createStatusReportUpdateTRPCInput) .mutation(async ({ ctx, input }) => { try { + const serviceCtx = toServiceCtx(ctx); const { statusReportUpdate } = await addStatusReportUpdate({ - ctx: toServiceCtx(ctx), + ctx: serviceCtx, input: { statusReportId: input.statusReportId, status: input.status, @@ -110,6 +113,21 @@ export const statusReportRouter = createTRPCRouter({ } }), + notify: protectedProcedure + .meta({ track: Events.NotifyReport }) + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + try { + await notifyStatusReport({ + ctx: toServiceCtx(ctx), + input: { statusReportUpdateId: input.id }, + }); + return { success: true }; + } catch (err) { + toTRPCError(err); + } + }), + updateStatusReportUpdate: protectedProcedure .meta({ track: Events.UpdateReportUpdate }) .input(updateStatusReportUpdateTRPCInput) diff --git a/packages/emails/src/client.test.ts b/packages/emails/src/client.test.ts new file mode 100644 index 00000000..cde4f7bf --- /dev/null +++ b/packages/emails/src/client.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { EmailClient } from "./client"; + +// sendStatusReportUpdate early-returns in development; force the real send path. +process.env.NODE_ENV = "test"; + +function makeSubscribers(n: number) { + return Array.from({ length: n }, (_, i) => ({ + email: `user-${i}@example.com`, + token: `token-${i}`, + })); +} + +function baseReq( + overrides: Partial[0]> = {}, +) { + return { + subscribers: makeSubscribers(1), + pageSlug: "demo", + pageTitle: "Demo", + reportTitle: "Outage", + status: "investigating" as const, + date: "2026-04-21T09:59:58Z", + message: "We are investigating.", + pageComponents: [] as string[], + ...overrides, + }; +} + +// biome-ignore lint/suspicious/noExplicitAny: test doubles for the Resend batch result +const ok = { data: { data: [] }, error: null } as any; +// biome-ignore lint/suspicious/noExplicitAny: simulated Resend application error +const fail = { data: null, error: { name: "application_error" } } as any; + +describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { + let client: EmailClient; + // biome-ignore lint/suspicious/noExplicitAny: bun spy handle + let batchSend: any; + + beforeEach(() => { + // zero backoff so the retry test doesn't wait on the real exponential sleep + client = new EmailClient({ + apiKey: "re_test_123", + retryBackoff: "0 millis", + }); + batchSend = spyOn(client.client.batch, "send").mockResolvedValue(ok); + }); + + afterEach(() => { + batchSend.mockRestore(); + }); + + test("passes the base idempotency key suffixed with the batch index", async () => { + await client.sendStatusReportUpdate( + baseReq({ idempotencyKey: "status-report-update:5" }), + ); + + expect(batchSend).toHaveBeenCalledTimes(1); + const [, options] = batchSend.mock.calls[0]; + expect(options).toEqual({ idempotencyKey: "status-report-update:5:0" }); + }); + + test("gives each 100-recipient chunk a distinct key and its own slice", async () => { + await client.sendStatusReportUpdate( + baseReq({ + subscribers: makeSubscribers(250), + idempotencyKey: "status-report-update:9", + }), + ); + + expect(batchSend).toHaveBeenCalledTimes(3); + const keys = batchSend.mock.calls.map( + // biome-ignore lint/suspicious/noExplicitAny: positional spy args + ([, o]: [unknown, any]) => o?.idempotencyKey, + ); + expect(keys).toEqual([ + "status-report-update:9:0", + "status-report-update:9:1", + "status-report-update:9:2", + ]); + const sizes = batchSend.mock.calls.map( + // biome-ignore lint/suspicious/noExplicitAny: positional spy args + ([payload]: [any[]]) => payload.length, + ); + expect(sizes).toEqual([100, 100, 50]); + }); + + test("omits the option entirely when no base key is provided", async () => { + await client.sendStatusReportUpdate(baseReq()); + + const [, options] = batchSend.mock.calls[0]; + expect(options).toBeUndefined(); + }); + + test("reuses the same key across a retry so Resend dedupes the resend", async () => { + batchSend.mockResolvedValueOnce(fail).mockResolvedValueOnce(ok); + + await client.sendStatusReportUpdate( + baseReq({ idempotencyKey: "status-report-update:7" }), + ); + + // failure → retry: the second attempt must carry the identical key, or + // Resend would treat the retry as a fresh batch and double-send. + expect(batchSend).toHaveBeenCalledTimes(2); + const keys = batchSend.mock.calls.map( + // biome-ignore lint/suspicious/noExplicitAny: positional spy args + ([, o]: [unknown, any]) => o?.idempotencyKey, + ); + expect(keys).toEqual([ + "status-report-update:7:0", + "status-report-update:7:0", + ]); + }); +}); diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index ce7b7a68..275293ef 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ -import { Effect, Schedule } from "effect"; +import { type Duration, Effect, Schedule } from "effect"; import { render } from "react-email"; import { Resend } from "resend"; @@ -37,9 +37,13 @@ function chunk(array: T[], size: number): T[][] { export class EmailClient { public readonly client: Resend; + // Base delay for the per-batch send retry. Overridable so tests can run the + // retry path without the real ~1s exponential sleep. + private readonly retryBackoff: Duration.DurationInput; - constructor(opts: { apiKey: string }) { + constructor(opts: { apiKey: string; retryBackoff?: Duration.DurationInput }) { this.client = new Resend(opts.apiKey); + this.retryBackoff = opts.retryBackoff ?? "1000 millis"; } public async sendFollowUp(req: { to: string }) { @@ -161,6 +165,10 @@ export class EmailClient { subscribers: Array<{ email: string; token: string }>; pageSlug: string; customDomain?: string | null; + // Base key for Resend idempotency. The per-batch retry below would + // otherwise re-send the whole chunk if a request succeeds server-side + // but the response is lost. Must be stable across retries. + idempotencyKey?: string; }, ) { const statusPageBaseUrl = req.customDomain @@ -176,7 +184,14 @@ export class EmailClient { return; } - for (const recipients of chunk(req.subscribers, 100)) { + const chunks = chunk(req.subscribers, 100); + for (let i = 0; i < chunks.length; i++) { + const recipients = chunks[i]; + // suffix the chunk index so a multi-batch send doesn't collide its + // own chunks on a single shared key + const batchKey = req.idempotencyKey + ? `${req.idempotencyKey}:${i}` + : undefined; const sendEmail = Effect.tryPromise({ try: () => this.client.batch.send( @@ -196,6 +211,7 @@ export class EmailClient { ), }; }), + batchKey ? { idempotencyKey: batchKey } : undefined, ), catch: (_unknown) => new Error( @@ -209,7 +225,7 @@ export class EmailClient { ), Effect.retry({ times: 3, - schedule: Schedule.exponential("1000 millis"), + schedule: Schedule.exponential(this.retryBackoff), }), ); await Effect.runPromise(sendEmail).catch(console.error); @@ -345,6 +361,7 @@ export class EmailClient { from: string; to: string; pageComponents: string[]; + idempotencyKey?: string; }) { const statusPageBaseUrl = req.customDomain ? `https://${req.customDomain}` @@ -359,7 +376,12 @@ export class EmailClient { return; } - for (const recipients of chunk(req.subscribers, 100)) { + const chunks = chunk(req.subscribers, 100); + for (let i = 0; i < chunks.length; i++) { + const recipients = chunks[i]; + const batchKey = req.idempotencyKey + ? `${req.idempotencyKey}:${i}` + : undefined; const sendEmail = Effect.tryPromise({ try: () => this.client.batch.send( @@ -384,6 +406,7 @@ export class EmailClient { ), }; }), + batchKey ? { idempotencyKey: batchKey } : undefined, ), catch: (_unknown) => new Error( @@ -397,7 +420,7 @@ export class EmailClient { ), Effect.retry({ times: 3, - schedule: Schedule.exponential("1000 millis"), + schedule: Schedule.exponential(this.retryBackoff), }), ); await Effect.runPromise(sendEmail).catch(console.error); diff --git a/packages/services/src/page-subscriber/create.ts b/packages/services/src/page-subscriber/create.ts index f845ec42..6eb1d5b1 100644 --- a/packages/services/src/page-subscriber/create.ts +++ b/packages/services/src/page-subscriber/create.ts @@ -5,7 +5,6 @@ import { pageSubscriberToPageComponent, selectPageSubscriberSchema, } from "@openstatus/db/src/schema"; -import { detectWebhookFlavor } from "@openstatus/subscriptions"; import { assertSafeUrl } from "@openstatus/utils"; import { emitAudit } from "../audit"; @@ -64,11 +63,6 @@ export async function createPageSubscriber(args: { // outside avoids holding the SQLite write lock across a network call. if (input.channelType === "webhook") { await assertSafeUrl(input.webhookUrl); - if (detectWebhookFlavor(input.webhookUrl) === "generic") { - throw new ValidationError( - "Only Slack and Discord webhook URLs are supported.", - ); - } } return withTransaction(ctx, async (tx) => { diff --git a/packages/services/src/page-subscriber/internal.ts b/packages/services/src/page-subscriber/internal.ts index c98c6bd9..a2f9986a 100644 --- a/packages/services/src/page-subscriber/internal.ts +++ b/packages/services/src/page-subscriber/internal.ts @@ -134,7 +134,6 @@ export const SAFE_SUBSCRIPTION_MESSAGES = new Set([ "Some components do not belong to this page", "A subscriber with this email already exists for this page.", "A subscriber with this webhook URL already exists for this page.", - "Only Slack and Discord webhook URLs are supported.", "Subscriber not found", "Subscriber is not a webhook channel", "Self-signup subscribers manage their own subscription; use the unsubscribe action instead.", diff --git a/packages/services/src/page-subscriber/update.ts b/packages/services/src/page-subscriber/update.ts index 0f572abc..4beb9413 100644 --- a/packages/services/src/page-subscriber/update.ts +++ b/packages/services/src/page-subscriber/update.ts @@ -6,7 +6,6 @@ import { pageSubscriberToPageComponent, selectPageSubscriberSchema, } from "@openstatus/db/src/schema"; -import { detectWebhookFlavor } from "@openstatus/subscriptions"; import { assertSafeUrl } from "@openstatus/utils"; import { emitAudit } from "../audit"; @@ -40,14 +39,9 @@ export async function updatePageSubscriberChannel(args: { // `assertSafeUrl` does a DNS lookup to block private/internal targets; // keep it outside the tx so we don't hold the SQLite write lock across - // a network call. Flavor check is pure and stays here for symmetry. + // a network call. if (input.webhookUrl !== undefined) { await assertSafeUrl(input.webhookUrl); - if (detectWebhookFlavor(input.webhookUrl) === "generic") { - throw new ValidationError( - "Only Slack and Discord webhook URLs are supported.", - ); - } } await withTransaction(ctx, async (tx) => { diff --git a/packages/subscriptions/package.json b/packages/subscriptions/package.json index 64824e69..c50cc3b9 100644 --- a/packages/subscriptions/package.json +++ b/packages/subscriptions/package.json @@ -22,6 +22,7 @@ "@openstatus/emails": "workspace:*", "@openstatus/notification-base": "workspace:*", "@openstatus/utils": "workspace:*", + "effect": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/subscriptions/src/channels/email.test.ts b/packages/subscriptions/src/channels/email.test.ts index d289ade2..6772aceb 100644 --- a/packages/subscriptions/src/channels/email.test.ts +++ b/packages/subscriptions/src/channels/email.test.ts @@ -141,4 +141,23 @@ describe("sendEmailNotifications", () => { const [args] = sendStatusReportUpdateMock.mock.calls[0]; expect(args.pageComponents).toEqual(["API", "Database"]); }); + + test("derives the idempotency key from the status-report update id", async () => { + const sub = makeSub(); + await sendEmailNotifications([sub], makeUpdate({ updateId: 77 })); + + const [args] = sendStatusReportUpdateMock.mock.calls[0]; + expect(args.idempotencyKey).toBe("status-report-update:77"); + }); + + test("falls back to a page-update key when there is no update id (maintenance)", async () => { + const sub = makeSub(); + await sendEmailNotifications( + [sub], + makeUpdate({ id: 17, updateId: undefined, status: "maintenance" }), + ); + + const [args] = sendStatusReportUpdateMock.mock.calls[0]; + expect(args.idempotencyKey).toBe("page-update:17:maintenance"); + }); }); diff --git a/packages/subscriptions/src/channels/email.ts b/packages/subscriptions/src/channels/email.ts index b82397b6..6e2cc6ba 100644 --- a/packages/subscriptions/src/channels/email.ts +++ b/packages/subscriptions/src/channels/email.ts @@ -24,6 +24,15 @@ export async function validateEmailConfig(config: unknown) { return { valid: email.success, error: email.error?.message }; } +// Stable per status-report update / maintenance so Resend dedupes the email +// retry path. Status reports key off the specific update; maintenance has no +// update row, so fall back to its id + status. +function idempotencyKeyFor(pageUpdate: PageUpdate): string { + return pageUpdate.updateId != null + ? `status-report-update:${pageUpdate.updateId}` + : `page-update:${pageUpdate.id}:${pageUpdate.status}`; +} + function hasEmailAndToken( sub: Subscription, ): sub is Subscription & { email: string; token: string } { @@ -76,5 +85,6 @@ export async function sendEmailNotifications( message: pageUpdate.message, date: pageUpdate.date, pageComponents: pageUpdate.pageComponents, + idempotencyKey: idempotencyKeyFor(pageUpdate), }); } diff --git a/packages/subscriptions/src/channels/retry.ts b/packages/subscriptions/src/channels/retry.ts new file mode 100644 index 00000000..c720ea7f --- /dev/null +++ b/packages/subscriptions/src/channels/retry.ts @@ -0,0 +1,67 @@ +import { Effect, Schedule } from "effect"; + +export class WebhookSendError extends Error { + readonly httpStatus?: number; + + constructor( + message: string, + opts?: { httpStatus?: number; cause?: unknown }, + ) { + super(message, { cause: opts?.cause }); + this.name = "WebhookSendError"; + this.httpStatus = opts?.httpStatus; + } +} + +// Network errors and timeouts (no status) are transient; 5xx and 429 may clear. +// Other 4xx are client errors that won't succeed on retry. +const isRetryable = (err: WebhookSendError): boolean => + err.httpStatus === undefined || + err.httpStatus >= 500 || + err.httpStatus === 429; + +const retryPolicy = { + schedule: Schedule.exponential("200 millis").pipe(Schedule.jittered), + times: 3, + while: isRetryable, +}; + +/** + * POST to a webhook with exponential-backoff retries on transient failures. + * Rejects with `WebhookSendError` once retries are exhausted. + */ +export function postWebhookWithRetry(opts: { + url: string; + headers: Record; + body: string; + timeoutMs: number; +}): Promise { + const send = Effect.tryPromise({ + try: (signal) => + fetch(opts.url, { + method: "POST", + headers: opts.headers, + body: opts.body, + signal, + }), + catch: (cause) => new WebhookSendError("Webhook request failed", { cause }), + }).pipe( + Effect.timeoutFail({ + duration: `${opts.timeoutMs} millis`, + onTimeout: () => + new WebhookSendError(`Webhook timed out after ${opts.timeoutMs}ms`), + }), + Effect.flatMap((response) => + response.ok + ? Effect.void + : Effect.fail( + new WebhookSendError(`Webhook returned ${response.status}`, { + httpStatus: response.status, + }), + ), + ), + Effect.retry(retryPolicy), + ); + + return Effect.runPromise(send); +} diff --git a/packages/subscriptions/src/channels/webhook.test.ts b/packages/subscriptions/src/channels/webhook.test.ts index 640e933d..a10a9b41 100644 --- a/packages/subscriptions/src/channels/webhook.test.ts +++ b/packages/subscriptions/src/channels/webhook.test.ts @@ -161,7 +161,7 @@ describe("sendWebhookNotifications", () => { expect(urls).toContain("https://hooks.slack.com/services/T2/B2/bbb"); }); - test("drops subscriptions with non-Slack/Discord URLs", async () => { + test("sends a generic JSON payload to non-Slack/Discord URLs", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 200 })); const generic = makeSub({ id: 101, @@ -169,11 +169,61 @@ describe("sendWebhookNotifications", () => { }); const slack = makeSub({ id: 102, webhookUrl: SLACK_URL }); - await sendWebhookNotifications([generic, slack], makeUpdate()); + await sendWebhookNotifications( + [generic, slack], + makeUpdate({ updateId: 42 }), + ); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url] = fetchMock.mock.calls[0]; - expect(url).toBe(SLACK_URL); + expect(fetchMock).toHaveBeenCalledTimes(2); + const byUrl = new Map( + fetchMock.mock.calls.map(([url, init]: [string, RequestInit]) => [ + url, + JSON.parse(init.body as string), + ]), + ); + expect(byUrl.get("https://example.com/webhook").type).toBe("status_report"); + expect(byUrl.get(SLACK_URL).attachments).toBeDefined(); + }); + + test("the status_report body sent over the wire satisfies webhookPayloadSchema (v1)", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + const sub = makeSub({ webhookUrl: "https://example.com/webhook" }); + + await sendWebhookNotifications( + [sub], + makeUpdate({ + status: "investigating", + updateId: 42, + componentsWithImpact: [ + { id: 7, name: "API", impact: "partial_outage" }, + ], + }), + ); + + const [, init] = fetchMock.mock.calls[0]; + const sentBody = JSON.parse(init?.body as string); + const result = webhookPayloadSchema.safeParse(sentBody); + expect(result.success).toBe(true); + }); + + test("the maintenance body sent over the wire satisfies webhookPayloadSchema (v1)", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + const sub = makeSub({ webhookUrl: "https://example.com/webhook" }); + + await sendWebhookNotifications( + [sub], + makeUpdate({ + status: "maintenance", + startsAt: "2026-04-22T02:00:00Z", + endsAt: "2026-04-22T03:00:00Z", + pageComponentsWithId: [{ id: 7, name: "API" }], + }), + ); + + const [, init] = fetchMock.mock.calls[0]; + const sentBody = JSON.parse(init?.body as string); + const result = webhookPayloadSchema.safeParse(sentBody); + expect(result.success).toBe(true); }); test("applies custom headers from channelConfig", async () => { @@ -191,14 +241,15 @@ describe("sendWebhookNotifications", () => { expect(headers["X-Custom-Header"]).toBe("my-value"); }); - test("continues sending to remaining webhooks when one fails", async () => { - fetchMock - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce(new Response(null, { status: 200 })); + test("continues sending to remaining webhooks when one keeps failing", async () => { + const failUrl = "https://hooks.slack.com/services/T1/B1/fail"; + fetchMock.mockImplementation(async (url: string) => + url === failUrl + ? Promise.reject(new Error("Network error")) + : new Response(null, { status: 200 }), + ); - const sub1 = makeSub({ - webhookUrl: "https://hooks.slack.com/services/T1/B1/fail", - }); + const sub1 = makeSub({ webhookUrl: failUrl }); const sub2 = makeSub({ webhookUrl: "https://hooks.slack.com/services/T2/B2/succeed", }); @@ -207,7 +258,34 @@ describe("sendWebhookNotifications", () => { sendWebhookNotifications([sub1, sub2], makeUpdate()), ).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); + const calls = fetchMock.mock.calls.map(([url]: [string]) => url); + expect(calls).toContain("https://hooks.slack.com/services/T2/B2/succeed"); + }); + + test("retries transient failures then succeeds", async () => { + let attempts = 0; + fetchMock.mockImplementation(async () => { + attempts += 1; + return attempts < 3 + ? new Response(null, { status: 503, statusText: "Service Unavailable" }) + : new Response(null, { status: 200 }); + }); + + await expect( + sendWebhookNotifications([makeSub()], makeUpdate()), + ).resolves.toBeUndefined(); + + expect(attempts).toBe(3); + }); + + test("does not retry non-retryable 4xx responses", async () => { + fetchMock.mockResolvedValue( + new Response(null, { status: 400, statusText: "Bad Request" }), + ); + + await sendWebhookNotifications([makeSub()], makeUpdate()); + + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -288,6 +366,49 @@ describe("sendWebhookNotifications (flavor detection)", () => { const stringified = init?.body as string; expect(stringified).toContain("https://status.partner.com/manage/tok-123"); }); + + test("Slack payload links to the event details and the page origin", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + const sub = makeSub({ pageSlug: "demo", pageName: "Demo" }); + + await sendWebhookNotifications([sub], makeUpdate({ id: 99 })); + + const [, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init?.body as string); + const stringified = init?.body as string; + // title stays in a plain_text header block + expect(body.attachments[0].blocks[0]).toMatchObject({ + type: "header", + text: { type: "plain_text", text: "Test Incident" }, + }); + expect(stringified).toContain( + "", + ); + expect(stringified).toContain(""); + }); + + test("Discord embed url points at the event; maintenance uses the maintenance path", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + const sub = makeSub({ + webhookUrl: DISCORD_URL, + pageSlug: "demo", + pageName: "Demo", + }); + + await sendWebhookNotifications( + [sub], + makeUpdate({ id: 7, status: "maintenance" }), + ); + + const [, init] = fetchMock.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.embeds[0].url).toBe( + "https://demo.openstatus.dev/events/maintenance/7", + ); + expect(body.embeds[0].fields[1].value).toBe( + "[Demo](https://demo.openstatus.dev)", + ); + }); }); // ─── buildGenericPayload (staged, not yet reachable in production) ──────────── @@ -329,11 +450,12 @@ describe("buildGenericPayload", () => { expect(payload.data.status_report).toMatchObject({ id: 12, title: "API degraded", + url: "https://acme.openstatus.dev/events/report/12", update: { id: 42, status: "investigating", message: "Looking into it.", - created_at: "2026-04-21T09:59:58Z", + occurred_at: "2026-04-21T09:59:58Z", }, page: { id: 42, @@ -379,6 +501,7 @@ describe("buildGenericPayload", () => { expect(payload.data.maintenance).toMatchObject({ id: 17, title: "DB upgrade", + url: "https://acme.openstatus.dev/events/maintenance/17", message: "Rolling primary.", starts_at: "2026-04-22T02:00:00Z", ends_at: "2026-04-22T03:00:00Z", @@ -438,13 +561,22 @@ describe("buildTestPayload", () => { expect(payload.embeds).toBeInstanceOf(Array); }); - test("generic flavor returns type='test' JSON", () => { + test("generic flavor returns enveloped type='test' JSON", () => { const payload = buildTestPayload("generic") as { + version: string; type: string; - message: string; + data: { test: { message: string; timestamp: string } }; }; + expect(payload.version).toBe("1"); expect(payload.type).toBe("test"); - expect(typeof payload.message).toBe("string"); + expect(typeof payload.data.test.message).toBe("string"); + expect(typeof payload.data.test.timestamp).toBe("string"); + }); + + test("generic output satisfies the canonical webhookPayloadSchema contract", () => { + expect( + webhookPayloadSchema.safeParse(buildTestPayload("generic")).success, + ).toBe(true); }); }); diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts index 94d0402f..c0921277 100644 --- a/packages/subscriptions/src/channels/webhook.ts +++ b/packages/subscriptions/src/channels/webhook.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { WEBHOOK_PAYLOAD_VERSION } from "../payload"; import type { PageUpdate, Subscription } from "../types"; +import { postWebhookWithRetry } from "./retry"; export type WebhookFlavor = "slack" | "discord" | "generic"; @@ -35,6 +36,16 @@ function resolveStatusPageOrigin(subscription: Subscription): string { : `https://${subscription.pageSlug}.openstatus.dev`; } +// Deep link to the specific event on the status page, mirroring the path +// shape used by the Slack app integration (apps/server slack/page-urls.ts). +function resolveEventUrl( + pageUpdate: PageUpdate, + subscription: Subscription, +): string { + const kind = pageUpdate.status === "maintenance" ? "maintenance" : "report"; + return `${resolveStatusPageOrigin(subscription)}/events/${kind}/${pageUpdate.id}`; +} + function buildManagementLinks(subscription: Subscription) { if (!subscription.token) { return { manageUrl: null, unsubscribeUrl: null }; @@ -120,6 +131,8 @@ function buildSlackPayload( links: ManagementLinks, ) { const color = statusColor(pageUpdate.status); + const eventUrl = resolveEventUrl(pageUpdate, subscription); + const pageOrigin = resolveStatusPageOrigin(subscription); const blocks: Record[] = [ { @@ -139,7 +152,7 @@ function buildSlackPayload( }, { type: "mrkdwn", - text: `*Page*\n${subscription.pageName}`, + text: `*Page*\n<${pageOrigin}|${subscription.pageName}>`, }, ], }, @@ -175,17 +188,17 @@ function buildSlackPayload( ], }); + const footerLinks = [`<${eventUrl}|View details>`]; if (links.manageUrl && links.unsubscribeUrl) { - blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: `<${links.manageUrl}|Manage> · <${links.unsubscribeUrl}|Unsubscribe>`, - }, - ], - }); + footerLinks.push( + `<${links.manageUrl}|Manage>`, + `<${links.unsubscribeUrl}|Unsubscribe>`, + ); } + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: footerLinks.join(" · ") }], + }); return { attachments: [ @@ -203,6 +216,8 @@ function buildDiscordPayload( links: ManagementLinks, ) { const color = statusColor(pageUpdate.status); + const eventUrl = resolveEventUrl(pageUpdate, subscription); + const pageOrigin = resolveStatusPageOrigin(subscription); const descriptionParts: string[] = []; if (pageUpdate.message) descriptionParts.push(pageUpdate.message); @@ -221,6 +236,7 @@ function buildDiscordPayload( embeds: [ { title: pageUpdate.title, + url: eventUrl, description: descriptionParts.join("\n\n") || undefined, color: COLOR_DECIMALS[color], fields: [ @@ -231,7 +247,7 @@ function buildDiscordPayload( }, { name: "Page", - value: subscription.pageName, + value: `[${subscription.pageName}](${pageOrigin})`, inline: true, }, ], @@ -244,21 +260,21 @@ function buildDiscordPayload( } /** - * Prepared (staged) generic webhook payload. Currently unreachable in - * production — the input gate rejects non-Slack/Discord URLs, and the - * dispatcher filters the same at send-time. Exported for unit-test coverage - * so the contract stays green while generic webhooks are held back. + * Generic JSON webhook payload, sent to any non-Slack/Discord URL. + * Shape is pinned by `webhookPayloadSchema` in `../payload`. */ export function buildGenericPayload( pageUpdate: PageUpdate, subscription: Subscription, links: ManagementLinks, ) { + const origin = resolveStatusPageOrigin(subscription); + const eventUrl = resolveEventUrl(pageUpdate, subscription); const page = { id: subscription.pageId, name: subscription.pageName, slug: subscription.pageSlug, - url: resolveStatusPageOrigin(subscription), + url: origin, }; const components = pageUpdate.componentsWithImpact ?? @@ -284,6 +300,7 @@ export function buildGenericPayload( maintenance: { id: pageUpdate.id, title: pageUpdate.title, + url: eventUrl, message: pageUpdate.message, starts_at: pageUpdate.startsAt, ends_at: pageUpdate.endsAt, @@ -306,11 +323,12 @@ export function buildGenericPayload( status_report: { id: pageUpdate.id, title: pageUpdate.title, + url: eventUrl, update: { id: pageUpdate.updateId, status: pageUpdate.status, message: pageUpdate.message, - created_at: pageUpdate.date, + occurred_at: pageUpdate.date, }, page, components, @@ -344,18 +362,7 @@ export async function sendWebhookNotifications( subscriptions: Subscription[], pageUpdate: PageUpdate, ) { - // Defense-in-depth: the input gate (tRPC + service layer) already rejects - // non-Slack/Discord URLs at save time. Drop any that slip through so the - // unreachable generic payload never ships to an unapproved destination. - const validSubscriptions = subscriptions - .filter(hasWebhookUrl) - .filter((sub) => { - if (detectWebhookFlavor(sub.webhookUrl) !== "generic") return true; - console.warn( - `Dropping webhook subscription ${sub.id}: generic URLs are not currently supported`, - ); - return false; - }); + const validSubscriptions = subscriptions.filter(hasWebhookUrl); if (validSubscriptions.length === 0) return; await Promise.allSettled( @@ -387,19 +394,12 @@ export async function sendWebhookNotifications( try { await assertSafeUrl(subscription.webhookUrl); - const response = await fetch(subscription.webhookUrl, { - method: "POST", + await postWebhookWithRetry({ + url: subscription.webhookUrl, headers, body: JSON.stringify(payload), - signal: AbortSignal.timeout(TIMEOUT_MS), + timeoutMs: TIMEOUT_MS, }); - - if (!response.ok) { - console.error( - `Webhook notification failed for ${redactWebhookUrl(subscription.webhookUrl)}: ${response.status} ${response.statusText}`, - ); - throw new Error(`Webhook returned ${response.status}`); - } } catch (error) { console.error( `Failed to send webhook notification to ${redactWebhookUrl(subscription.webhookUrl)}:`, @@ -455,9 +455,14 @@ export function buildTestPayload(flavor: WebhookFlavor) { }; case "generic": return { - type: "test", - message: "Your openstatus webhook is configured correctly.", - timestamp: new Date().toISOString(), + version: WEBHOOK_PAYLOAD_VERSION, + type: "test" as const, + data: { + test: { + message: "Your openstatus webhook is configured correctly.", + timestamp: new Date().toISOString(), + }, + }, }; } } diff --git a/packages/subscriptions/src/payload.ts b/packages/subscriptions/src/payload.ts index aeba254e..df509aee 100644 --- a/packages/subscriptions/src/payload.ts +++ b/packages/subscriptions/src/payload.ts @@ -30,11 +30,12 @@ export const statusReportWebhookSchema = z.object({ status_report: z.object({ id: z.number().int(), title: z.string(), + url: z.url(), update: z.object({ id: z.number().int(), status: statusReportStatusSchema, message: z.string(), - created_at: z.string(), + occurred_at: z.string(), }), page: pageSchema, components: z.array(componentSchema), @@ -50,6 +51,7 @@ export const maintenanceWebhookSchema = z.object({ maintenance: z.object({ id: z.number().int(), title: z.string(), + url: z.url(), message: z.string(), starts_at: z.string().optional(), ends_at: z.string().optional(), @@ -60,9 +62,21 @@ export const maintenanceWebhookSchema = z.object({ subscription: subscriptionSchema, }); +export const testWebhookSchema = z.object({ + version: z.literal(WEBHOOK_PAYLOAD_VERSION), + type: z.literal("test"), + data: z.object({ + test: z.object({ + message: z.string(), + timestamp: z.string(), + }), + }), +}); + export const webhookPayloadSchema = z.discriminatedUnion("type", [ statusReportWebhookSchema, maintenanceWebhookSchema, + testWebhookSchema, ]); export type WebhookPayload = z.infer; diff --git a/packages/subscriptions/tsconfig.json b/packages/subscriptions/tsconfig.json index 0b8670d6..868037cf 100644 --- a/packages/subscriptions/tsconfig.json +++ b/packages/subscriptions/tsconfig.json @@ -1,5 +1,13 @@ { - "extends": "@openstatus/tsconfig/react-library.json", + "extends": "@openstatus/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["bun"] + }, "include": [".", "src"], "exclude": ["dist", "build", "node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c362b6d..4fcd3756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1195,7 +1195,7 @@ importers: version: 5.0.0-beta.31(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) next-intl: specifier: 'catalog:' - version: 4.12.0(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 4.12.0(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3) next-plausible: specifier: 'catalog:' version: 3.12.5(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2601,6 +2601,9 @@ importers: '@openstatus/utils': specifier: workspace:* version: link:../utils + effect: + specifier: 'catalog:' + version: 3.21.2 zod: specifier: 'catalog:' version: 4.1.13 @@ -18056,7 +18059,7 @@ snapshots: next-intl-swc-plugin-extractor@4.12.0: {} - next-intl@4.12.0(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + next-intl@4.12.0(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.1