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 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
··· 14 expect(result.success).toBe(true); 15 }); 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 + 31 test("no auth key should return 401", async () => { 32 const res = await app.request("/v1/maintenance/1"); 33
+5 -2
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"; ··· 33 34 const _maintenance = await db.query.maintenance.findFirst({ 35 with: { 36 - maintenancesToMonitors: true, 37 }, 38 where: and( 39 eq(maintenance.id, Number(id)), ··· 50 51 const data = MaintenanceSchema.parse({ 52 ..._maintenance, 53 - monitorIds: _maintenance.maintenancesToMonitors.map((m) => m.monitorId), 54 }); 55 56 return c.json(data, 200);
··· 1 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { notEmpty } from "@/utils/not-empty"; 3 import { createRoute } from "@hono/zod-openapi"; 4 import { and, db, eq } from "@openstatus/db"; 5 import { maintenance } from "@openstatus/db/src/schema/maintenances"; ··· 34 35 const _maintenance = await db.query.maintenance.findFirst({ 36 with: { 37 + maintenancesToPageComponents: { with: { pageComponent: true } }, 38 }, 39 where: and( 40 eq(maintenance.id, Number(id)), ··· 51 52 const data = MaintenanceSchema.parse({ 53 ..._maintenance, 54 + monitorIds: _maintenance.maintenancesToPageComponents 55 + .map((m) => m.pageComponent.monitorId) 56 + .filter(notEmpty), 57 }); 58 59 return c.json(data, 200);
+20
apps/server/src/routes/v1/maintenances/get_all.test.ts
··· 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",
··· 17 expect(result.data?.length).toBeGreaterThan(0); 18 }); 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 + 40 test("return empty maintenances", async () => { 41 const res = await app.request("/v1/maintenance", { 42 method: "GET",
+5 -2
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"; ··· 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), ··· 39 const data = MaintenanceSchema.array().parse( 40 _maintenances.map((m) => ({ 41 ...m, 42 - monitorIds: m.maintenancesToMonitors.map((mtm) => mtm.monitorId), 43 })), 44 ); 45
··· 1 import { openApiErrorResponses } from "@/libs/errors"; 2 + import { notEmpty } from "@/utils/not-empty"; 3 import { createRoute } from "@hono/zod-openapi"; 4 import { db, desc, eq } from "@openstatus/db"; 5 import { maintenance } from "@openstatus/db/src/schema/maintenances"; ··· 31 32 const _maintenances = await db.query.maintenance.findMany({ 33 with: { 34 + maintenancesToPageComponents: { with: { pageComponent: true } }, 35 }, 36 where: eq(maintenance.workspaceId, workspaceId), 37 orderBy: desc(maintenance.createdAt), ··· 40 const data = MaintenanceSchema.array().parse( 41 _maintenances.map((m) => ({ 42 ...m, 43 + monitorIds: m.maintenancesToPageComponents 44 + .map((mtm) => mtm.pageComponent.monitorId) 45 + .filter(notEmpty), 46 })), 47 ); 48
+28
apps/server/src/routes/v1/maintenances/post.test.ts
··· 133 expect(res.status).toBe(200); 134 expect(result.success).toBe(true); 135 expect(result.data?.monitorIds?.length).toBe(1); 136 }); 137 138 test("create a maintenance with invalid dates should return 400", async () => {
··· 133 expect(res.status).toBe(200); 134 expect(result.success).toBe(true); 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])); 164 }); 165 166 test("create a maintenance with invalid dates should return 400", async () => {
+17 -6
apps/server/src/routes/v1/pages/get.ts
··· 1 import { createRoute } from "@hono/zod-openapi"; 2 3 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 import { and, eq } from "@openstatus/db"; 5 import { db } from "@openstatus/db/src/db"; 6 import { page } from "@openstatus/db/src/schema"; ··· 33 const workspaceId = c.get("workspace").id; 34 const { id } = c.req.valid("param"); 35 36 - const _page = await db 37 - .select() 38 - .from(page) 39 - .where(and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id)))) 40 - .get(); 41 42 if (!_page) { 43 throw new OpenStatusApiError({ ··· 46 }); 47 } 48 49 - const data = transformPageData(PageSchema.parse(_page)); 50 51 return c.json(data, 200); 52 });
··· 1 import { createRoute } from "@hono/zod-openapi"; 2 3 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { notEmpty } from "@/utils/not-empty"; 5 import { and, eq } from "@openstatus/db"; 6 import { db } from "@openstatus/db/src/db"; 7 import { page } from "@openstatus/db/src/schema"; ··· 34 const workspaceId = c.get("workspace").id; 35 const { id } = c.req.valid("param"); 36 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 + }); 43 44 if (!_page) { 45 throw new OpenStatusApiError({ ··· 48 }); 49 } 50 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 + ); 61 62 return c.json(data, 200); 63 });
+18 -5
apps/server/src/routes/v1/pages/get_all.ts
··· 1 import { createRoute } from "@hono/zod-openapi"; 2 3 import { openApiErrorResponses } from "@/libs/errors"; 4 import { db, eq } from "@openstatus/db"; 5 import { page } from "@openstatus/db/src/schema"; 6 import type { pagesApi } from "./index"; ··· 28 return api.openapi(getAllRoute, async (c) => { 29 const workspaceId = c.get("workspace").id; 30 31 - const _pages = await db 32 - .select() 33 - .from(page) 34 - .where(eq(page.workspaceId, workspaceId)); 35 36 const data = PageSchema.array() 37 - .parse(_pages) 38 .map((page) => transformPageData(page)); 39 40 return c.json(data, 200);
··· 1 import { createRoute } from "@hono/zod-openapi"; 2 3 import { openApiErrorResponses } from "@/libs/errors"; 4 + import { notEmpty } from "@/utils/not-empty"; 5 import { db, eq } from "@openstatus/db"; 6 import { page } from "@openstatus/db/src/schema"; 7 import type { pagesApi } from "./index"; ··· 29 return api.openapi(getAllRoute, async (c) => { 30 const workspaceId = c.get("workspace").id; 31 32 + const _pages = await db.query.page.findMany({ 33 + where: eq(page.workspaceId, workspaceId), 34 + with: { 35 + pageComponents: true, 36 + }, 37 + }); 38 39 const data = PageSchema.array() 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 + ) 51 .map((page) => transformPageData(page)); 52 53 return c.json(data, 200);
+18
apps/server/src/routes/v1/statusReports/get.test.ts
··· 17 expect(result.data?.monitorIds?.length).toBeGreaterThan(0); 18 }); 19 20 test("no auth key should return 401", async () => { 21 const res = await app.request("/v1/status_report/2"); 22
··· 17 expect(result.data?.monitorIds?.length).toBeGreaterThan(0); 18 }); 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 + 38 test("no auth key should return 401", async () => { 39 const res = await app.request("/v1/status_report/2"); 40
+7 -5
apps/server/src/routes/v1/statusReports/get.ts
··· 4 import { statusReport } from "@openstatus/db/src/schema"; 5 6 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 import type { statusReportsApi } from "./index"; 8 import { ParamsSchema, StatusReportSchema } from "./schema"; 9 ··· 36 const _statusUpdate = await db.query.statusReport.findFirst({ 37 with: { 38 statusReportUpdates: true, 39 - monitorsToStatusReports: true, 40 }, 41 where: and( 42 eq(statusReport.workspaceId, workspaceId), ··· 51 }); 52 } 53 54 - const { statusReportUpdates, monitorsToStatusReports } = _statusUpdate; 55 56 // most recent report information 57 const { message, date } = ··· 61 ..._statusUpdate, 62 message, 63 date, 64 - monitorIds: monitorsToStatusReports.length 65 - ? monitorsToStatusReports.map((monitor) => monitor.monitorId) 66 - : null, 67 68 statusReportUpdateIds: statusReportUpdates.map((update) => update.id), 69 });
··· 4 import { statusReport } from "@openstatus/db/src/schema"; 5 6 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 + import { notEmpty } from "@/utils/not-empty"; 8 import type { statusReportsApi } from "./index"; 9 import { ParamsSchema, StatusReportSchema } from "./schema"; 10 ··· 37 const _statusUpdate = await db.query.statusReport.findFirst({ 38 with: { 39 statusReportUpdates: true, 40 + statusReportsToPageComponents: { with: { pageComponent: true } }, 41 }, 42 where: and( 43 eq(statusReport.workspaceId, workspaceId), ··· 52 }); 53 } 54 55 + const { statusReportUpdates, statusReportsToPageComponents } = 56 + _statusUpdate; 57 58 // most recent report information 59 const { message, date } = ··· 63 ..._statusUpdate, 64 message, 65 date, 66 + monitorIds: statusReportsToPageComponents 67 + .map((sr) => sr.pageComponent.monitorId) 68 + .filter(notEmpty), 69 70 statusReportUpdateIds: statusReportUpdates.map((update) => update.id), 71 });
+24
apps/server/src/routes/v1/statusReports/get_all.test.ts
··· 18 expect(result.data?.length).toBeGreaterThan(0); 19 }); 20 21 test("return empty status reports", async () => { 22 const res = await app.request("/v1/status_report", { 23 method: "GET",
··· 18 expect(result.data?.length).toBeGreaterThan(0); 19 }); 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 + 45 test("return empty status reports", async () => { 46 const res = await app.request("/v1/status_report", { 47 method: "GET",
+5 -2
apps/server/src/routes/v1/statusReports/get_all.ts
··· 4 import { statusReport } from "@openstatus/db/src/schema"; 5 6 import { openApiErrorResponses } from "@/libs/errors"; 7 import type { statusReportsApi } from "./index"; 8 import { StatusReportSchema } from "./schema"; 9 ··· 33 const _statusReports = await db.query.statusReport.findMany({ 34 with: { 35 statusReportUpdates: true, 36 - monitorsToStatusReports: true, 37 }, 38 where: eq(statusReport.workspaceId, workspaceId), 39 }); ··· 42 _statusReports.map((r) => ({ 43 ...r, 44 statusReportUpdateIds: r.statusReportUpdates.map((u) => u.id), 45 - monitorIds: r.monitorsToStatusReports.map((m) => m.monitorId), 46 })), 47 ); 48
··· 4 import { statusReport } from "@openstatus/db/src/schema"; 5 6 import { openApiErrorResponses } from "@/libs/errors"; 7 + import { notEmpty } from "@/utils/not-empty"; 8 import type { statusReportsApi } from "./index"; 9 import { StatusReportSchema } from "./schema"; 10 ··· 34 const _statusReports = await db.query.statusReport.findMany({ 35 with: { 36 statusReportUpdates: true, 37 + statusReportsToPageComponents: { with: { pageComponent: true } }, 38 }, 39 where: eq(statusReport.workspaceId, workspaceId), 40 }); ··· 43 _statusReports.map((r) => ({ 44 ...r, 45 statusReportUpdateIds: r.statusReportUpdates.map((u) => u.id), 46 + monitorIds: r.statusReportsToPageComponents 47 + .map((sr) => sr.pageComponent.monitorId) 48 + .filter(notEmpty), 49 })), 50 ); 51
+80 -1
apps/server/src/routes/v1/statusReports/post.test.ts
··· 28 expect(res.status).toBe(200); 29 expect(result.success).toBe(true); 30 expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); 31 - expect(result.data?.monitorIds?.length).toBeGreaterThan(0); 32 }); 33 34 test("create a status report with invalid monitor should return 400", async () => {
··· 28 expect(res.status).toBe(200); 29 expect(result.success).toBe(true); 30 expect(result.data?.statusReportUpdateIds?.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); 111 }); 112 113 test("create a status report with invalid monitor should return 400", async () => {
+60
packages/db/src/seed.mts
··· 6 incidentTable, 7 maintenance, 8 maintenancesToMonitors, 9 monitor, 10 monitorsToPages, 11 monitorsToStatusReport, 12 notification, 13 notificationsToMonitors, 14 page, 15 privateLocation, 16 privateLocationToMonitors, 17 statusReport, 18 statusReportUpdate, 19 user, 20 usersToWorkspaces, 21 workspace, ··· 168 .values({ monitorId: 1, pageId: 1 }) 169 .onConflictDoNothing() 170 .run(); 171 await db 172 .insert(notification) 173 .values({ ··· 354 { 355 monitorId: 1, 356 statusReportId: 2, 357 }, 358 ]) 359 .onConflictDoNothing()
··· 6 incidentTable, 7 maintenance, 8 maintenancesToMonitors, 9 + maintenancesToPageComponents, 10 monitor, 11 monitorsToPages, 12 monitorsToStatusReport, 13 notification, 14 notificationsToMonitors, 15 page, 16 + pageComponent, 17 privateLocation, 18 privateLocationToMonitors, 19 statusReport, 20 statusReportUpdate, 21 + statusReportsToPageComponents, 22 user, 23 usersToWorkspaces, 24 workspace, ··· 171 .values({ monitorId: 1, pageId: 1 }) 172 .onConflictDoNothing() 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 + 203 await db 204 .insert(notification) 205 .values({ ··· 386 { 387 monitorId: 1, 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, 417 }, 418 ]) 419 .onConflictDoNothing()