From 7dc8451050583ae3a861997e5f29a6edb869d3cd Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Thu, 8 Oct 2026 17:08:04 +0200 Subject: [PATCH] fix(maintenance): anchor announcements on the first update, hash the window into the email key - dispatchMaintenance ordered updates descending, so a re-announcement after a later notified update reused that update's id (Slack deduped it away, email carried the wrong message) - the email idempotency fingerprint ignored the maintenance window the body renders, so a changed window on the same update hit Resend's 409 - drop the maintenance list row click and its stopPropagation wrapper; the title cell link already navigates --- .../[id]/maintenances/(list)/page.tsx | 11 ++-------- .../maintenances/data-table-row-actions.tsx | 4 ++-- .../subscriptions/src/channels/email.test.ts | 22 +++++++++++++++++++ packages/subscriptions/src/channels/email.ts | 3 +++ packages/subscriptions/src/dispatcher.test.ts | 9 ++++++++ packages/subscriptions/src/dispatcher.ts | 3 ++- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index fa4a2068b..e51cac462 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -2,7 +2,7 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { toast } from "sonner"; import { Link } from "@/components/common/link"; @@ -23,7 +23,6 @@ import { errorMessage } from "@/lib/trpc/error"; export default function Page() { const { id } = useParams<{ id: string }>(); - const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: statusPage } = useQuery( @@ -95,13 +94,7 @@ export default function Page() { - - router.push(`/status-pages/${id}/maintenances/${row.original.id}`) - } - /> + ); diff --git a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx index 9bab71caa..1637b344f 100644 --- a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx @@ -50,7 +50,7 @@ export function MaintenanceRowActions({ ); return ( -
event.stopPropagation()}> + <> -
+ ); } diff --git a/packages/subscriptions/src/channels/email.test.ts b/packages/subscriptions/src/channels/email.test.ts index 825565d32..19ec30dde 100644 --- a/packages/subscriptions/src/channels/email.test.ts +++ b/packages/subscriptions/src/channels/email.test.ts @@ -196,6 +196,28 @@ describe("sendEmailNotifications", () => { expect(keys[0]).not.toBe(keys[1]); }); + test("changes the key when the maintenance window changes", async () => { + const base = { + updateId: 77, + status: "maintenance" as const, + date: "2026-07-16T11:00:00Z", + startsAt: "2026-07-20T10:00:00Z", + }; + await sendEmailNotifications( + [makeSub()], + makeUpdate({ ...base, endsAt: "2026-07-20T11:00:00Z" }), + ); + await sendEmailNotifications( + [makeSub()], + makeUpdate({ ...base, endsAt: "2026-07-20T12:00:00Z" }), + ); + + const keys = sendStatusReportUpdateMock.calls.map( + (c) => c.args[0].idempotencyKey, + ); + expect(keys[0]).not.toBe(keys[1]); + }); + test("changes the key when the update content changes", async () => { const date = "2026-07-16T11:00:00Z"; await sendEmailNotifications( diff --git a/packages/subscriptions/src/channels/email.ts b/packages/subscriptions/src/channels/email.ts index 90440ac17..afccdccef 100644 --- a/packages/subscriptions/src/channels/email.ts +++ b/packages/subscriptions/src/channels/email.ts @@ -95,6 +95,9 @@ export async function sendEmailNotifications( pageUpdate.status, pageUpdate.message, pageUpdate.date, + // the maintenance window is rendered instead of `date` below + pageUpdate.startsAt ?? null, + pageUpdate.endsAt ?? null, pageUpdate.pageComponents, pageUpdate.componentsWithImpact?.map((c) => c.impact), ]), diff --git a/packages/subscriptions/src/dispatcher.test.ts b/packages/subscriptions/src/dispatcher.test.ts index 1464b8b19..9d12dea07 100644 --- a/packages/subscriptions/src/dispatcher.test.ts +++ b/packages/subscriptions/src/dispatcher.test.ts @@ -269,6 +269,15 @@ describe("dispatchMaintenance", () => { }) .returning() .get(); + // a later update must not hijack the announcement's id or message + await db + .insert(maintenanceUpdate) + .values({ + maintenanceId: record.id, + message: "later update", + date: new Date("2026-08-08T14:00:00.000Z"), + }) + .run(); await dispatchMaintenance(record.id); diff --git a/packages/subscriptions/src/dispatcher.ts b/packages/subscriptions/src/dispatcher.ts index d2c2c8918..0d5394324 100644 --- a/packages/subscriptions/src/dispatcher.ts +++ b/packages/subscriptions/src/dispatcher.ts @@ -96,8 +96,9 @@ export async function dispatchMaintenance(maintenanceId: number) { maintenancesToPageComponents: { with: { pageComponent: true }, }, + // first update: the announcement must not reuse a later update's id maintenanceUpdates: { - orderBy: (updates, { desc }) => [desc(updates.date), desc(updates.id)], + orderBy: (updates, { asc }) => [asc(updates.date), asc(updates.id)], limit: 1, }, }, -- 2.51.2