Openstatus www.openstatus.dev

feat: page-components [api] [Part 2.5] (#1777)

* feat: page-components api

* fix: test

* fix: seed

* chore: more tests

authored by

Maximilian Kaske and committed by
GitHub
90027d0e 5d972bc6

+301 -23
+14
apps/server/src/routes/v1/maintenances/get.test.ts
··· 14 14 expect(result.success).toBe(true); 15 15 }); 16 16 17 + test("return the maintenance with monitorIds", async () => { 18 + const res = await app.request("/v1/maintenance/1", { 19 + headers: { 20 + "x-openstatus-key": "1", 21 + }, 22 + }); 23 + const result = MaintenanceSchema.safeParse(await res.json()); 24 + 25 + expect(res.status).toBe(200); 26 + expect(result.success).toBe(true); 27 + expect(result.data?.monitorIds).toBeDefined(); 28 + expect(Array.isArray(result.data?.monitorIds)).toBe(true); 29 + }); 30 + 17 31 test("no auth key should return 401", async () => { 18 32 const res = await app.request("/v1/maintenance/1"); 19 33
+5 -2
apps/server/src/routes/v1/maintenances/get.ts
··· 1 1 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { notEmpty } from "@/utils/not-empty"; 2 3 import { createRoute } from "@hono/zod-openapi"; 3 4 import { and, db, eq } from "@openstatus/db"; 4 5 import { maintenance } from "@openstatus/db/src/schema/maintenances"; ··· 33 34 34 35 const _maintenance = await db.query.maintenance.findFirst({ 35 36 with: { 36 - maintenancesToMonitors: true, 37 + maintenancesToPageComponents: { with: { pageComponent: true } }, 37 38 }, 38 39 where: and( 39 40 eq(maintenance.id, Number(id)), ··· 50 51 51 52 const data = MaintenanceSchema.parse({ 52 53 ..._maintenance, 53 - monitorIds: _maintenance.maintenancesToMonitors.map((m) => m.monitorId), 54 + monitorIds: _maintenance.maintenancesToPageComponents 55 + .map((m) => m.pageComponent.monitorId) 56 + .filter(notEmpty), 54 57 }); 55 58 56 59 return c.json(data, 200);
+20
apps/server/src/routes/v1/maintenances/get_all.test.ts
··· 17 17 expect(result.data?.length).toBeGreaterThan(0); 18 18 }); 19 19 20 + test("return all maintenances with monitorIds", async () => { 21 + const res = await app.request("/v1/maintenance", { 22 + method: "GET", 23 + headers: { 24 + "x-openstatus-key": "1", 25 + }, 26 + }); 27 + 28 + const result = MaintenanceSchema.array().safeParse(await res.json()); 29 + 30 + expect(res.status).toBe(200); 31 + expect(result.success).toBe(true); 32 + expect(result.data?.length).toBeGreaterThan(0); 33 + // Each maintenance should have monitorIds defined 34 + for (const maintenance of result.data || []) { 35 + expect(maintenance.monitorIds).toBeDefined(); 36 + expect(Array.isArray(maintenance.monitorIds)).toBe(true); 37 + } 38 + }); 39 + 20 40 test("return empty maintenances", async () => { 21 41 const res = await app.request("/v1/maintenance", { 22 42 method: "GET",
+5 -2
apps/server/src/routes/v1/maintenances/get_all.ts
··· 1 1 import { openApiErrorResponses } from "@/libs/errors"; 2 + import { notEmpty } from "@/utils/not-empty"; 2 3 import { createRoute } from "@hono/zod-openapi"; 3 4 import { db, desc, eq } from "@openstatus/db"; 4 5 import { maintenance } from "@openstatus/db/src/schema/maintenances"; ··· 30 31 31 32 const _maintenances = await db.query.maintenance.findMany({ 32 33 with: { 33 - maintenancesToMonitors: true, 34 + maintenancesToPageComponents: { with: { pageComponent: true } }, 34 35 }, 35 36 where: eq(maintenance.workspaceId, workspaceId), 36 37 orderBy: desc(maintenance.createdAt), ··· 39 40 const data = MaintenanceSchema.array().parse( 40 41 _maintenances.map((m) => ({ 41 42 ...m, 42 - monitorIds: m.maintenancesToMonitors.map((mtm) => mtm.monitorId), 43 + monitorIds: m.maintenancesToPageComponents 44 + .map((mtm) => mtm.pageComponent.monitorId) 45 + .filter(notEmpty), 43 46 })), 44 47 ); 45 48
+28
apps/server/src/routes/v1/maintenances/post.test.ts
··· 133 133 expect(res.status).toBe(200); 134 134 expect(result.success).toBe(true); 135 135 expect(result.data?.monitorIds?.length).toBe(1); 136 + expect(result.data?.monitorIds).toEqual([1]); 137 + }); 138 + 139 + test("create a maintenance with multiple monitorIds", async () => { 140 + const from = new Date(); 141 + const to = new Date(from.getTime() + 3600000); // 1 hour later 142 + 143 + const res = await app.request("/v1/maintenance", { 144 + method: "POST", 145 + headers: { 146 + "x-openstatus-key": "1", 147 + "content-type": "application/json", 148 + }, 149 + body: JSON.stringify({ 150 + title: "Multi-Monitor Maintenance", 151 + message: "Maintenance affecting multiple monitors", 152 + from: from.toISOString(), 153 + to: to.toISOString(), 154 + monitorIds: [1, 2], 155 + pageId: 1, 156 + }), 157 + }); 158 + const result = MaintenanceSchema.safeParse(await res.json()); 159 + 160 + expect(res.status).toBe(200); 161 + expect(result.success).toBe(true); 162 + expect(result.data?.monitorIds?.length).toBe(2); 163 + expect(result.data?.monitorIds).toEqual(expect.arrayContaining([1, 2])); 136 164 }); 137 165 138 166 test("create a maintenance with invalid dates should return 400", async () => {
+17 -6
apps/server/src/routes/v1/pages/get.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 3 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { notEmpty } from "@/utils/not-empty"; 4 5 import { and, eq } from "@openstatus/db"; 5 6 import { db } from "@openstatus/db/src/db"; 6 7 import { page } from "@openstatus/db/src/schema"; ··· 33 34 const workspaceId = c.get("workspace").id; 34 35 const { id } = c.req.valid("param"); 35 36 36 - const _page = await db 37 - .select() 38 - .from(page) 39 - .where(and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id)))) 40 - .get(); 37 + const _page = await db.query.page.findFirst({ 38 + where: and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id))), 39 + with: { 40 + pageComponents: true, 41 + }, 42 + }); 41 43 42 44 if (!_page) { 43 45 throw new OpenStatusApiError({ ··· 46 48 }); 47 49 } 48 50 49 - const data = transformPageData(PageSchema.parse(_page)); 51 + const monitorIds = _page.pageComponents 52 + .map((pc) => pc.monitorId) 53 + .filter(notEmpty); 54 + 55 + const data = transformPageData( 56 + PageSchema.parse({ 57 + ..._page, 58 + monitors: monitorIds.length > 0 ? monitorIds : undefined, 59 + }), 60 + ); 50 61 51 62 return c.json(data, 200); 52 63 });
+18 -5
apps/server/src/routes/v1/pages/get_all.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 3 import { openApiErrorResponses } from "@/libs/errors"; 4 + import { notEmpty } from "@/utils/not-empty"; 4 5 import { db, eq } from "@openstatus/db"; 5 6 import { page } from "@openstatus/db/src/schema"; 6 7 import type { pagesApi } from "./index"; ··· 28 29 return api.openapi(getAllRoute, async (c) => { 29 30 const workspaceId = c.get("workspace").id; 30 31 31 - const _pages = await db 32 - .select() 33 - .from(page) 34 - .where(eq(page.workspaceId, workspaceId)); 32 + const _pages = await db.query.page.findMany({ 33 + where: eq(page.workspaceId, workspaceId), 34 + with: { 35 + pageComponents: true, 36 + }, 37 + }); 35 38 36 39 const data = PageSchema.array() 37 - .parse(_pages) 40 + .parse( 41 + _pages.map((p) => { 42 + const monitorIds = p.pageComponents 43 + .map((pc) => pc.monitorId) 44 + .filter(notEmpty); 45 + return { 46 + ...p, 47 + monitors: monitorIds.length > 0 ? monitorIds : undefined, 48 + }; 49 + }), 50 + ) 38 51 .map((page) => transformPageData(page)); 39 52 40 53 return c.json(data, 200);
+18
apps/server/src/routes/v1/statusReports/get.test.ts
··· 17 17 expect(result.data?.monitorIds?.length).toBeGreaterThan(0); 18 18 }); 19 19 20 + test("return the status report with correct monitorIds structure", async () => { 21 + const res = await app.request("/v1/status_report/2", { 22 + headers: { 23 + "x-openstatus-key": "1", 24 + }, 25 + }); 26 + const result = StatusReportSchema.safeParse(await res.json()); 27 + 28 + expect(res.status).toBe(200); 29 + expect(result.success).toBe(true); 30 + expect(result.data?.monitorIds).toBeDefined(); 31 + expect(Array.isArray(result.data?.monitorIds)).toBe(true); 32 + // Ensure each monitorId is a number 33 + for (const monitorId of result.data?.monitorIds || []) { 34 + expect(typeof monitorId).toBe("number"); 35 + } 36 + }); 37 + 20 38 test("no auth key should return 401", async () => { 21 39 const res = await app.request("/v1/status_report/2"); 22 40
+7 -5
apps/server/src/routes/v1/statusReports/get.ts
··· 4 4 import { statusReport } from "@openstatus/db/src/schema"; 5 5 6 6 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 + import { notEmpty } from "@/utils/not-empty"; 7 8 import type { statusReportsApi } from "./index"; 8 9 import { ParamsSchema, StatusReportSchema } from "./schema"; 9 10 ··· 36 37 const _statusUpdate = await db.query.statusReport.findFirst({ 37 38 with: { 38 39 statusReportUpdates: true, 39 - monitorsToStatusReports: true, 40 + statusReportsToPageComponents: { with: { pageComponent: true } }, 40 41 }, 41 42 where: and( 42 43 eq(statusReport.workspaceId, workspaceId), ··· 51 52 }); 52 53 } 53 54 54 - const { statusReportUpdates, monitorsToStatusReports } = _statusUpdate; 55 + const { statusReportUpdates, statusReportsToPageComponents } = 56 + _statusUpdate; 55 57 56 58 // most recent report information 57 59 const { message, date } = ··· 61 63 ..._statusUpdate, 62 64 message, 63 65 date, 64 - monitorIds: monitorsToStatusReports.length 65 - ? monitorsToStatusReports.map((monitor) => monitor.monitorId) 66 - : null, 66 + monitorIds: statusReportsToPageComponents 67 + .map((sr) => sr.pageComponent.monitorId) 68 + .filter(notEmpty), 67 69 68 70 statusReportUpdateIds: statusReportUpdates.map((update) => update.id), 69 71 });
+24
apps/server/src/routes/v1/statusReports/get_all.test.ts
··· 18 18 expect(result.data?.length).toBeGreaterThan(0); 19 19 }); 20 20 21 + test("return all status reports with monitorIds", async () => { 22 + const res = await app.request("/v1/status_report", { 23 + method: "GET", 24 + headers: { 25 + "x-openstatus-key": "1", 26 + }, 27 + }); 28 + 29 + const result = StatusReportSchema.array().safeParse(await res.json()); 30 + 31 + expect(res.status).toBe(200); 32 + expect(result.success).toBe(true); 33 + expect(result.data?.length).toBeGreaterThan(0); 34 + // Each status report should have monitorIds defined 35 + for (const statusReport of result.data || []) { 36 + expect(statusReport.monitorIds).toBeDefined(); 37 + expect(Array.isArray(statusReport.monitorIds)).toBe(true); 38 + // Ensure each monitorId is a number 39 + for (const monitorId of statusReport.monitorIds || []) { 40 + expect(typeof monitorId).toBe("number"); 41 + } 42 + } 43 + }); 44 + 21 45 test("return empty status reports", async () => { 22 46 const res = await app.request("/v1/status_report", { 23 47 method: "GET",
+5 -2
apps/server/src/routes/v1/statusReports/get_all.ts
··· 4 4 import { statusReport } from "@openstatus/db/src/schema"; 5 5 6 6 import { openApiErrorResponses } from "@/libs/errors"; 7 + import { notEmpty } from "@/utils/not-empty"; 7 8 import type { statusReportsApi } from "./index"; 8 9 import { StatusReportSchema } from "./schema"; 9 10 ··· 33 34 const _statusReports = await db.query.statusReport.findMany({ 34 35 with: { 35 36 statusReportUpdates: true, 36 - monitorsToStatusReports: true, 37 + statusReportsToPageComponents: { with: { pageComponent: true } }, 37 38 }, 38 39 where: eq(statusReport.workspaceId, workspaceId), 39 40 }); ··· 42 43 _statusReports.map((r) => ({ 43 44 ...r, 44 45 statusReportUpdateIds: r.statusReportUpdates.map((u) => u.id), 45 - monitorIds: r.monitorsToStatusReports.map((m) => m.monitorId), 46 + monitorIds: r.statusReportsToPageComponents 47 + .map((sr) => sr.pageComponent.monitorId) 48 + .filter(notEmpty), 46 49 })), 47 50 ); 48 51
+80 -1
apps/server/src/routes/v1/statusReports/post.test.ts
··· 28 28 expect(res.status).toBe(200); 29 29 expect(result.success).toBe(true); 30 30 expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); 31 - expect(result.data?.monitorIds?.length).toBeGreaterThan(0); 31 + expect(result.data?.monitorIds?.length).toBe(1); 32 + expect(result.data?.monitorIds).toEqual([1]); 33 + }); 34 + 35 + test("create a status report with multiple monitorIds", async () => { 36 + const date = new Date(); 37 + date.setMilliseconds(0); 38 + 39 + const res = await app.request("/v1/status_report", { 40 + method: "POST", 41 + headers: { 42 + "x-openstatus-key": "1", 43 + "content-type": "application/json", 44 + }, 45 + body: JSON.stringify({ 46 + status: "investigating", 47 + title: "Multi-Monitor Status Report", 48 + message: "Affecting multiple monitors", 49 + monitorIds: [1, 2], 50 + date: date.toISOString(), 51 + pageId: 1, 52 + }), 53 + }); 54 + 55 + const result = StatusReportSchema.safeParse(await res.json()); 56 + 57 + expect(res.status).toBe(200); 58 + expect(result.success).toBe(true); 59 + expect(result.data?.monitorIds?.length).toBe(2); 60 + expect(result.data?.monitorIds).toEqual(expect.arrayContaining([1, 2])); 61 + }); 62 + 63 + test("create a status report without monitorIds", async () => { 64 + const date = new Date(); 65 + date.setMilliseconds(0); 66 + 67 + const res = await app.request("/v1/status_report", { 68 + method: "POST", 69 + headers: { 70 + "x-openstatus-key": "1", 71 + "content-type": "application/json", 72 + }, 73 + body: JSON.stringify({ 74 + status: "investigating", 75 + title: "General Status Report", 76 + message: "No specific monitors affected", 77 + date: date.toISOString(), 78 + pageId: 1, 79 + }), 80 + }); 81 + 82 + const result = StatusReportSchema.safeParse(await res.json()); 83 + 84 + expect(res.status).toBe(200); 85 + expect(result.success).toBe(true); 86 + expect(result.data?.monitorIds).toBeDefined(); 87 + expect(Array.isArray(result.data?.monitorIds)).toBe(true); 88 + }); 89 + 90 + test("create a status report with partial invalid monitorIds should return 400", async () => { 91 + const date = new Date(); 92 + date.setMilliseconds(0); 93 + 94 + const res = await app.request("/v1/status_report", { 95 + method: "POST", 96 + headers: { 97 + "x-openstatus-key": "1", 98 + "content-type": "application/json", 99 + }, 100 + body: JSON.stringify({ 101 + status: "investigating", 102 + title: "Partial Invalid Monitors", 103 + message: "One valid, one invalid", 104 + monitorIds: [1, 9999], 105 + date: date.toISOString(), 106 + pageId: 1, 107 + }), 108 + }); 109 + 110 + expect(res.status).toBe(400); 32 111 }); 33 112 34 113 test("create a status report with invalid monitor should return 400", async () => {
+60
packages/db/src/seed.mts
··· 6 6 incidentTable, 7 7 maintenance, 8 8 maintenancesToMonitors, 9 + maintenancesToPageComponents, 9 10 monitor, 10 11 monitorsToPages, 11 12 monitorsToStatusReport, 12 13 notification, 13 14 notificationsToMonitors, 14 15 page, 16 + pageComponent, 15 17 privateLocation, 16 18 privateLocationToMonitors, 17 19 statusReport, 18 20 statusReportUpdate, 21 + statusReportsToPageComponents, 19 22 user, 20 23 usersToWorkspaces, 21 24 workspace, ··· 168 171 .values({ monitorId: 1, pageId: 1 }) 169 172 .onConflictDoNothing() 170 173 .run(); 174 + 175 + // Page Components - representing monitors on the status page 176 + await db 177 + .insert(pageComponent) 178 + .values([ 179 + { 180 + id: 1, 181 + workspaceId: 1, 182 + pageId: 1, 183 + type: "monitor", 184 + monitorId: 1, 185 + name: "OpenStatus Monitor", 186 + description: "Main website monitoring", 187 + order: 0, 188 + }, 189 + { 190 + id: 2, 191 + workspaceId: 1, 192 + pageId: 1, 193 + type: "monitor", 194 + monitorId: 2, 195 + name: "Google Monitor", 196 + description: "Google.com monitoring", 197 + order: 1, 198 + }, 199 + ]) 200 + .onConflictDoNothing() 201 + .run(); 202 + 171 203 await db 172 204 .insert(notification) 173 205 .values({ ··· 354 386 { 355 387 monitorId: 1, 356 388 statusReportId: 2, 389 + }, 390 + ]) 391 + .onConflictDoNothing() 392 + .run(); 393 + 394 + // Link status reports to page components 395 + await db 396 + .insert(statusReportsToPageComponents) 397 + .values([ 398 + { 399 + statusReportId: 1, 400 + pageComponentId: 1, 401 + }, 402 + { 403 + statusReportId: 2, 404 + pageComponentId: 1, 405 + }, 406 + ]) 407 + .onConflictDoNothing() 408 + .run(); 409 + 410 + // Link maintenances to page components 411 + await db 412 + .insert(maintenancesToPageComponents) 413 + .values([ 414 + { 415 + maintenanceId: 1, 416 + pageComponentId: 1, 357 417 }, 358 418 ]) 359 419 .onConflictDoNothing()