diff --git a/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx b/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx index 76edf647b..c31fdbff0 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx @@ -10,7 +10,7 @@ export function addMaintenanceUpdateChanges( ...(applied ? [{ field: "id", after: applied.id }] : []), { field: "maintenanceId", after: input.maintenanceId }, { field: "message", after: input.message }, - { field: "date", after: input.date }, + ...(input.date ? [{ field: "date", after: input.date }] : []), { field: "notify", after: applied?.notified ?? input.notify }, ]; } @@ -28,5 +28,5 @@ export function updateMaintenanceUpdateChanges( export function deleteMaintenanceUpdateChanges( input: AgentToolInput<"delete_maintenance_update">, ): ChangeRow[] { - return [{ field: "id", before: input.id, after: undefined }]; + return [{ field: "id", before: input.id }]; } diff --git a/apps/dashboard/src/components/forms/form-alert-dialog.tsx b/apps/dashboard/src/components/forms/form-alert-dialog.tsx index 1138b47b1..8d92a5b0e 100644 --- a/apps/dashboard/src/components/forms/form-alert-dialog.tsx +++ b/apps/dashboard/src/components/forms/form-alert-dialog.tsx @@ -35,9 +35,14 @@ export function FormAlertDialog({ const { copy, isCopied } = useCopyToClipboard(); const [open, setOpen] = useState(false); - const handleDelete = async () => { - try { - startTransition(async () => { + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setValue(""); + }; + + const handleDelete = () => { + startTransition(async () => { + try { const promise = submitAction(); toast.promise(promise, { loading: "Deleting...", @@ -50,15 +55,15 @@ export function FormAlertDialog({ }, }); await promise; - setOpen(false); - }); - } catch (error) { - console.error("Failed to revoke:", error); - } + handleOpenChange(false); + } catch (error) { + console.error("Failed to delete:", error); + } + }); }; return ( - + {children ?? ( - + + {total > 1 ? ( + + + + ) : null} diff --git a/apps/server/src/routes/mcp/tools/maintenance.ts b/apps/server/src/routes/mcp/tools/maintenance.ts index f038a0392..b877fa6ec 100644 --- a/apps/server/src/routes/mcp/tools/maintenance.ts +++ b/apps/server/src/routes/mcp/tools/maintenance.ts @@ -4,8 +4,11 @@ import type { } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ServiceContext } from "@openstatus/services"; import { + addMaintenanceUpdateTool, createMaintenanceTool, + deleteMaintenanceUpdateTool, listMaintenancesTool, + updateMaintenanceUpdateTool, } from "@openstatus/services/agent-tools"; import { registerRegistryTools } from "./registry-adapter"; @@ -17,5 +20,8 @@ export function registerMaintenanceTools( return registerRegistryTools(server, ctx, [ listMaintenancesTool, createMaintenanceTool, + addMaintenanceUpdateTool, + updateMaintenanceUpdateTool, + deleteMaintenanceUpdateTool, ]); } diff --git a/apps/server/src/routes/mcp/tools/tools.test.ts b/apps/server/src/routes/mcp/tools/tools.test.ts index c3b38ac9b..6755d1874 100644 --- a/apps/server/src/routes/mcp/tools/tools.test.ts +++ b/apps/server/src/routes/mcp/tools/tools.test.ts @@ -653,6 +653,9 @@ describe("scope filter", () => { expect(reportTools.has("update_status_report")).toBe(false); expect(reportTools.has("resolve_status_report")).toBe(false); expect(maintenanceTools.has("create_maintenance")).toBe(false); + expect(maintenanceTools.has("add_maintenance_update")).toBe(false); + expect(maintenanceTools.has("update_maintenance_update")).toBe(false); + expect(maintenanceTools.has("delete_maintenance_update")).toBe(false); }); test("write key sees both read and write tools", () => { diff --git a/apps/server/src/routes/rpc/handlers/maintenance/errors.ts b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts index ab1a6d184..15b37181a 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/errors.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/errors.ts @@ -6,6 +6,7 @@ import { Code, ConnectError } from "@connectrpc/connect"; export const ErrorReason = { MAINTENANCE_NOT_FOUND: "MAINTENANCE_NOT_FOUND", MAINTENANCE_ID_REQUIRED: "MAINTENANCE_ID_REQUIRED", + MAINTENANCE_UPDATE_ID_REQUIRED: "MAINTENANCE_UPDATE_ID_REQUIRED", MAINTENANCE_CREATE_FAILED: "MAINTENANCE_CREATE_FAILED", MAINTENANCE_UPDATE_FAILED: "MAINTENANCE_UPDATE_FAILED", INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", @@ -62,6 +63,17 @@ export function maintenanceIdRequiredError(): ConnectError { ); } +/** + * Creates a "maintenance update ID required" error. + */ +export function maintenanceUpdateIdRequiredError(): ConnectError { + return createError( + "Maintenance update ID is required", + Code.InvalidArgument, + ErrorReason.MAINTENANCE_UPDATE_ID_REQUIRED, + ); +} + /** * Creates a "failed to create maintenance" error. */ diff --git a/apps/server/src/routes/rpc/handlers/maintenance/index.ts b/apps/server/src/routes/rpc/handlers/maintenance/index.ts index e5574c527..a9ae93de9 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/index.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/index.ts @@ -19,7 +19,11 @@ import { dbMaintenanceToProtoSummary, dbMaintenanceUpdateToProto, } from "./converters"; -import { invalidDateFormatError, maintenanceIdRequiredError } from "./errors"; +import { + invalidDateFormatError, + maintenanceIdRequiredError, + maintenanceUpdateIdRequiredError, +} from "./errors"; function parseDate(dateString: string): Date { const date = new Date(dateString); @@ -31,14 +35,8 @@ function parseDate(dateString: string): Date { function parsePageComponentIds(ids: ReadonlyArray): number[] { return ids.map((id) => { - // `Number.parseInt(id, 10)` rather than `Number(id)` — `Number("")` - // is `0` (finite!), so the previous guard silently coerced an - // empty-string id into component 0 and the service's - // `NotFoundError` ended up as a misleading 404 on the wire. - // `parseInt` returns NaN for `""`, which fails the finite check - // and surfaces the correct `InvalidArgument` here. - const n = Number.parseInt(id, 10); - if (!Number.isFinite(n)) { + const n = Number(id); + if (id.trim() === "" || !Number.isSafeInteger(n)) { throw new ConnectError( `Invalid page component id: "${id}"`, Code.InvalidArgument, @@ -242,7 +240,7 @@ export const maintenanceServiceImpl: ServiceImpl = { try { const rpcCtx = getRpcContext(ctx); if (!req.id?.trim()) { - throw maintenanceIdRequiredError(); + throw maintenanceUpdateIdRequiredError(); } const update = await updateMaintenanceUpdate({ ctx: toServiceCtx(rpcCtx), @@ -262,7 +260,7 @@ export const maintenanceServiceImpl: ServiceImpl = { try { const rpcCtx = getRpcContext(ctx); if (!req.id?.trim()) { - throw maintenanceIdRequiredError(); + throw maintenanceUpdateIdRequiredError(); } await deleteMaintenanceUpdate({ ctx: toServiceCtx(rpcCtx), diff --git a/apps/server/src/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index 1167bb708..b8eefd263 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -162,7 +162,7 @@ api.route("/status_report", statusReportsApi); api.route("/status_report_update", statusReportUpdatesApi); api.route("/incident", incidentsApi); api.route("/maintenance", maintenancesApi); -api.route("/maintenanceUpdates", maintenanceUpdatesApi); +api.route("/maintenance_update", maintenanceUpdatesApi); api.route("/notification", notificationsApi); api.route("/page_subscriber", pageSubscribersApi); api.route("/check", checkApi); 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 e0355de86..e05301bb0 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts @@ -9,7 +9,7 @@ const headers = { }; test("maintenance update REST CRUD", async () => { - const created = await app.request("/v1/maintenanceUpdates", { + const created = await app.request("/v1/maintenance_update", { method: "POST", headers, body: JSON.stringify({ @@ -21,12 +21,12 @@ test("maintenance update REST CRUD", async () => { expect(created.status).toBe(200); const update = await created.json(); - const fetched = await app.request(`/v1/maintenanceUpdates/${update.id}`, { + const fetched = await app.request(`/v1/maintenance_update/${update.id}`, { headers, }); expect(fetched.status).toBe(200); - const edited = await app.request(`/v1/maintenanceUpdates/${update.id}`, { + const edited = await app.request(`/v1/maintenance_update/${update.id}`, { method: "PUT", headers, body: JSON.stringify({ message: "Edited REST maintenance update" }), @@ -34,7 +34,7 @@ test("maintenance update REST CRUD", async () => { expect(edited.status).toBe(200); expect((await edited.json()).message).toBe("Edited REST maintenance update"); - const deleted = await app.request(`/v1/maintenanceUpdates/${update.id}`, { + const deleted = await app.request(`/v1/maintenance_update/${update.id}`, { method: "DELETE", headers, }); @@ -43,6 +43,6 @@ test("maintenance update REST CRUD", async () => { }); test("maintenance update REST requires authentication", async () => { - const response = await app.request("/v1/maintenanceUpdates/1"); + const response = await app.request("/v1/maintenance_update/1"); expect(response.status).toBe(401); }); diff --git a/apps/server/src/routes/v1/maintenanceUpdates/post.ts b/apps/server/src/routes/v1/maintenanceUpdates/post.ts index b84f9857b..56dc87a15 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/post.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/post.ts @@ -46,10 +46,17 @@ export function registerPostMaintenanceUpdate( const ctx = toServiceContext(c); const result = await addMaintenanceUpdate({ ctx, input }); if (input.notify) { - await notifyMaintenance({ - ctx, - input: { maintenanceUpdateId: result.maintenanceUpdate.id }, - }); + try { + await notifyMaintenance({ + ctx, + input: { maintenanceUpdateId: result.maintenanceUpdate.id }, + }); + } catch (err) { + console.warn( + "notifyMaintenance failed after create maintenance update", + err, + ); + } } return c.json( MaintenanceUpdateSchema.parse(result.maintenanceUpdate), diff --git a/apps/server/src/routes/v1/maintenanceUpdates/schema.ts b/apps/server/src/routes/v1/maintenanceUpdates/schema.ts index 8dc1c39ee..f536e7390 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/schema.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/schema.ts @@ -4,6 +4,7 @@ export const ParamsSchema = z.object({ id: z .string() .min(1) + .regex(/^\d+$/, "ID must be a numeric string") .openapi({ param: { name: "id", in: "path" }, description: "The maintenance update id", diff --git a/apps/server/src/routes/v1/maintenanceUpdates/utils.ts b/apps/server/src/routes/v1/maintenanceUpdates/utils.ts index 510f7b556..0039405f8 100644 --- a/apps/server/src/routes/v1/maintenanceUpdates/utils.ts +++ b/apps/server/src/routes/v1/maintenanceUpdates/utils.ts @@ -31,12 +31,11 @@ export function throwApiError(error: unknown): never { ? "FORBIDDEN" : error.code === "UNAUTHORIZED" ? "UNAUTHORIZED" - : error.code === "CONFLICT" || - error.code === "VALIDATION" || - error.code === "PRECONDITION_FAILED" - ? "BAD_REQUEST" - : error.code === "LIMIT_EXCEEDED" - ? "CONFLICT" + : error.code === "CONFLICT" || error.code === "LIMIT_EXCEEDED" + ? "CONFLICT" + : error.code === "VALIDATION" || + error.code === "PRECONDITION_FAILED" + ? "BAD_REQUEST" : "INTERNAL_SERVER_ERROR"; throw new OpenStatusApiError({ code, message: error.message }); } diff --git a/apps/server/src/routes/v1/maintenances/post.ts b/apps/server/src/routes/v1/maintenances/post.ts index 28e02e3ce..73a02e0b5 100644 --- a/apps/server/src/routes/v1/maintenances/post.ts +++ b/apps/server/src/routes/v1/maintenances/post.ts @@ -10,8 +10,10 @@ import { maintenancesToPageComponents, pageComponent, } from "@openstatus/db/src/schema/page_components"; +import { emitAudit } from "@openstatus/services"; import { dispatchMaintenanceUpdate } from "@openstatus/subscriptions"; +import { tb } from "@/libs/clients"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; @@ -56,12 +58,26 @@ const postRoute = createRoute({ export function registerPostMaintenance(api: typeof maintenancesApi) { return api.openapi(postRoute, async (c) => { - const workspaceId = c.get("workspace").id; + const workspace = c.get("workspace"); + const workspaceId = workspace.id; + const apiKey = c.get("apiKey"); const input = c.req.valid("json"); - const limits = c.get("workspace").limits; + const limits = workspace.limits; const { monitorIds, pageId } = input; + const serviceCtx = { + workspace, + actor: { + type: "apiKey" as const, + keyId: apiKey.id, + userId: apiKey.createdById, + scopes: apiKey.scopes, + }, + requestId: c.get("requestId"), + tb, + }; + if (input.from > input.to) { throw new OpenStatusApiError({ code: "BAD_REQUEST", @@ -121,6 +137,14 @@ export function registerPostMaintenance(api: typeof maintenancesApi) { .returning() .get(); + await emitAudit(tx, serviceCtx, { + action: "maintenance_update.create", + entityType: "maintenance_update", + entityId: initialUpdate.id, + after: initialUpdate, + metadata: { maintenanceId: newMaintenance.id }, + }); + if (monitorIds?.length && newMaintenance.pageId) { // Get page components for the given monitors and page const pageComponents = await tx diff --git a/apps/server/src/routes/v1/maintenances/put.test.ts b/apps/server/src/routes/v1/maintenances/put.test.ts index 1400f196a..ba6d80e3c 100644 --- a/apps/server/src/routes/v1/maintenances/put.test.ts +++ b/apps/server/src/routes/v1/maintenances/put.test.ts @@ -1,3 +1,5 @@ +import { db, desc, eq } from "@openstatus/db"; +import { maintenanceUpdate } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; import { test } from "@std/testing/bdd"; @@ -95,6 +97,14 @@ test("update only the message", async () => { expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.message).toBe("Only Message Updated"); + + const latestUpdate = await db + .select() + .from(maintenanceUpdate) + .where(eq(maintenanceUpdate.maintenanceId, 1)) + .orderBy(desc(maintenanceUpdate.date), desc(maintenanceUpdate.id)) + .get(); + expect(latestUpdate?.message).toBe("Only Message Updated"); }); test.ignore("update only the dates", async () => { diff --git a/apps/server/src/routes/v1/maintenances/put.ts b/apps/server/src/routes/v1/maintenances/put.ts index 2e67a4cf3..f37a1ded4 100644 --- a/apps/server/src/routes/v1/maintenances/put.ts +++ b/apps/server/src/routes/v1/maintenances/put.ts @@ -1,13 +1,18 @@ import { createRoute } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; -import { and, db, eq, inArray, isNull } from "@openstatus/db"; +import { and, db, desc, eq, inArray, isNull } from "@openstatus/db"; import { monitor, page } from "@openstatus/db/src/schema"; -import { maintenance } from "@openstatus/db/src/schema/maintenances"; +import { + maintenance, + maintenanceUpdate, +} from "@openstatus/db/src/schema/maintenances"; import { maintenancesToPageComponents, pageComponent, } from "@openstatus/db/src/schema/page_components"; +import { updateMaintenanceUpdate } from "@openstatus/services/maintenance"; +import { tb } from "@/libs/clients"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; @@ -53,9 +58,22 @@ const putRoute = createRoute({ export function registerPutMaintenance(api: typeof maintenancesApi) { return api.openapi(putRoute, async (c) => { - const workspaceId = c.get("workspace").id; + const workspace = c.get("workspace"); + const workspaceId = workspace.id; + const apiKey = c.get("apiKey"); const { id } = c.req.valid("param"); const input = c.req.valid("json"); + const serviceCtx = { + workspace, + actor: { + type: "apiKey" as const, + keyId: apiKey.id, + userId: apiKey.createdById, + scopes: apiKey.scopes, + }, + requestId: c.get("requestId"), + tb, + }; const { monitorIds, pageId } = input; @@ -127,6 +145,27 @@ export function registerPutMaintenance(api: typeof maintenancesApi) { } const updatedMaintenance = await db.transaction(async (tx) => { + if (input.message !== undefined) { + const latestUpdate = await tx + .select({ id: maintenanceUpdate.id }) + .from(maintenanceUpdate) + .where(eq(maintenanceUpdate.maintenanceId, Number(id))) + .orderBy(desc(maintenanceUpdate.date), desc(maintenanceUpdate.id)) + .get(); + + if (!latestUpdate) { + throw new OpenStatusApiError({ + code: "CONFLICT", + message: "A maintenance must have at least one update.", + }); + } + + await updateMaintenanceUpdate({ + ctx: { ...serviceCtx, db: tx }, + input: { id: latestUpdate.id, message: input.message }, + }); + } + const updated = await tx .update(maintenance) .set({ diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts index c8d99696a..510b1a094 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/[type]/route.ts @@ -97,11 +97,7 @@ export async function GET( title: `${statusLabel("maintenance")} - ${maintenance.title}`, link: maintenanceUrl, description, - date: - updates[0]?.date ?? - maintenance.updatedAt ?? - maintenance.createdAt ?? - new Date(), + date: maintenance.updatedAt ?? maintenance.createdAt ?? new Date(), }); } diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts index bb70a4d1c..a2e28d460 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/feed/json/route.ts @@ -78,7 +78,7 @@ export async function GET( return { id: maintenance.id, name: maintenance.title, - message: updates.length === 0 ? maintenance.message : undefined, + message: maintenance.message, maintenanceUpdates: updates.map((update) => ({ id: update.id, message: update.message, diff --git a/packages/api/src/router/maintenance.test.ts b/packages/api/src/router/maintenance.test.ts index 76ce419d6..803752269 100644 --- a/packages/api/src/router/maintenance.test.ts +++ b/packages/api/src/router/maintenance.test.ts @@ -246,6 +246,8 @@ 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", diff --git a/packages/api/src/router/maintenance.ts b/packages/api/src/router/maintenance.ts index 5f2797777..c7e180d55 100644 --- a/packages/api/src/router/maintenance.ts +++ b/packages/api/src/router/maintenance.ts @@ -194,11 +194,16 @@ export const maintenanceRouter = createTRPCRouter({ updateUpdate: protectedProcedure .meta({ track: Events.UpdateMaintenanceUpdate }) .input( - z.object({ - id: z.number(), - message: z.string().optional(), - date: z.coerce.date().optional(), - }), + z + .object({ + id: z.number(), + message: z.string().min(1).optional(), + date: z.coerce.date().optional(), + }) + .refine( + (input) => input.message !== undefined || input.date !== undefined, + { message: "At least one field must be provided." }, + ), ) .mutation(async ({ ctx, input }) => { try { diff --git a/packages/services/src/agent-tools/__tests__/maintenance-updates.test.ts b/packages/services/src/agent-tools/__tests__/maintenance-updates.test.ts index 9832e99ae..7687bdecf 100644 --- a/packages/services/src/agent-tools/__tests__/maintenance-updates.test.ts +++ b/packages/services/src/agent-tools/__tests__/maintenance-updates.test.ts @@ -10,6 +10,20 @@ describe("maintenance update agent tools", () => { expect(agentTools.delete_maintenance_update.scope).toBe("write"); }); + test("rejects id-only update payloads", () => { + expect( + agentTools.update_maintenance_update.inputSchema.safeParse({ + id: 1, + }).success, + ).toBe(false); + expect( + agentTools.update_maintenance_update.inputSchema.safeParse({ + id: 1, + message: "corrected", + }).success, + ).toBe(true); + }); + test("requires an explicit notify decision when adding an update", () => { expect( agentTools.add_maintenance_update.inputSchema.safeParse({ diff --git a/packages/services/src/agent-tools/maintenance.ts b/packages/services/src/agent-tools/maintenance.ts index 5ce8e16ce..e3a973b49 100644 --- a/packages/services/src/agent-tools/maintenance.ts +++ b/packages/services/src/agent-tools/maintenance.ts @@ -60,6 +60,13 @@ const ListMaintenancesOutput = z.object({ to: z.string(), pageId: z.number().int().nullable(), pageComponentIds: z.array(z.number().int()), + updates: z.array( + z.object({ + id: z.number().int(), + message: z.string(), + date: z.string(), + }), + ), }), ), pagination: z.object({ @@ -76,7 +83,7 @@ export const listMaintenancesTool: AgentTool< > = { name: "list_maintenances", description: - "List maintenance windows in this workspace, newest first. Paginated via `page` (1-indexed) and `perPage`.", + "List maintenance windows in this workspace, newest first. Returns timeline update ids so edit/delete can target a specific entry. Paginated via `page` (1-indexed) and `perPage`.", scope: "read", destructive: false, inputSchema: ListMaintenancesInputShape, @@ -101,6 +108,11 @@ export const listMaintenancesTool: AgentTool< to: m.to.toISOString(), pageId: m.pageId, pageComponentIds: m.pageComponentIds, + updates: m.updates.map((u) => ({ + id: u.id, + message: u.message, + date: u.date.toISOString(), + })), })), pagination: { page, @@ -149,6 +161,7 @@ const CreateMaintenanceOutput = z.object({ from: z.string(), to: z.string(), pageId: z.number().int().nullable(), + initialUpdateId: z.number().int(), notified: z.boolean(), }); @@ -209,11 +222,10 @@ export const createMaintenanceTool: AgentTool< let notified = false; if (input.notify) { try { - await notifyMaintenance({ + notified = await notifyMaintenance({ ctx, input: { maintenanceUpdateId: result.initialUpdate.id }, }); - notified = true; } catch (err) { console.warn("notifyMaintenance failed after create_maintenance", err); } @@ -224,6 +236,7 @@ export const createMaintenanceTool: AgentTool< from: result.maintenance.from.toISOString(), to: result.maintenance.to.toISOString(), pageId: result.maintenance.pageId, + initialUpdateId: result.initialUpdate.id, notified, }; }, @@ -290,11 +303,10 @@ export const addMaintenanceUpdateTool: AgentTool< let notified = false; if (input.notify) { try { - await notifyMaintenance({ + notified = await notifyMaintenance({ ctx, input: { maintenanceUpdateId: result.maintenanceUpdate.id }, }); - notified = true; } catch (err) { console.warn( "notifyMaintenance failed after add_maintenance_update", @@ -314,7 +326,12 @@ export const addMaintenanceUpdateTool: AgentTool< const UpdateMaintenanceUpdateInputShape = z .object({ - id: z.number().int().describe("Maintenance update id."), + id: z + .number() + .int() + .describe( + "Maintenance update id from list_maintenances (or create/add result) — never guess.", + ), message: z .string() .min(1) @@ -332,7 +349,7 @@ export const updateMaintenanceUpdateTool: AgentTool< > = { name: "update_maintenance_update", description: - "Edit an existing maintenance timeline entry. PUBLIC and AUDIT-LOGGED. Does not notify subscribers.", + "Edit an existing maintenance timeline entry. PUBLIC and AUDIT-LOGGED. Does not notify subscribers. The update id MUST come from list_maintenances (or a prior create/add result).", scope: "write", destructive: true, inputSchema: UpdateMaintenanceUpdateInputShape, @@ -368,7 +385,12 @@ export const updateMaintenanceUpdateTool: AgentTool< }; const DeleteMaintenanceUpdateInputShape = z.object({ - id: z.number().int().describe("Maintenance update id."), + id: z + .number() + .int() + .describe( + "Maintenance update id from list_maintenances (or create/add result) — never guess.", + ), }); const DeleteMaintenanceUpdateOutput = z.object({ id: z.number().int(), @@ -381,7 +403,7 @@ export const deleteMaintenanceUpdateTool: AgentTool< > = { name: "delete_maintenance_update", description: - "Delete a maintenance timeline entry. PUBLIC, AUDIT-LOGGED, AND IRREVERSIBLE. A maintenance must retain at least one update.", + "Delete a maintenance timeline entry. PUBLIC, AUDIT-LOGGED, AND IRREVERSIBLE. A maintenance must retain at least one update. The update id MUST come from list_maintenances (or a prior create/add result).", scope: "write", destructive: true, inputSchema: DeleteMaintenanceUpdateInputShape, diff --git a/packages/services/src/maintenance/__tests__/maintenance.test.ts b/packages/services/src/maintenance/__tests__/maintenance.test.ts index c1e7b462d..8a9757e69 100644 --- a/packages/services/src/maintenance/__tests__/maintenance.test.ts +++ b/packages/services/src/maintenance/__tests__/maintenance.test.ts @@ -390,6 +390,50 @@ describe("updateMaintenance", () => { ).rejects.toBeInstanceOf(ConflictError); }); }); + + test("syncs message onto the latest update child and audits", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const { maintenance: record, initialUpdate } = await createMaintenance({ + ctx, + input: { + title: `${TEST_PREFIX}-message-sync`, + message: "initial", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + const updated = await updateMaintenance({ + ctx, + input: { id: record.id, message: "synced message" }, + }); + expect(updated.message).toBe("synced message"); + + const child = await tx + .select() + .from(maintenanceUpdate) + .where(eq(maintenanceUpdate.id, initialUpdate.id)) + .get(); + expect(child?.message).toBe("synced message"); + + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "maintenance.update", + entityType: "maintenance", + entityId: record.id, + db: tx, + }); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "maintenance_update.update", + entityType: "maintenance_update", + entityId: initialUpdate.id, + db: tx, + }); + }); + }); }); describe("deleteMaintenance", () => { @@ -696,16 +740,53 @@ describe("notifyMaintenance", () => { }, }); - await notifyMaintenance({ + const dispatched = await notifyMaintenance({ ctx: { ...teamCtx, db: tx }, input: { maintenanceUpdateId: initialUpdate.id }, }); + expect(dispatched).toBe(true); expect(subscriptionSpies?.dispatchMaintenanceUpdate.mock.calls).toEqual([ [initialUpdate.id], ]); }); }); + + test("returns false when the plan disables status-subscribers", async () => { + await withTestTransaction(async (tx) => { + const { initialUpdate } = await createMaintenance({ + ctx: { ...teamCtx, db: tx }, + input: { + title: `${TEST_PREFIX}-notify-gated`, + message: "m", + ...futureRange(), + pageId: testPageId, + pageComponentIds: [], + }, + }); + + const gatedCtx: ServiceContext = { + ...teamCtx, + db: tx, + workspace: { + ...teamCtx.workspace, + limits: { + ...teamCtx.workspace.limits, + "status-subscribers": false, + }, + }, + }; + + const dispatched = await notifyMaintenance({ + ctx: gatedCtx, + input: { maintenanceUpdateId: initialUpdate.id }, + }); + expect(dispatched).toBe(false); + expect(subscriptionSpies?.dispatchMaintenanceUpdate.mock.calls).toEqual( + [], + ); + }); + }); }); describe("slack actor path", () => { diff --git a/packages/services/src/maintenance/get-update.ts b/packages/services/src/maintenance/get-update.ts index 56226f2c8..44221c253 100644 --- a/packages/services/src/maintenance/get-update.ts +++ b/packages/services/src/maintenance/get-update.ts @@ -1,6 +1,4 @@ -import { db as defaultDb } from "@openstatus/db"; - -import type { ServiceContext } from "../context"; +import { type ServiceContext, getReadDb } from "../context"; import type { MaintenanceUpdate } from "../types"; import { getMaintenanceUpdateInWorkspace } from "./internal"; import { GetMaintenanceUpdateInput } from "./schemas"; @@ -12,7 +10,7 @@ export async function getMaintenanceUpdate(args: { const { ctx } = args; const input = GetMaintenanceUpdateInput.parse(args.input); return getMaintenanceUpdateInWorkspace({ - tx: ctx.db ?? defaultDb, + tx: getReadDb(ctx), id: input.id, workspaceId: ctx.workspace.id, }); diff --git a/packages/services/src/maintenance/notify.ts b/packages/services/src/maintenance/notify.ts index 431311b5c..64a8bb706 100644 --- a/packages/services/src/maintenance/notify.ts +++ b/packages/services/src/maintenance/notify.ts @@ -15,12 +15,14 @@ import { NotifyMaintenanceInput } from "./schemas"; * * Enforces: * - Workspace owns the target update (via its parent maintenance). - * - Plan has `status-subscribers` enabled — otherwise no-op. + * - Plan has `status-subscribers` enabled — otherwise returns false. + * + * 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); @@ -46,8 +48,9 @@ export async function notifyMaintenance(args: { } if (!ctx.workspace.limits["status-subscribers"]) { - return; + return false; } await dispatchMaintenanceUpdate(input.maintenanceUpdateId); + return true; } diff --git a/packages/services/src/maintenance/schemas.ts b/packages/services/src/maintenance/schemas.ts index 232d1556f..0e885cc02 100644 --- a/packages/services/src/maintenance/schemas.ts +++ b/packages/services/src/maintenance/schemas.ts @@ -45,11 +45,15 @@ export type AddMaintenanceUpdateInput = z.infer< typeof AddMaintenanceUpdateInput >; -export const UpdateMaintenanceUpdateInput = z.object({ - id: z.number().int(), - message: z.string().min(1).optional(), - date: z.coerce.date().optional(), -}); +export const UpdateMaintenanceUpdateInput = z + .object({ + id: z.number().int(), + message: z.string().min(1).optional(), + date: z.coerce.date().optional(), + }) + .refine((input) => input.message !== undefined || input.date !== undefined, { + message: "At least one field must be provided.", + }); export type UpdateMaintenanceUpdateInput = z.infer< typeof UpdateMaintenanceUpdateInput >;