diff --git a/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx b/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx index 314a53b8b..bdc5f348e 100644 --- a/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx +++ b/apps/dashboard/src/components/forms/maintenance-update/sheet.tsx @@ -24,6 +24,7 @@ export function FormSheetMaintenanceUpdate({ onSubmit, open: controlledOpen, onOpenChange, + ...props }: Omit, "onSubmit"> & { defaultValues?: Partial; onSubmit: (values: FormValues) => Promise; @@ -44,7 +45,9 @@ export function FormSheetMaintenanceUpdate({ return ( {children ? ( - {children} + + {children} + ) : null} diff --git a/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx b/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx index dc2cc08ca..0a47ded4e 100644 --- a/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx +++ b/apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx @@ -79,6 +79,7 @@ export function MaintenanceUpdateComposer({ setMessage(e.target.value)} onSubmit={() => submit().catch(console.error)} diff --git a/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts b/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts index 291828fe8..242e76fc8 100644 --- a/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts +++ b/apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts @@ -1,6 +1,7 @@ "use client"; import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; @@ -11,7 +12,8 @@ import { useInvalidateMaintenance } from "./use-invalidate-maintenance"; /** * Posts a maintenance update, notifies subscribers when asked, then refetches. * The update is persisted before notifying, so a notification failure is - * reported on its own and never rejects the publish. + * reported on its own and never rejects the publish. `isPending` stays true + * through the refetch so the same message cannot be submitted twice. */ export function usePublishMaintenanceUpdate(maintenanceId: number) { const trpc = useTRPC(); @@ -20,26 +22,33 @@ export function usePublishMaintenanceUpdate(maintenanceId: number) { const notify = useMutation( trpc.subscriberNotification.maintenanceUpdate.mutationOptions(), ); + const [isPending, setIsPending] = useState(false); return { - isPending: create.isPending || notify.isPending, + isPending, async publish(input: { message: string; date: Date; notifySubscribers: boolean; }) { - const update = await create.mutateAsync({ maintenanceId, ...input }); - if (update && input.notifySubscribers) { - const fallback = "Update published, but subscribers were not notified"; - try { - // resolves with success=false when the plan lacks subscribers - const result = await notify.mutateAsync({ id: update.id }); - if (!result.success) toast.error(fallback); - } catch (error) { - toast.error(errorMessage(error, fallback)); + setIsPending(true); + try { + const update = await create.mutateAsync({ maintenanceId, ...input }); + if (update && input.notifySubscribers) { + const fallback = + "Update published, but subscribers were not notified"; + try { + // resolves with success=false when the plan lacks subscribers + const result = await notify.mutateAsync({ id: update.id }); + if (!result.success) toast.error(fallback); + } catch (error) { + toast.error(errorMessage(error, fallback)); + } } + await invalidate(); + } finally { + setIsPending(false); } - await invalidate(); }, }; } diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx index 332056ddd..06d616275 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx @@ -168,7 +168,11 @@ export function DataTable({ data-state={ (row.getIsSelected() || row.getIsExpanded()) && "selected" } - onClick={() => onRowClick?.(row)} + onClick={(e) => { + // nested links/buttons own their click + if ((e.target as HTMLElement).closest("a,button")) return; + onRowClick?.(row); + }} className="data-[state=selected]:bg-muted/50" > {row.getVisibleCells().map((cell) => ( diff --git a/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts b/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts index a43a515fe..2590eab03 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts @@ -44,6 +44,9 @@ test("maintenance update REST CRUD", async () => { headers, }); expect(fetched.status).toBe(200); + const fetchedBody = await fetched.json(); + expect(fetchedBody.id).toBe(update.id); + expect(fetchedBody.message).toBe("REST maintenance update"); const edited = await app.request(`/v1/maintenance_update/${update.id}`, { method: "PUT", diff --git a/apps/server/src/routes/v1/maintenanceUpdates/schema.ts b/apps/server/src/routes/v1/maintenanceUpdates/schema.ts index f536e7390..730e415d7 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/schema.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/schema.ts @@ -25,17 +25,24 @@ export const MaintenanceUpdateSchema = z }) .openapi("MaintenanceUpdate"); +// JSON `null` would coerce to 1970-01-01 with a bare `z.coerce.date()`. +const isoDate = z.iso.datetime({ offset: true }).pipe(z.coerce.date()); + export const CreateMaintenanceUpdateSchema = z.object({ maintenanceId: z.number().int(), message: z.string().min(1), - date: z.coerce.date().optional(), + date: isoDate.optional().openapi({ + description: "ISO 8601 date-time of the update; defaults to now", + }), notify: z.boolean().default(false), }); export const UpdateMaintenanceUpdateSchema = z .object({ message: z.string().min(1).optional(), - date: z.coerce.date().optional(), + date: isoDate.optional().openapi({ + description: "ISO 8601 date-time of the update", + }), }) .refine((input) => input.message !== undefined || input.date !== undefined, { message: "At least one field must be provided.", diff --git a/apps/server/static/openapi-v1.json b/apps/server/static/openapi-v1.json index 1cbf1496a..1a39ad896 100644 --- a/apps/server/static/openapi-v1.json +++ b/apps/server/static/openapi-v1.json @@ -933,7 +933,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-10-08T13:22:10.941Z", + "default": "2026-10-08T14:29:20.267Z", "description": "The date of the update in ISO8601 format" }, "message": { @@ -6631,7 +6631,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-10-08T13:22:10.941Z", + "default": "2026-10-08T14:29:20.267Z", "description": "The date of the report in ISO8601 format, defaults to now" }, "message": { @@ -7086,7 +7086,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-10-08T13:22:10.941Z", + "default": "2026-10-08T14:29:20.267Z", "description": "The date of the update in ISO8601 format" }, "message": { @@ -7387,7 +7387,7 @@ "type": "string", "nullable": true, "format": "date-time", - "default": "2026-10-08T13:22:10.941Z", + "default": "2026-10-08T14:29:20.267Z", "description": "The date of the update in ISO8601 format" }, "message": { @@ -8755,8 +8755,8 @@ }, "date": { "type": "string", - "nullable": true, - "format": "date-time" + "format": "date-time", + "description": "ISO 8601 date-time of the update" } } } @@ -9049,8 +9049,8 @@ }, "date": { "type": "string", - "nullable": true, - "format": "date-time" + "format": "date-time", + "description": "ISO 8601 date-time of the update; defaults to now" }, "notify": { "type": "boolean", diff --git a/apps/status-page/src/content/markdown/generators.ts b/apps/status-page/src/content/markdown/generators.ts index 713c9ac7f..21cb31b8a 100644 --- a/apps/status-page/src/content/markdown/generators.ts +++ b/apps/status-page/src/content/markdown/generators.ts @@ -460,7 +460,9 @@ export function generateEventsList( const updates = newestMaintenanceUpdates(m.maintenanceUpdates); if (updates.length === 0 && m.message) out.push(m.message); for (const update of updates) { - out.push(`- ${formatDayTime(update.date)} — ${update.message}`); + // indent continuation lines so a blank line does not end the item + const message = update.message.replace(/\n/g, "\n "); + out.push(`- ${formatDayTime(update.date)} — ${message}`); } out.push(""); } diff --git a/apps/web/src/content/pages/docs/reference/mcp-server.mdx b/apps/web/src/content/pages/docs/reference/mcp-server.mdx index 983c3c92a..6152d8e08 100644 --- a/apps/web/src/content/pages/docs/reference/mcp-server.mdx +++ b/apps/web/src/content/pages/docs/reference/mcp-server.mdx @@ -62,7 +62,7 @@ For AI agents that should only observe state — health summaries, paging on-cal ## Tools -The server exposes 23 tools (21 on plans without the `audit-log` feature), grouped by resource: 19 scoped to the workspace tied to your credential, plus 4 public content tools that read openstatus.dev pages on any credential and any scope (see [Content](#content)). Workspaces with incident management enabled get 10 more (see [Incidents](#incidents)). Mutations write to the audit log with `actor_type = "mcp"` (see [Audit log](#audit-log)). +The server exposes 26 tools (24 on plans without the `audit-log` feature), grouped by resource: 22 scoped to the workspace tied to your credential, plus 4 public content tools that read openstatus.dev pages on any credential and any scope (see [Content](#content)). Workspaces with incident management enabled get 10 more (see [Incidents](#incidents)). Mutations write to the audit log with `actor_type = "mcp"` (see [Audit log](#audit-log)). ### Pages @@ -170,7 +170,7 @@ the read returns a short pointer back to the same URI rather than a blank body. ### Notifying subscribers -Every publishing tool — `create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance` — has a **required** `notify: boolean` field, with no default. The tool's input schema rejects calls that omit it, which forces the LLM to make an explicit choice (and therefore ask the user) before firing. `update_status_report` edits metadata only and carries no `notify` field at all. +Every publishing tool — `create_status_report`, `add_status_report_update`, `resolve_status_report`, `create_maintenance`, `add_maintenance_update` — has a **required** `notify: boolean` field, with no default. The tool's input schema rejects calls that omit it, which forces the LLM to make an explicit choice (and therefore ask the user) before firing. `update_status_report` edits metadata only and carries no `notify` field at all. This required-field behaviour is specific to MCP. The dashboard AI assistant and the Slack agent wrap these same tools in an approval step that strips `notify` from the model-facing schema and injects it from a human toggle defaulting to `false`. MCP exposes the raw schema, so the caller must supply `notify` explicitly. @@ -178,7 +178,7 @@ Notifications dispatch as part of the same call. There is no separate notify too The mutation and the notify dispatch are sequential, not transactional. The mutation persists first; if the notify step then throws (transient provider issue, partial outage of an integration), the response carries `notified: false` and the row stays. -`notified: true` means the dispatch call returned without throwing — **not** that every subscriber received a message. If the workspace plan doesn't include subscriber notifications, the service is a silent no-op and the response will still report `notified: true`. Treat the field as "the dispatch ran cleanly," not as a delivery receipt. +`notified: true` means the dispatch call returned without throwing — **not** that every subscriber received a message. If the workspace plan doesn't include subscriber notifications, the service is a no-op: the maintenance tools report `notified: false`, the status report tools still report `notified: true`. Treat the field as "the dispatch ran cleanly," not as a delivery receipt. | Tool | What `notify: true` sends | |----------------------------|---------------------------| diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts index 592f5e7f3..c21162b5c 100644 --- a/packages/api/src/router/maintenance.ts +++ b/packages/api/src/router/maintenance.ts @@ -22,8 +22,8 @@ export const maintenanceRouter = createTRPCRouter({ .input( z.object({ maintenanceId: z.number(), - message: z.string(), - date: z.coerce.date().optional(), + message: z.string().min(1), + date: z.date().optional(), notifySubscribers: z.boolean().nullish(), }), ) @@ -189,7 +189,7 @@ export const maintenanceRouter = createTRPCRouter({ .object({ id: z.number(), message: z.string().min(1).optional(), - date: z.coerce.date().optional(), + date: z.date().optional(), }) .refine( (input) => input.message !== undefined || input.date !== undefined, diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 2d8084a4c..fb14de1c5 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -737,9 +737,6 @@ export const statusPageRouter = createTRPCRouter({ with: { maintenances: { with: { - maintenanceUpdates: { - orderBy: (updates, { desc }) => desc(updates.date), - }, maintenancesToPageComponents: { with: { pageComponent: true } }, }, }, diff --git a/packages/db/drizzle/0092_yielding_banshee.sql b/packages/db/drizzle/0092_yielding_banshee.sql index 337d4fc03..4b25c4a2a 100644 --- a/packages/db/drizzle/0092_yielding_banshee.sql +++ b/packages/db/drizzle/0092_yielding_banshee.sql @@ -15,6 +15,6 @@ CREATE TABLE `maintenance_update` ( CREATE INDEX `maintenance_update_maintenance_id_idx` ON `maintenance_update` (`maintenance_id`); --> statement-breakpoint INSERT INTO `maintenance_update` (`maintenance_id`, `message`, `date`, `created_by`, `updated_by`) -SELECT `id`, `message`, COALESCE(`created_at`, `from`), `created_by`, `created_by` +SELECT `id`, `message`, COALESCE(`created_at`, `from`), `created_by`, COALESCE(`updated_by`, `created_by`) FROM `maintenance` WHERE `message` <> ''; diff --git a/packages/services/src/import/phase-writers.ts b/packages/services/src/import/phase-writers.ts index df1680842..a545bdc99 100644 --- a/packages/services/src/import/phase-writers.ts +++ b/packages/services/src/import/phase-writers.ts @@ -639,13 +639,16 @@ export async function writeMaintenancesPhase( // the message is the first timeline update; the announcement time // of imported history is unknown, so it is dated at the window start - await tx.insert(maintenanceUpdate).values({ - maintenanceId: inserted.id, - message: data.message, - date: data.from, - createdBy: actorUserId, - updatedBy: actorUserId, - }); + const [insertedUpdate] = await tx + .insert(maintenanceUpdate) + .values({ + maintenanceId: inserted.id, + message: data.message, + date: data.from, + createdBy: actorUserId, + updatedBy: actorUserId, + }) + .returning(); const componentLinks: Array<{ maintenanceId: number; @@ -678,6 +681,18 @@ export async function writeMaintenancesPhase( }), after: inserted, }); + if (insertedUpdate) { + await emitAudit(tx, ctx, { + action: "maintenance_update.create", + entityType: "maintenance_update", + entityId: insertedUpdate.id, + metadata: auditMeta(pc, { + sourceId: resource.sourceId, + maintenanceId: inserted.id, + }), + after: insertedUpdate, + }); + } } catch (err) { resource.status = "failed"; resource.error = err instanceof Error ? err.message : String(err); diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts index 4826ac024..076cb9d54 100644 --- a/packages/services/src/maintenance/schemas.ts +++ b/packages/services/src/maintenance/schemas.ts @@ -10,15 +10,20 @@ export const maintenanceListPeriods = ["1d", "7d", "14d"] as const; export type MaintenanceListPeriod = (typeof maintenanceListPeriods)[number]; export const maintenanceListPeriodSchema = z.enum(maintenanceListPeriods); +// `z.coerce.date()` turns `null` into 1970-01-01; reject it before coercing. +const coercedDate = z + .union([z.string(), z.number(), z.date()]) + .pipe(z.coerce.date()); + export const CreateMaintenanceInput = z .object({ title: z.string().trim().min(1).max(256), /** Becomes the first timeline update. */ message: z.string().min(1), /** Date of the first update; defaults to now. */ - date: z.coerce.date().optional(), - from: z.coerce.date(), - to: z.coerce.date(), + date: coercedDate.optional(), + from: coercedDate, + to: coercedDate, pageId: z.number().int(), pageComponentIds: z.array(z.number().int()).default([]), }) @@ -33,8 +38,8 @@ export const UpdateMaintenanceInput = z.object({ title: z.string().trim().min(1).max(256).optional(), /** Rewrites the newest timeline update. */ message: z.string().min(1).optional(), - from: z.coerce.date().optional(), - to: z.coerce.date().optional(), + from: coercedDate.optional(), + to: coercedDate.optional(), /** When provided, replaces the full association set (empty array clears). */ pageComponentIds: z.array(z.number().int()).optional(), }); @@ -43,7 +48,7 @@ export type UpdateMaintenanceInput = z.infer; export const AddMaintenanceUpdateInput = z.object({ maintenanceId: z.number().int(), message: z.string().min(1), - date: z.coerce.date().optional(), + date: coercedDate.optional(), }); export type AddMaintenanceUpdateInput = z.infer< typeof AddMaintenanceUpdateInput @@ -53,7 +58,7 @@ export const UpdateMaintenanceUpdateInput = z .object({ id: z.number().int(), message: z.string().min(1).optional(), - date: z.coerce.date().optional(), + date: coercedDate.optional(), }) .refine((input) => input.message !== undefined || input.date !== undefined, { message: "At least one field must be provided.", diff --git a/packages/subscriptions/src/channels/email.ts b/packages/subscriptions/src/channels/email.ts index ba538accd..90440ac17 100644 --- a/packages/subscriptions/src/channels/email.ts +++ b/packages/subscriptions/src/channels/email.ts @@ -112,7 +112,13 @@ export async function sendEmailNotifications( reportTitle: pageUpdate.title, status: pageUpdate.status, message: pageUpdate.message, - date: pageUpdate.date, + // the template prints non-date strings verbatim, so the window reads "from - to" + date: + pageUpdate.status === "maintenance" && + pageUpdate.startsAt && + pageUpdate.endsAt + ? `${pageUpdate.startsAt} - ${pageUpdate.endsAt}` + : pageUpdate.date, pageComponents: pageUpdate.pageComponents, componentImpacts: pageUpdate.componentsWithImpact, idempotencyKey: `${idempotencyKeyFor(pageUpdate)}:${payloadHash}`, diff --git a/packages/subscriptions/src/dispatcher.test.ts b/packages/subscriptions/src/dispatcher.test.ts index 9e9800a4a..1464b8b19 100644 --- a/packages/subscriptions/src/dispatcher.test.ts +++ b/packages/subscriptions/src/dispatcher.test.ts @@ -274,7 +274,10 @@ describe("dispatchMaintenance", () => { const args = sendStatusReportUpdateMock.calls[0].args[0]; expect(args.message).toBe("announcement"); - expect(args.date).toBe("2026-08-10T10:00:00.000Z"); + // maintenance emails carry the window, not the update timestamp + expect(args.date).toBe( + "2026-08-10T10:00:00.000Z - 2026-08-10T11:00:00.000Z", + ); expect(args.idempotencyKey).toMatch( new RegExp(`^maintenance-update:${update.id}:`), ); @@ -324,7 +327,9 @@ describe("dispatchMaintenanceUpdate", () => { const args = sendStatusReportUpdateMock.calls[0].args[0]; expect(args.message).toBe("specific update message"); - expect(args.date).toBe(occurredAt.toISOString()); + expect(args.date).toBe( + `${startsAt.toISOString()} - ${endsAt.toISOString()}`, + ); expect(args.pageComponents).toContain(COMPONENT_1_NAME); expect(args.idempotencyKey).toMatch( new RegExp(`^maintenance-update:${update.id}:`),