diff --git a/apps/dashboard/src/components/forms/maintenance-update/card.tsx b/apps/dashboard/src/components/forms/maintenance-update/card.tsx index d20e185f..46b2aa02 100644 --- a/apps/dashboard/src/components/forms/maintenance-update/card.tsx +++ b/apps/dashboard/src/components/forms/maintenance-update/card.tsx @@ -44,16 +44,14 @@ export function FormMaintenanceUpdateCard({ onSubmit={onSubmit} /> - {total > 1 ? ( - - - - ) : null} + + + diff --git a/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts index 862fb3bb..51aa8bcc 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/__tests__/maintenance.test.ts @@ -554,16 +554,9 @@ describe("MaintenanceService.CreateMaintenance", () => { expect(data.maintenance.title).toBe(`${TEST_PREFIX}-with-notify`); // Verify dispatcher was called (dispatchers are mocked in preload.ts) - expect(subscriptionSpies.dispatchMaintenance).toHaveBeenCalledTimes( - 1, - ); - const initialUpdate = await db - .select({ id: maintenanceUpdate.id }) - .from(maintenanceUpdate) - .where(eq(maintenanceUpdate.maintenanceId, Number(data.maintenance.id))) - .get(); + expect(subscriptionSpies.dispatchMaintenance).toHaveBeenCalledTimes(1); expect(subscriptionSpies.dispatchMaintenance).toHaveBeenCalledWith( - initialUpdate?.id, + Number(data.maintenance.id), ); // Clean up diff --git a/apps/server/src/routes/v1/maintenances/post.test.ts b/apps/server/src/routes/v1/maintenances/post.test.ts index 5dae557c..6f40dd26 100644 --- a/apps/server/src/routes/v1/maintenances/post.test.ts +++ b/apps/server/src/routes/v1/maintenances/post.test.ts @@ -245,9 +245,7 @@ test("create a maintenance calls dispatchMaintenance", async () => { const result = MaintenanceSchema.safeParse(await res.json()); expect(result.success).toBe(true); expect(spies.dispatchMaintenance.mock.calls.length).toBe(1); - expect(typeof spies.dispatchMaintenance.mock.calls[0][0]).toBe( - "number", - ); + expect(typeof spies.dispatchMaintenance.mock.calls[0][0]).toBe("number"); if (result.success) { await db.delete(maintenance).where(eq(maintenance.id, result.data.id)); diff --git a/apps/web/src/content/pages/docs/reference/maintenance.mdx b/apps/web/src/content/pages/docs/reference/maintenance.mdx index f8dc84bc..aacebcf5 100644 --- a/apps/web/src/content/pages/docs/reference/maintenance.mdx +++ b/apps/web/src/content/pages/docs/reference/maintenance.mdx @@ -35,7 +35,7 @@ A short, human-readable name for the maintenance window. **Type:** String (required) -A description of the maintenance shown to users, explaining what is happening and the expected impact. +A description of the maintenance shown to users, explaining what is happening and the expected impact. It is the announcement: progress during the window goes into [updates](#updates), which never change this field. **Example:** `"Upgrading our database to improve performance. Brief interruptions may occur."` @@ -77,6 +77,16 @@ A one-time flag evaluated at creation time — it is **not** stored on the maint The v1 REST API does not expose this flag; it notifies subscribers automatically when a maintenance is created on a page whose workspace has subscribers enabled. +## Updates + +A maintenance can carry an optional timeline of dated notes, for example "Work started", "Taking longer than expected" or "Completed early". Each update has: + +- **Message** — String (required). The note shown on the status page. +- **Date** — Datetime (optional). When the note applies. Defaults to the time it is posted. +- **Notify** — Boolean (optional, write-only). Sends the note to status page subscribers. + +Updates are independent of the maintenance message: adding, editing or deleting a note never rewrites the announcement, and a maintenance with no updates is valid. Reads return them newest-first under `updates` (RPC, agent tools) or `maintenanceUpdates` (status page JSON). The public status page, feeds and Markdown render the announcement followed by its notes. + ## Relationships - **Status page** — a maintenance is tied to a single page. Deleting the page deletes its maintenance windows. 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 e3e1ec2b..983c3c92 100644 --- a/apps/web/src/content/pages/docs/reference/mcp-server.mdx +++ b/apps/web/src/content/pages/docs/reference/mcp-server.mdx @@ -87,6 +87,9 @@ The server exposes 23 tools (21 on plans without the `audit-log` feature), group |----------------------|----------|---------| | `list_maintenances` | read | List maintenance windows newest-first. Paginated via `page` (1-indexed) and `perPage`; response carries a `pagination` object with `page`, `perPage`, `totalSize`, and `totalPages`. | | `create_maintenance` | mutation | Schedule a maintenance window (`from` / `to` are ISO 8601 strings). | +| `add_maintenance_update` | mutation | Post a dated progress note on a maintenance window (`date` optional, defaults to now). | +| `update_maintenance_update` | mutation | Edit the message or date of an existing note. | +| `delete_maintenance_update` | mutation | Remove a note. The window and its announcement stay. | ### Incidents @@ -183,6 +186,7 @@ The mutation and the notify dispatch are sequential, not transactional. The muta | `add_status_report_update` | Notification for the new update | | `resolve_status_report` | Resolution notification | | `create_maintenance` | Maintenance scheduled notification | +| `add_maintenance_update` | Notification for the new note | | `update_status_report` | n/a — metadata-only edit, never has a notify path | The required `notify` field, combined with the mandatory draft-and-confirm workflow in each tool's description, encodes the contract that LLMs must: diff --git a/packages/api/src/router/maintenance.test.ts b/packages/api/src/router/maintenance.test.ts index ac63c420..acbc6581 100644 --- a/packages/api/src/router/maintenance.test.ts +++ b/packages/api/src/router/maintenance.test.ts @@ -246,8 +246,6 @@ test("maintenance update procedures provide full CRUD", async () => { }); createdMaintenanceIds.push(created.id); - createdMaintenanceUpdateIds.push(created.initialUpdateId); - const added = await caller.maintenance.createUpdate({ maintenanceId: created.id, message: "Second update", @@ -264,8 +262,9 @@ test("maintenance update procedures provide full CRUD", async () => { expect(edited.message).toBe("Edited update"); const found = await caller.maintenance.get({ id: created.id }); - expect(found.updates.length).toBe(2); - expect(found.message).toBe("Edited update"); + expect(found.updates.map((u) => u.message)).toEqual(["Edited update"]); + // the announcement is independent of the timeline + expect(found.message).toBe("Initial update"); await caller.maintenance.deleteUpdate({ id: added.id }); const deleted = await db.query.maintenanceUpdate.findFirst({ @@ -274,11 +273,11 @@ test("maintenance update procedures provide full CRUD", async () => { expect(deleted).toBeUndefined(); try { - await caller.maintenance.deleteUpdate({ id: created.initialUpdateId }); + await caller.maintenance.deleteUpdate({ id: added.id }); throw new Error("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(TRPCError); - expect((error as TRPCError).code).toBe("CONFLICT"); + expect((error as TRPCError).code).toBe("NOT_FOUND"); } await caller.maintenance.delete({ id: created.id }); diff --git a/packages/services/src/maintenance/__tests__/maintenance.test.ts b/packages/services/src/maintenance/__tests__/maintenance.test.ts index 091ed733..d51e8931 100644 --- a/packages/services/src/maintenance/__tests__/maintenance.test.ts +++ b/packages/services/src/maintenance/__tests__/maintenance.test.ts @@ -705,7 +705,11 @@ describe("maintenance updates", () => { input: { maintenanceId: parent.id, message: "x" }, }); const readOnly = { - ...makeApiKeyCtx(teamCtx.workspace, { scopes: ["read"] }), + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), db: tx, }; diff --git a/packages/services/src/maintenance/notify.ts b/packages/services/src/maintenance/notify.ts index b0e5a99e..9213255d 100644 --- a/packages/services/src/maintenance/notify.ts +++ b/packages/services/src/maintenance/notify.ts @@ -15,11 +15,13 @@ import { NotifyMaintenanceInput } from "./schemas"; * Enforces: * - Workspace owns the target maintenance. * - Plan has `status-subscribers` enabled — otherwise no-op. + * + * Returns true only when a dispatch actually ran. */ export async function notifyMaintenance(args: { ctx: ServiceContext; input: NotifyMaintenanceInput; -}): Promise { +}): Promise { const { ctx } = args; requireScope(ctx, "write"); const input = NotifyMaintenanceInput.parse(args.input); @@ -39,8 +41,9 @@ export async function notifyMaintenance(args: { } if (!ctx.workspace.limits["status-subscribers"]) { - return; + return false; } await dispatchMaintenance(input.maintenanceId); + return true; } diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts index d2c79717..7263541b 100644 --- a/packages/services/src/maintenance/schemas.ts +++ b/packages/services/src/maintenance/schemas.ts @@ -89,7 +89,9 @@ export const ListMaintenancesInput = z.object({ }); export type ListMaintenancesInput = z.infer; -export const NotifyMaintenanceInput = z.object({ maintenanceId: z.number().int() }); +export const NotifyMaintenanceInput = z.object({ + maintenanceId: z.number().int(), +}); export type NotifyMaintenanceInput = z.infer; export const NotifyMaintenanceUpdateInput = z.object({ diff --git a/packages/subscriptions/src/dispatcher.ts b/packages/subscriptions/src/dispatcher.ts index 65f715be..a71607b2 100644 --- a/packages/subscriptions/src/dispatcher.ts +++ b/packages/subscriptions/src/dispatcher.ts @@ -1,5 +1,6 @@ import { and, db, eq, isNotNull, isNull } from "@openstatus/db"; import { + maintenance, maintenanceUpdate, page, pageSubscriber,