Openstatus www.openstatus.dev

add maintenance resource routes

+568 -1
+8 -1
apps/server/src/routes/v1/index.ts
··· 10 10 import { incidentsApi } from "./incidents"; 11 11 import { monitorsApi } from "./monitors"; 12 12 import { notificationsApi } from "./notifications"; 13 + import { maintenanceApi } from "./maintenance"; 13 14 import { pageSubscribersApi } from "./pageSubscribers"; 14 15 import { pagesApi } from "./pages"; 15 16 import { statusReportUpdatesApi } from "./statusReportUpdates"; ··· 72 73 "x-displayName": "Incident", 73 74 }, 74 75 { 76 + name: "maintenance", 77 + description: "Maintenance related endpoints", 78 + "x-displayName": "Maintenance", 79 + }, 80 + { 75 81 name: "notification", 76 82 description: "Notification related endpoints", 77 83 "x-displayName": "Notification", ··· 115 121 "https://openstatus.dev/api/og?title=OpenStatus%20API&description=API%20Reference", 116 122 twitterCard: "summary_large_image", 117 123 }, 118 - }), 124 + }) 119 125 ); 120 126 /** 121 127 * Middlewares ··· 130 136 api.route("/status_report", statusReportsApi); 131 137 api.route("/status_report_update", statusReportUpdatesApi); 132 138 api.route("/incident", incidentsApi); 139 + api.route("/maintenance", maintenanceApi); 133 140 api.route("/notification", notificationsApi); 134 141 api.route("/page_subscriber", pageSubscribersApi); 135 142 api.route("/check", checkApi);
+31
apps/server/src/routes/v1/maintenance/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + import { MaintenanceSchema } from "./schema"; 4 + 5 + test("return the maintenance", async () => { 6 + const res = await app.request("/v1/maintenance/1", { 7 + headers: { 8 + "x-openstatus-key": "1", 9 + }, 10 + }); 11 + const result = MaintenanceSchema.safeParse(await res.json()); 12 + 13 + expect(res.status).toBe(200); 14 + expect(result.success).toBe(true); 15 + }); 16 + 17 + test("no auth key should return 401", async () => { 18 + const res = await app.request("/v1/maintenance/1"); 19 + 20 + expect(res.status).toBe(401); 21 + }); 22 + 23 + test("invalid maintenance id should return 404", async () => { 24 + const res = await app.request("/v1/maintenance/999", { 25 + headers: { 26 + "x-openstatus-key": "1", 27 + }, 28 + }); 29 + 30 + expect(res.status).toBe(404); 31 + });
+58
apps/server/src/routes/v1/maintenance/get.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + import { and, db, eq } from "@openstatus/db"; 3 + import { maintenance } from "@openstatus/db/src/schema/maintenances"; 4 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 5 + import type { maintenanceApi } from "./index"; 6 + import { MaintenanceSchema, ParamsSchema } from "./schema"; 7 + 8 + const getRoute = createRoute({ 9 + method: "get", 10 + tags: ["maintenance"], 11 + summary: "Get a maintenance", 12 + path: "/:id", 13 + request: { 14 + params: ParamsSchema, 15 + }, 16 + responses: { 17 + 200: { 18 + content: { 19 + "application/json": { 20 + schema: MaintenanceSchema, 21 + }, 22 + }, 23 + description: "Get a maintenance", 24 + }, 25 + ...openApiErrorResponses, 26 + }, 27 + }); 28 + 29 + export function registerGetMaintenance(api: typeof maintenanceApi) { 30 + return api.openapi(getRoute, async (c) => { 31 + const workspaceId = c.get("workspace").id; 32 + const { id } = c.req.valid("param"); 33 + 34 + const _maintenance = await db.query.maintenance.findFirst({ 35 + with: { 36 + maintenancesToMonitors: true, 37 + }, 38 + where: and( 39 + eq(maintenance.id, Number(id)), 40 + eq(maintenance.workspaceId, workspaceId) 41 + ), 42 + }); 43 + 44 + if (!_maintenance) { 45 + throw new OpenStatusApiError({ 46 + code: "NOT_FOUND", 47 + message: `Maintenance ${id} not found`, 48 + }); 49 + } 50 + 51 + const data = MaintenanceSchema.parse({ 52 + ..._maintenance, 53 + monitorIds: _maintenance.maintenancesToMonitors.map((m) => m.monitorId), 54 + }); 55 + 56 + return c.json(data, 200); 57 + }); 58 + }
+41
apps/server/src/routes/v1/maintenance/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + import { MaintenanceSchema } from "./schema"; 4 + 5 + test("return all maintenances", async () => { 6 + const res = await app.request("/v1/maintenance", { 7 + method: "GET", 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + 13 + const result = MaintenanceSchema.array().safeParse(await res.json()); 14 + 15 + expect(res.status).toBe(200); 16 + expect(result.success).toBe(true); 17 + expect(result.data?.length).toBeGreaterThan(0); 18 + }); 19 + 20 + test("return empty maintenances", async () => { 21 + const res = await app.request("/v1/maintenance", { 22 + method: "GET", 23 + headers: { 24 + "x-openstatus-key": "2", 25 + }, 26 + }); 27 + 28 + const result = MaintenanceSchema.array().safeParse(await res.json()); 29 + 30 + expect(result.success).toBe(true); 31 + expect(res.status).toBe(200); 32 + expect(result.data?.length).toBe(0); 33 + }); 34 + 35 + test("no auth key should return 401", async () => { 36 + const res = await app.request("/v1/maintenance", { 37 + method: "GET", 38 + }); 39 + 40 + expect(res.status).toBe(401); 41 + });
+48
apps/server/src/routes/v1/maintenance/get_all.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + import { db, eq, desc } from "@openstatus/db"; 3 + import { maintenance } from "@openstatus/db/src/schema/maintenances"; 4 + import { openApiErrorResponses } from "@/libs/errors"; 5 + import type { maintenanceApi } from "./index"; 6 + import { MaintenanceSchema } from "./schema"; 7 + 8 + const getAllRoute = createRoute({ 9 + method: "get", 10 + tags: ["maintenance"], 11 + summary: "List all maintenances", 12 + path: "/", 13 + request: {}, 14 + responses: { 15 + 200: { 16 + content: { 17 + "application/json": { 18 + schema: MaintenanceSchema.array(), 19 + }, 20 + }, 21 + description: "Get all maintenances", 22 + }, 23 + ...openApiErrorResponses, 24 + }, 25 + }); 26 + 27 + export function registerGetAllMaintenances(api: typeof maintenanceApi) { 28 + return api.openapi(getAllRoute, async (c) => { 29 + const workspaceId = c.get("workspace").id; 30 + 31 + const _maintenances = await db.query.maintenance.findMany({ 32 + with: { 33 + maintenancesToMonitors: true, 34 + }, 35 + where: eq(maintenance.workspaceId, workspaceId), 36 + orderBy: desc(maintenance.createdAt) 37 + }); 38 + 39 + const data = MaintenanceSchema.array().parse( 40 + _maintenances.map((m) => ({ 41 + ...m, 42 + monitorIds: m.maintenancesToMonitors.map((mtm) => mtm.monitorId), 43 + })) 44 + ); 45 + 46 + return c.json(data, 200); 47 + }); 48 + }
+18
apps/server/src/routes/v1/maintenance/index.ts
··· 1 + import { OpenAPIHono } from "@hono/zod-openapi"; 2 + import { handleZodError } from "@/libs/errors"; 3 + import type { Variables } from "../index"; 4 + import { registerGetMaintenance } from "./get"; 5 + import { registerGetAllMaintenances } from "./get_all"; 6 + import { registerPostMaintenance } from "./post"; 7 + import { registerPutMaintenance } from "./put"; 8 + 9 + const maintenanceApi = new OpenAPIHono<{ Variables: Variables }>({ 10 + defaultHook: handleZodError, 11 + }); 12 + 13 + registerGetAllMaintenances(maintenanceApi); 14 + registerGetMaintenance(maintenanceApi); 15 + registerPostMaintenance(maintenanceApi); 16 + registerPutMaintenance(maintenanceApi); 17 + 18 + export { maintenanceApi };
+60
apps/server/src/routes/v1/maintenance/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + import { MaintenanceSchema } from "./schema"; 4 + 5 + test("create a valid maintenance", async () => { 6 + const from = new Date(); 7 + const to = new Date(from.getTime() + 3600000); // 1 hour later 8 + 9 + const res = await app.request("/v1/maintenance", { 10 + method: "POST", 11 + headers: { 12 + "x-openstatus-key": "1", 13 + "content-type": "application/json", 14 + }, 15 + body: JSON.stringify({ 16 + title: "Database Upgrade", 17 + message: "Scheduled database maintenance", 18 + from: from.toISOString(), 19 + to: to.toISOString(), 20 + monitorIds: [1], 21 + pageId: 1, 22 + }), 23 + }); 24 + 25 + const result = MaintenanceSchema.safeParse(await res.json()); 26 + 27 + expect(res.status).toBe(200); 28 + expect(result.success).toBe(true); 29 + expect(result.data?.monitorIds?.length).toBe(1); 30 + }); 31 + 32 + test("create a maintenance with invalid dates should return 400", async () => { 33 + const res = await app.request("/v1/maintenance", { 34 + method: "POST", 35 + headers: { 36 + "x-openstatus-key": "1", 37 + "content-type": "application/json", 38 + }, 39 + body: JSON.stringify({ 40 + title: "Invalid Maintenance", 41 + message: "Test message", 42 + from: "invalid-date", 43 + to: "invalid-date", 44 + pageId: 1, 45 + }), 46 + }); 47 + 48 + expect(res.status).toBe(400); 49 + }); 50 + 51 + test("no auth key should return 401", async () => { 52 + const res = await app.request("/v1/maintenance", { 53 + method: "POST", 54 + headers: { 55 + "content-type": "application/json", 56 + }, 57 + }); 58 + 59 + expect(res.status).toBe(401); 60 + });
+78
apps/server/src/routes/v1/maintenance/post.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + import { db } from "@openstatus/db"; 3 + import { 4 + maintenance, 5 + maintenancesToMonitors, 6 + } from "@openstatus/db/src/schema/maintenances"; 7 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 + import { trackMiddleware } from "@/libs/middlewares"; 9 + import { Events } from "@openstatus/analytics"; 10 + import type { maintenanceApi } from "./index"; 11 + import { MaintenanceSchema } from "./schema"; 12 + 13 + const postRoute = createRoute({ 14 + method: "post", 15 + tags: ["maintenance"], 16 + summary: "Create a maintenance", 17 + path: "/", 18 + middleware: [trackMiddleware(Events.CreateMaintenance)], 19 + request: { 20 + body: { 21 + content: { 22 + "application/json": { 23 + schema: MaintenanceSchema.omit({ id: true }), 24 + }, 25 + }, 26 + }, 27 + }, 28 + responses: { 29 + 200: { 30 + content: { 31 + "application/json": { 32 + schema: MaintenanceSchema, 33 + }, 34 + }, 35 + description: "Create a maintenance", 36 + }, 37 + ...openApiErrorResponses, 38 + }, 39 + }); 40 + 41 + export function registerPostMaintenance(api: typeof maintenanceApi) { 42 + return api.openapi(postRoute, async (c) => { 43 + const workspaceId = c.get("workspace").id; 44 + const input = c.req.valid("json"); 45 + 46 + const _maintenance = await db.transaction(async (tx) => { 47 + const newMaintenance = await tx 48 + .insert(maintenance) 49 + .values({ 50 + ...input, 51 + workspaceId, 52 + }) 53 + .returning() 54 + .get(); 55 + 56 + if (input.monitorIds?.length) { 57 + await tx 58 + .insert(maintenancesToMonitors) 59 + .values( 60 + input.monitorIds.map((monitorId) => ({ 61 + maintenanceId: newMaintenance.id, 62 + monitorId, 63 + })) 64 + ) 65 + .run(); 66 + } 67 + 68 + return newMaintenance; 69 + }); 70 + 71 + const data = MaintenanceSchema.parse({ 72 + ..._maintenance, 73 + monitorIds: input.monitorIds, 74 + }); 75 + 76 + return c.json(data, 200); 77 + }); 78 + }
+69
apps/server/src/routes/v1/maintenance/put.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + import { MaintenanceSchema } from "./schema"; 4 + 5 + test("update the maintenance", async () => { 6 + const res = await app.request("/v1/maintenance/1", { 7 + method: "PUT", 8 + headers: { 9 + "x-openstatus-key": "1", 10 + "Content-Type": "application/json", 11 + }, 12 + body: JSON.stringify({ 13 + title: "Updated Maintenance", 14 + message: "Updated message", 15 + }), 16 + }); 17 + 18 + const result = MaintenanceSchema.safeParse(await res.json()); 19 + 20 + expect(res.status).toBe(200); 21 + expect(result.success).toBe(true); 22 + expect(result.data?.title).toBe("Updated Maintenance"); 23 + }); 24 + 25 + test("update maintenance monitors", async () => { 26 + const res = await app.request("/v1/maintenance/1", { 27 + method: "PUT", 28 + headers: { 29 + "x-openstatus-key": "1", 30 + "Content-Type": "application/json", 31 + }, 32 + body: JSON.stringify({ 33 + monitorIds: [1, 2], 34 + }), 35 + }); 36 + 37 + const result = MaintenanceSchema.safeParse(await res.json()); 38 + 39 + expect(res.status).toBe(200); 40 + expect(result.success).toBe(true); 41 + expect(result.data?.monitorIds?.length).toBe(2); 42 + }); 43 + 44 + test("invalid maintenance id should return 404", async () => { 45 + const res = await app.request("/v1/maintenance/999", { 46 + method: "PUT", 47 + headers: { 48 + "x-openstatus-key": "1", 49 + "Content-Type": "application/json", 50 + }, 51 + body: JSON.stringify({ 52 + title: "Not Found", 53 + }), 54 + }); 55 + 56 + expect(res.status).toBe(404); 57 + }); 58 + 59 + test("no auth key should return 401", async () => { 60 + const res = await app.request("/v1/maintenance/1", { 61 + method: "PUT", 62 + headers: { 63 + "content-type": "application/json", 64 + }, 65 + body: JSON.stringify({}), 66 + }); 67 + 68 + expect(res.status).toBe(401); 69 + });
+109
apps/server/src/routes/v1/maintenance/put.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + import { and, db, eq } from "@openstatus/db"; 3 + import { 4 + maintenance, 5 + maintenancesToMonitors, 6 + } from "@openstatus/db/src/schema/maintenances"; 7 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 + import { trackMiddleware } from "@/libs/middlewares"; 9 + import { Events } from "@openstatus/analytics"; 10 + import type { maintenanceApi } from "./index"; 11 + import { MaintenanceSchema, ParamsSchema } from "./schema"; 12 + 13 + const putRoute = createRoute({ 14 + method: "put", 15 + tags: ["maintenance"], 16 + summary: "Update a maintenance", 17 + path: "/:id", 18 + middleware: [trackMiddleware(Events.UpdateMaintenance)], 19 + request: { 20 + params: ParamsSchema, 21 + body: { 22 + content: { 23 + "application/json": { 24 + schema: MaintenanceSchema.omit({ id: true }).partial(), 25 + }, 26 + }, 27 + }, 28 + }, 29 + responses: { 30 + 200: { 31 + content: { 32 + "application/json": { 33 + schema: MaintenanceSchema, 34 + }, 35 + }, 36 + description: "Update a maintenance", 37 + }, 38 + ...openApiErrorResponses, 39 + }, 40 + }); 41 + 42 + export function registerPutMaintenance(api: typeof maintenanceApi) { 43 + return api.openapi(putRoute, async (c) => { 44 + const workspaceId = c.get("workspace").id; 45 + const { id } = c.req.valid("param"); 46 + const input = c.req.valid("json"); 47 + 48 + const _maintenance = await db.query.maintenance.findFirst({ 49 + with: { 50 + maintenancesToMonitors: true, 51 + }, 52 + where: and( 53 + eq(maintenance.id, Number(id)), 54 + eq(maintenance.workspaceId, workspaceId) 55 + ), 56 + }); 57 + 58 + if (!_maintenance) { 59 + throw new OpenStatusApiError({ 60 + code: "NOT_FOUND", 61 + message: `Maintenance ${id} not found`, 62 + }); 63 + } 64 + 65 + const updatedMaintenance = await db.transaction(async (tx) => { 66 + const updated = await tx 67 + .update(maintenance) 68 + .set({ 69 + ...input, 70 + updatedAt: new Date(), 71 + }) 72 + .where(eq(maintenance.id, Number(id))) 73 + .returning() 74 + .get(); 75 + 76 + if (input.monitorIds) { 77 + // Delete existing monitor associations 78 + await tx 79 + .delete(maintenancesToMonitors) 80 + .where(eq(maintenancesToMonitors.maintenanceId, Number(id))) 81 + .run(); 82 + 83 + // Add new monitor associations 84 + if (input.monitorIds.length > 0) { 85 + await tx 86 + .insert(maintenancesToMonitors) 87 + .values( 88 + input.monitorIds.map((monitorId) => ({ 89 + maintenanceId: Number(id), 90 + monitorId, 91 + })) 92 + ) 93 + .run(); 94 + } 95 + } 96 + 97 + return updated; 98 + }); 99 + 100 + const data = MaintenanceSchema.parse({ 101 + ...updatedMaintenance, 102 + monitorIds: 103 + input.monitorIds ?? 104 + _maintenance.maintenancesToMonitors.map((m) => m.monitorId), 105 + }); 106 + 107 + return c.json(data, 200); 108 + }); 109 + }
+48
apps/server/src/routes/v1/maintenance/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + 3 + export const ParamsSchema = z.object({ 4 + id: z 5 + .string() 6 + .min(1) 7 + .openapi({ 8 + param: { 9 + name: "id", 10 + in: "path", 11 + }, 12 + description: "The id of the maintenance", 13 + example: "1", 14 + }), 15 + }); 16 + 17 + export const MaintenanceSchema = z 18 + .object({ 19 + id: z.number().openapi({ 20 + description: "The id of the maintenance", 21 + example: 1, 22 + }), 23 + title: z.string().openapi({ 24 + description: "The title of the maintenance", 25 + example: "Database Upgrade", 26 + }), 27 + message: z.string().openapi({ 28 + description: "The message describing the maintenance", 29 + example: "Upgrading database to improve performance", 30 + }), 31 + from: z.coerce.date().openapi({ 32 + description: "When the maintenance starts", 33 + }), 34 + to: z.coerce.date().openapi({ 35 + description: "When the maintenance ends", 36 + }), 37 + monitorIds: z 38 + .array(z.number()) 39 + .optional() 40 + .default([]) 41 + .openapi({ description: "IDs of affected monitors" }), 42 + pageId: z.number().openapi({ 43 + description: "The id of the status page this maintenance belongs to", 44 + }), 45 + }) 46 + .openapi("Maintenance"); 47 + 48 + export type MaintenanceSchema = z.infer<typeof MaintenanceSchema>;