Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'thomasmol-feat/add-maintenance-resource-to-API'

+696
+7
apps/server/src/routes/v1/index.ts
··· 8 8 import type { Workspace } from "@openstatus/db/src/schema"; 9 9 import { checkApi } from "./check"; 10 10 import { incidentsApi } from "./incidents"; 11 + import { maintenancesApi } from "./maintenances"; 11 12 import { monitorsApi } from "./monitors"; 12 13 import { notificationsApi } from "./notifications"; 13 14 import { pageSubscribersApi } from "./pageSubscribers"; ··· 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", ··· 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", maintenancesApi); 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/maintenances/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/maintenances/get.ts
··· 1 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { createRoute } from "@hono/zod-openapi"; 3 + import { and, db, eq } from "@openstatus/db"; 4 + import { maintenance } from "@openstatus/db/src/schema/maintenances"; 5 + import type { maintenancesApi } 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 maintenancesApi) { 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/maintenances/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/maintenances/get_all.ts
··· 1 + import { openApiErrorResponses } from "@/libs/errors"; 2 + import { createRoute } from "@hono/zod-openapi"; 3 + import { db, desc, eq } from "@openstatus/db"; 4 + import { maintenance } from "@openstatus/db/src/schema/maintenances"; 5 + import type { maintenancesApi } 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 maintenancesApi) { 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/maintenances/index.ts
··· 1 + import { handleZodError } from "@/libs/errors"; 2 + import { OpenAPIHono } from "@hono/zod-openapi"; 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 maintenancesApi = new OpenAPIHono<{ Variables: Variables }>({ 10 + defaultHook: handleZodError, 11 + }); 12 + 13 + registerGetAllMaintenances(maintenancesApi); 14 + registerGetMaintenance(maintenancesApi); 15 + registerPostMaintenance(maintenancesApi); 16 + registerPutMaintenance(maintenancesApi); 17 + 18 + export { maintenancesApi };
+60
apps/server/src/routes/v1/maintenances/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 + });
+113
apps/server/src/routes/v1/maintenances/post.ts
··· 1 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { trackMiddleware } from "@/libs/middlewares"; 3 + import { createRoute } from "@hono/zod-openapi"; 4 + import { Events } from "@openstatus/analytics"; 5 + import { and, db, eq, inArray, isNull } from "@openstatus/db"; 6 + import { monitor, page } from "@openstatus/db/src/schema"; 7 + import { 8 + maintenance, 9 + maintenancesToMonitors, 10 + } from "@openstatus/db/src/schema/maintenances"; 11 + import type { maintenancesApi } from "./index"; 12 + import { MaintenanceSchema } from "./schema"; 13 + 14 + const postRoute = createRoute({ 15 + method: "post", 16 + tags: ["maintenance"], 17 + summary: "Create a maintenance", 18 + path: "/", 19 + middleware: [trackMiddleware(Events.CreateMaintenance)], 20 + request: { 21 + body: { 22 + content: { 23 + "application/json": { 24 + schema: MaintenanceSchema.omit({ id: true }), 25 + }, 26 + }, 27 + }, 28 + }, 29 + responses: { 30 + 200: { 31 + content: { 32 + "application/json": { 33 + schema: MaintenanceSchema, 34 + }, 35 + }, 36 + description: "Create a maintenance", 37 + }, 38 + ...openApiErrorResponses, 39 + }, 40 + }); 41 + 42 + export function registerPostMaintenance(api: typeof maintenancesApi) { 43 + return api.openapi(postRoute, async (c) => { 44 + const workspaceId = c.get("workspace").id; 45 + const input = c.req.valid("json"); 46 + 47 + const { monitorIds, pageId } = input; 48 + 49 + const _monitors = await db 50 + .select() 51 + .from(monitor) 52 + .where( 53 + and( 54 + inArray(monitor.id, monitorIds), 55 + eq(monitor.workspaceId, workspaceId), 56 + isNull(monitor.deletedAt), 57 + ), 58 + ) 59 + .all(); 60 + 61 + if (_monitors.length !== monitorIds.length) { 62 + throw new OpenStatusApiError({ 63 + code: "BAD_REQUEST", 64 + message: `Some of the monitors ${monitorIds.join(", ")} not found`, 65 + }); 66 + } 67 + 68 + const _page = await db 69 + .select() 70 + .from(page) 71 + .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) 72 + .get(); 73 + 74 + if (!_page) { 75 + throw new OpenStatusApiError({ 76 + code: "BAD_REQUEST", 77 + message: `Page ${pageId} not found`, 78 + }); 79 + } 80 + 81 + const _maintenance = await db.transaction(async (tx) => { 82 + const newMaintenance = await tx 83 + .insert(maintenance) 84 + .values({ 85 + ...input, 86 + workspaceId, 87 + }) 88 + .returning() 89 + .get(); 90 + 91 + if (monitorIds?.length) { 92 + await tx 93 + .insert(maintenancesToMonitors) 94 + .values( 95 + input.monitorIds.map((monitorId) => ({ 96 + maintenanceId: newMaintenance.id, 97 + monitorId, 98 + })), 99 + ) 100 + .run(); 101 + } 102 + 103 + return newMaintenance; 104 + }); 105 + 106 + const data = MaintenanceSchema.parse({ 107 + ..._maintenance, 108 + monitorIds: input.monitorIds, 109 + }); 110 + 111 + return c.json(data, 200); 112 + }); 113 + }
+99
apps/server/src/routes/v1/maintenances/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 + }); 70 + 71 + test("update with invalid monitor ids should return 400", async () => { 72 + const res = await app.request("/v1/maintenance/1", { 73 + method: "PUT", 74 + headers: { 75 + "x-openstatus-key": "1", 76 + "Content-Type": "application/json", 77 + }, 78 + body: JSON.stringify({ 79 + monitorIds: [999], // Non-existent monitor 80 + }), 81 + }); 82 + 83 + expect(res.status).toBe(400); 84 + }); 85 + 86 + test("update with invalid page id should return 400", async () => { 87 + const res = await app.request("/v1/maintenance/1", { 88 + method: "PUT", 89 + headers: { 90 + "x-openstatus-key": "1", 91 + "Content-Type": "application/json", 92 + }, 93 + body: JSON.stringify({ 94 + pageId: 999, // Non-existent page 95 + }), 96 + }); 97 + 98 + expect(res.status).toBe(400); 99 + });
+148
apps/server/src/routes/v1/maintenances/put.ts
··· 1 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { trackMiddleware } from "@/libs/middlewares"; 3 + import { createRoute } from "@hono/zod-openapi"; 4 + import { Events } from "@openstatus/analytics"; 5 + import { and, db, eq, inArray, isNull } from "@openstatus/db"; 6 + import { monitor, page } from "@openstatus/db/src/schema"; 7 + import { 8 + maintenance, 9 + maintenancesToMonitors, 10 + } from "@openstatus/db/src/schema/maintenances"; 11 + import type { maintenancesApi } from "./index"; 12 + import { MaintenanceSchema, ParamsSchema } from "./schema"; 13 + 14 + const putRoute = createRoute({ 15 + method: "put", 16 + tags: ["maintenance"], 17 + summary: "Update a maintenance", 18 + path: "/:id", 19 + middleware: [trackMiddleware(Events.UpdateMaintenance)], 20 + request: { 21 + params: ParamsSchema, 22 + body: { 23 + content: { 24 + "application/json": { 25 + schema: MaintenanceSchema.omit({ id: true }).partial(), 26 + }, 27 + }, 28 + }, 29 + }, 30 + responses: { 31 + 200: { 32 + content: { 33 + "application/json": { 34 + schema: MaintenanceSchema, 35 + }, 36 + }, 37 + description: "Update a maintenance", 38 + }, 39 + ...openApiErrorResponses, 40 + }, 41 + }); 42 + 43 + export function registerPutMaintenance(api: typeof maintenancesApi) { 44 + return api.openapi(putRoute, async (c) => { 45 + const workspaceId = c.get("workspace").id; 46 + const { id } = c.req.valid("param"); 47 + const input = c.req.valid("json"); 48 + 49 + const { monitorIds, pageId } = input; 50 + 51 + const _maintenance = await db.query.maintenance.findFirst({ 52 + with: { 53 + maintenancesToMonitors: true, 54 + }, 55 + where: and( 56 + eq(maintenance.id, Number(id)), 57 + eq(maintenance.workspaceId, workspaceId), 58 + ), 59 + }); 60 + 61 + if (!_maintenance) { 62 + throw new OpenStatusApiError({ 63 + code: "NOT_FOUND", 64 + message: `Maintenance ${id} not found`, 65 + }); 66 + } 67 + 68 + if (monitorIds?.length) { 69 + const _monitors = await db 70 + .select() 71 + .from(monitor) 72 + .where( 73 + and( 74 + inArray(monitor.id, monitorIds), 75 + eq(monitor.workspaceId, workspaceId), 76 + isNull(monitor.deletedAt), 77 + ), 78 + ) 79 + .all(); 80 + 81 + if (_monitors.length !== monitorIds.length) { 82 + throw new OpenStatusApiError({ 83 + code: "BAD_REQUEST", 84 + message: `Some of the monitors ${monitorIds.join(", ")} not found`, 85 + }); 86 + } 87 + } 88 + 89 + if (pageId) { 90 + const _page = await db 91 + .select() 92 + .from(page) 93 + .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) 94 + .get(); 95 + 96 + if (!_page) { 97 + throw new OpenStatusApiError({ 98 + code: "BAD_REQUEST", 99 + message: `Page ${pageId} not found`, 100 + }); 101 + } 102 + } 103 + 104 + const updatedMaintenance = await db.transaction(async (tx) => { 105 + const updated = await tx 106 + .update(maintenance) 107 + .set({ 108 + ...input, 109 + updatedAt: new Date(), 110 + }) 111 + .where(eq(maintenance.id, Number(id))) 112 + .returning() 113 + .get(); 114 + 115 + if (monitorIds) { 116 + // Delete existing monitor associations 117 + await tx 118 + .delete(maintenancesToMonitors) 119 + .where(eq(maintenancesToMonitors.maintenanceId, Number(id))) 120 + .run(); 121 + 122 + // Add new monitor associations 123 + if (monitorIds.length > 0) { 124 + await tx 125 + .insert(maintenancesToMonitors) 126 + .values( 127 + monitorIds.map((monitorId) => ({ 128 + maintenanceId: Number(id), 129 + monitorId, 130 + })), 131 + ) 132 + .run(); 133 + } 134 + } 135 + 136 + return updated; 137 + }); 138 + 139 + const data = MaintenanceSchema.parse({ 140 + ...updatedMaintenance, 141 + monitorIds: 142 + monitorIds ?? 143 + _maintenance.maintenancesToMonitors.map((m) => m.monitorId), 144 + }); 145 + 146 + return c.json(data, 200); 147 + }); 148 + }
+48
apps/server/src/routes/v1/maintenances/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>;
+25
packages/db/src/seed.mts
··· 5 5 import { env } from "../env.mjs"; 6 6 import { 7 7 incidentTable, 8 + maintenance, 9 + maintenancesToMonitors, 8 10 monitor, 9 11 monitorsToPages, 10 12 monitorsToStatusReport, ··· 196 198 status: "investigating", 197 199 message: "Message", 198 200 date: new Date(), 201 + }) 202 + .onConflictDoNothing() 203 + .run(); 204 + 205 + await db 206 + .insert(maintenance) 207 + .values({ 208 + id: 1, 209 + workspaceId: 1, 210 + title: "Test Maintenance", 211 + message: "Test message", 212 + from: new Date(), 213 + to: new Date(Date.now() + 1000), 214 + pageId: 1, 215 + }) 216 + .onConflictDoNothing() 217 + .run(); 218 + 219 + await db 220 + .insert(maintenancesToMonitors) 221 + .values({ 222 + maintenanceId: 1, 223 + monitorId: 1, 199 224 }) 200 225 .onConflictDoNothing() 201 226 .run();