feat: add maintenance updates for status page communications (#2561) master
* feat: add maintenance updates for status page communications Allow posting chronological updates on maintenances across dashboard, API, RPC/MCP, feeds, and subscriber notifications. * fix: complete maintenance update integrations * ci: apply automated fixes * refactor(maintenance): keep message as the announcement, updates as an independent timeline The branch mirrored the newest update into `maintenance.message`, which kept reads compatible but let any legacy writer (v1 PUT, RPC UpdateMaintenance, Terraform, the dashboard composer) overwrite the latest progress note. - `message` is the announcement again; `maintenance_update` rows are an optional 0..n timeline with `created_by`/`updated_by`. No sync helper, no initial update on create, no "at least one update" rule, no backfill. - Notifications split: `notifyMaintenance` (announcement, by maintenance id) and `notifyMaintenanceUpdate` (note, by update id); tRPC exposes both. Maintenance-update emails get their own idempotency key prefix. - Status JSON, feeds, Markdown and the UI block render the announcement followed by its notes. RPC/REST/CLI/Terraform shapes keep their meaning. - Migration 0092 regenerated; docs for the maintenance reference and MCP tools updated; tests rewritten for the new model. * fix(maintenance): reject non-decimal page component ids in RPC, surface notify failures - The maintenance RPC accepted any string that `Number()` turned into a safe integer, so "1e3" targeted component 1000 and "0x10" component 16. Use the same digits-only check as the status-report handler. - The dashboard fired subscriber notifications without an error handler after creating a maintenance or posting an update, so a failed dispatch was invisible. Toast the error on all three call sites. * feat(dashboard): maintenance updates as a timeline with an inline composer Replace the "Add update" button and update cards with the same layout the status-report detail uses: a composer at the top of the timeline (message, date, notify subscribers, "Publish update") and one timeline item per update with author, relative time, index and an actions menu for edit/delete. The header meta shows the update count and the last update time. Maintenance updates now carry `createdByUser`/`updatedByUser` so the timeline can attribute them. * refactor(maintenance): move the message into the update timeline A maintenance now works like a status report: creating one posts the message as the first `maintenance_update`, `message` on read is the newest update, `message` on update rewrites the newest one, and the last update cannot be deleted. The `maintenance.message` column stays as a create-only mirror until a follow-up drops it; migration 0092 backfills one update per existing row. - services: create returns `{ maintenance, initialUpdate }`, list/get derive `message`, `latestMaintenanceUpdate` helper - RPC v2 / v1 REST / agent tools keep their fields with the new meaning; v1 get/list/put derive and rewrite via `v1/maintenances/updates.ts` - ui blocks render updates oldest-first, `message` optional as fallback - status page feeds and markdown list updates, dispatcher uses the newest - importers date the first update at the window start - dashboard: single timeline composer, message only in the create sheet - docs, seed, OpenAPI v1 document, tests * chore(proto): regenerate maintenance comments * feat(subscriptions): thread maintenance updates in Slack like status reports Maintenance now has a timeline of updates, so each notified update posted a fresh root message in the subscriber's channel. Route maintenance through the same threaded delivery as status reports: the announcement opens the root, later updates backfill the first message, reply in thread and re-render the root. - Namespace anchor and delivery keys by event kind so a report and a maintenance sharing an id keep separate threads. Report keys are unchanged. - Send the first update's id on the announcement dispatch; it anchors the thread and gives the email idempotency key its per-update form. - Pin the root's "Scheduled" line to the maintenance start so a re-render does not pick up the update's timestamp. * fix(maintenance): address review findings on maintenance updates - Reject null dates: a bare z.coerce.date() turns JSON null into 1970-01-01. Service inputs reject null before coercing, REST update schemas take an ISO 8601 date-time, tRPC takes a Date. - Backfill updated_by from the legacy column before falling back to created_by so edits by another user keep their editor. - Maintenance emails carry the from - to window instead of a bare start. - Audit the first update written by the importer. - Dashboard: lock the composer while publishing and keep isPending through the refetch; nested links in a table row keep their own click; the update sheet forwards trigger props. - Drop the unused maintenance update load from getUptime. - Keep multi-line update messages inside their Markdown list item. - Docs: tool counts, add_maintenance_update in the notify contract, notified:false on plans without subscribers. - Tests: REST GET asserts the body; dispatcher asserts the window. * ci: apply automated fixes * 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 * fix(maintenance): reject future-dated updates, type update id as integer in OpenAPI * fix(maintenance): address Copilot review on updates ordering and RPC ids - break date ties by update id in status page queries, the UI block, feeds, status JSON and markdown generators (dates have second precision) - reject non-decimal maintenance, update and page ids in the RPC handler - require a non-empty message on the v1 maintenance schema - regenerate the v1 OpenAPI spec and the RPC OpenAPI json/yaml module * refactor(maintenance): drop v1 update routes and notify by update id Maintenance updates are a ConnectRPC-only surface: remove the duplicate v1 /maintenance_update REST routes and regenerate the OpenAPI spec. Collapse notifyMaintenance/notifyMaintenanceUpdate into one notifyMaintenance keyed by update id, mirroring notifyStatusReport. The announcement is the first update, so maintenance.new returns initialUpdateId for the dashboard to notify. Drop the NotifyMaintenanceUpdate event and tRPC procedure. Remove dispatchMaintenance from the subscriptions dispatcher; the v1 create route dispatches its first update instead. Drop the redundant client-side sort on the maintenance detail page (the service already orders newest first) and mark the feed JSON `message` field deprecated in favour of maintenanceUpdates. * fix(status-page): show only the newest maintenance update in the banner * test(maintenance): verify parent touch per actor and malformed RPC page ids * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske <maximilian@kaske.org>