Openstatus www.openstatus.dev

test: add missing cases and improve error api structure (#1141)

* test: improve coverage

* feat: add custom error class

* refactor: routes folder

* chore: include asertions pacakge

* fix: trpc test

* chore: add try/catch to public status endpoint

* refactor: subdomain safelist

* chore: coerce date

* chore: create middleware libs folder

* fix: disabe sentry on client error

* fix: unknown error codes

* chore: small stuff

* chore: preload mocks

* chore: cache get ping response details

* chore: make type safe

* chore: type error responses

* chore: small stuff

* chore: add updatedAt

* chore: include monitor run in billing progress bar

* chore: dry checker payload

* chore: add libs checker utils

* chore: add openapi schema model

* chore: add tag names and summary

* chore: add metadata

* chore: small stuff

* chore: validate key function

* fix: format

* chore: add os_ case

* chore: add super admin key

authored by

Maximilian Kaske and committed by
GitHub
18b1ce86 77f17e82

+3410 -2666
+4 -2
apps/server/package.json
··· 7 7 "scripts": { 8 8 "dev": "bun run --hot src/index.ts", 9 9 "start": "NODE_ENV=production bun run src/index.ts", 10 - "test": "bun test" 10 + "test": "bun test", 11 + "tsc": "tsc --noEmit" 11 12 }, 12 13 "dependencies": { 13 14 "@hono/sentry": "1.2.0", 14 15 "@hono/zod-openapi": "0.15.1", 15 16 "@hono/zod-validator": "0.2.2", 16 - "@openstatus/analytics": "workspace:^", 17 + "@openstatus/analytics": "workspace:*", 18 + "@openstatus/assertions": "workspace:*", 17 19 "@openstatus/db": "workspace:*", 18 20 "@openstatus/emails": "workspace:*", 19 21 "@openstatus/error": "workspace:*",
apps/server/src/checker/alerting.test.ts apps/server/src/routes/checker/alerting.test.ts
+1 -1
apps/server/src/checker/alerting.ts apps/server/src/routes/checker/alerting.ts
··· 5 5 selectNotificationSchema, 6 6 } from "@openstatus/db/src/schema"; 7 7 8 + import { checkerAudit } from "@/utils/audit-log"; 8 9 import type { MonitorFlyRegion } from "@openstatus/db/src/schema/constants"; 9 10 import { Redis } from "@openstatus/upstash"; 10 - import { checkerAudit } from "../utils/audit-log"; 11 11 import { providerToFunction } from "./utils"; 12 12 13 13 const redis = Redis.fromEnv();
+2 -2
apps/server/src/checker/index.ts apps/server/src/routes/checker/index.ts
··· 10 10 } from "@openstatus/db/src/schema/monitors/validation"; 11 11 import { Redis } from "@openstatus/upstash"; 12 12 13 + import { env } from "@/env"; 14 + import { checkerAudit } from "@/utils/audit-log"; 13 15 import { flyRegions } from "@openstatus/db/src/schema/constants"; 14 - import { env } from "../env"; 15 - import { checkerAudit } from "../utils/audit-log"; 16 16 import { triggerNotifications, upsertMonitorStatus } from "./alerting"; 17 17 18 18 export const checkerRoute = new Hono();
apps/server/src/checker/utils.ts apps/server/src/routes/checker/utils.ts
+1
apps/server/src/env.ts
··· 14 14 SCREENSHOT_SERVICE_URL: z.string(), 15 15 QSTASH_TOKEN: z.string(), 16 16 NODE_ENV: z.string().default("development"), 17 + SUPER_ADMIN_TOKEN: z.string(), 17 18 }, 18 19 19 20 /**
+25 -6
apps/server/src/index.ts
··· 3 3 import { showRoutes } from "hono/dev"; 4 4 import { logger } from "hono/logger"; 5 5 6 - import { checkerRoute } from "./checker"; 6 + import { prettyJSON } from "hono/pretty-json"; 7 + import { requestId } from "hono/request-id"; 7 8 import { env } from "./env"; 8 9 import { handleError } from "./libs/errors"; 9 - import { publicRoute } from "./public"; 10 - import { api } from "./v1"; 10 + import { checkerRoute } from "./routes/checker"; 11 + import { publicRoute } from "./routes/public"; 12 + import { api } from "./routes/v1"; 11 13 12 - const app = new Hono({ strict: false }); 14 + export const app = new Hono({ strict: false }); 15 + 16 + /** 17 + * Middleware 18 + */ 13 19 app.use("*", sentry({ dsn: process.env.SENTRY_DSN })); 20 + app.use("*", requestId()); 21 + app.use("*", logger()); 22 + app.use("*", prettyJSON()); 23 + 14 24 app.onError(handleError); 15 25 16 26 /** ··· 21 31 /** 22 32 * Ping Pong 23 33 */ 24 - app.use("/ping", logger()); 25 - app.get("/ping", (c) => c.json({ ping: "pong", region: env.FLY_REGION }, 200)); 34 + app.get("/ping", (c) => { 35 + return c.json( 36 + { ping: "pong", region: env.FLY_REGION, requestId: c.get("requestId") }, 37 + 200, 38 + ); 39 + }); 26 40 27 41 /** 28 42 * API Routes v1 29 43 */ 30 44 app.route("/v1", api); 31 45 46 + /** 47 + * TODO: move to `workflows` app 48 + * This route is used by our checker to update the status of the monitors, 49 + * create incidents, and send notifications. 50 + */ 32 51 app.route("/", checkerRoute); 33 52 34 53 const isDev = process.env.NODE_ENV === "development";
+1
apps/server/src/libs/checker/index.ts
··· 1 + export * from "./utils";
+67
apps/server/src/libs/checker/utils.ts
··· 1 + import { OpenStatusApiError } from "@/libs/errors"; 2 + import type { z } from "@hono/zod-openapi"; 3 + import type { selectMonitorSchema } from "@openstatus/db/src/schema"; 4 + import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; 5 + 6 + export function getCheckerPayload( 7 + monitor: z.infer<typeof selectMonitorSchema>, 8 + status: z.infer<typeof selectMonitorSchema>["status"], 9 + ): z.infer<typeof httpPayloadSchema> | z.infer<typeof tpcPayloadSchema> { 10 + const timestamp = new Date().getTime(); 11 + switch (monitor.jobType) { 12 + case "http": 13 + return { 14 + workspaceId: String(monitor.workspaceId), 15 + monitorId: String(monitor.id), 16 + url: monitor.url, 17 + method: monitor.method || "GET", 18 + cronTimestamp: timestamp, 19 + body: monitor.body, 20 + headers: monitor.headers, 21 + status: status, 22 + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, 23 + degradedAfter: monitor.degradedAfter, 24 + timeout: monitor.timeout, 25 + trigger: "api", 26 + }; 27 + case "tcp": 28 + return { 29 + workspaceId: String(monitor.workspaceId), 30 + monitorId: String(monitor.id), 31 + uri: monitor.url, 32 + status: status, 33 + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, 34 + cronTimestamp: timestamp, 35 + degradedAfter: monitor.degradedAfter, 36 + timeout: monitor.timeout, 37 + trigger: "api", 38 + }; 39 + default: 40 + throw new OpenStatusApiError({ 41 + code: "BAD_REQUEST", 42 + message: 43 + "Invalid jobType, currently only 'http' and 'tcp' are supported", 44 + }); 45 + } 46 + } 47 + 48 + export function getCheckerUrl( 49 + monitor: z.infer<typeof selectMonitorSchema>, 50 + opts: { trigger?: "api" | "cron"; data?: boolean } = { 51 + trigger: "api", 52 + data: false, 53 + }, 54 + ): string { 55 + switch (monitor.jobType) { 56 + case "http": 57 + return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; 58 + case "tcp": 59 + return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; 60 + default: 61 + throw new OpenStatusApiError({ 62 + code: "BAD_REQUEST", 63 + message: 64 + "Invalid jobType, currently only 'http' and 'tcp' are supported", 65 + }); 66 + } 67 + }
+1
apps/server/src/libs/errors/index.ts
··· 1 1 export * from "./utils"; 2 + export * from "./openapi-error-responses";
+11 -1
apps/server/src/libs/errors/openapi-error-responses.ts
··· 1 + import type { RouteConfig } from "@hono/zod-openapi"; 1 2 import { createErrorSchema } from "./utils"; 2 3 3 4 export const openApiErrorResponses = { ··· 16 17 content: { 17 18 "application/json": { 18 19 schema: createErrorSchema("UNAUTHORIZED").openapi("ErrUnauthorized"), 20 + }, 21 + }, 22 + }, 23 + 402: { 24 + description: "A higher pricing plan is required to access the resource.", 25 + content: { 26 + "application/json": { 27 + schema: 28 + createErrorSchema("PAYMENT_REQUIRED").openapi("ErrPaymentRequired"), 19 29 }, 20 30 }, 21 31 }, ··· 56 66 }, 57 67 }, 58 68 }, 59 - }; 69 + } satisfies RouteConfig["responses"];
+63 -6
apps/server/src/libs/errors/utils.ts
··· 1 + // Props to Unkey: https://github.com/unkeyed/unkey/blob/main/apps/api/src/pkg/errors/http.ts 1 2 import type { Context } from "hono"; 2 3 import { HTTPException } from "hono/http-exception"; 3 4 5 + import type { ErrorCode } from "@openstatus/error"; 4 6 import { 5 - type ErrorCode, 6 - ErrorCodeEnum, 7 + ErrorCodes, 7 8 SchemaError, 9 + codeToStatus, 8 10 statusToCode, 9 11 } from "@openstatus/error"; 10 12 11 - import { ZodError, z } from "zod"; 13 + import { z } from "@hono/zod-openapi"; 14 + import { ZodError } from "zod"; 15 + 16 + export class OpenStatusApiError extends HTTPException { 17 + public readonly code: ErrorCode; 18 + 19 + constructor({ 20 + code, 21 + message, 22 + }: { 23 + code: ErrorCode; 24 + message: HTTPException["message"]; 25 + }) { 26 + const status = codeToStatus(code); 27 + super(status, { message }); 28 + this.code = code; 29 + } 30 + } 12 31 13 32 export function handleError(err: Error, c: Context): Response { 14 33 if (err instanceof ZodError) { 15 34 const error = SchemaError.fromZod(err, c); 35 + 36 + // If the error is a client error, we disable Sentry 37 + c.get("sentry").setEnabled(false); 38 + 16 39 return c.json<ErrorSchema>( 17 40 { 18 41 code: "BAD_REQUEST", 19 42 message: error.message, 20 43 docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", 44 + requestId: c.get("requestId"), 21 45 }, 22 46 { status: 400 }, 23 47 ); 24 48 } 49 + 50 + /** 51 + * This is a custom error that we throw in our code so we can handle it 52 + */ 53 + if (err instanceof OpenStatusApiError) { 54 + const code = statusToCode(err.status); 55 + 56 + // If the error is a client error, we disable Sentry 57 + if (err.status < 499) { 58 + c.get("sentry").setEnabled(false); 59 + } 60 + 61 + return c.json<ErrorSchema>( 62 + { 63 + code: code, 64 + message: err.message, 65 + docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, 66 + requestId: c.get("requestId"), 67 + }, 68 + { status: err.status }, 69 + ); 70 + } 71 + 25 72 if (err instanceof HTTPException) { 26 73 const code = statusToCode(err.status); 27 74 return c.json<ErrorSchema>( ··· 29 76 code: code, 30 77 message: err.message, 31 78 docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, 79 + requestId: c.get("requestId"), 32 80 }, 33 81 { status: err.status }, 34 82 ); 35 83 } 84 + 36 85 return c.json<ErrorSchema>( 37 86 { 38 87 code: "INTERNAL_SERVER_ERROR", 39 88 message: err.message ?? "Something went wrong", 40 89 docs: "https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR", 90 + requestId: c.get("requestId"), 41 91 }, 42 92 43 93 { status: 500 }, ··· 63 113 code: "BAD_REQUEST", 64 114 docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", 65 115 message: error.message, 116 + requestId: c.get("requestId"), 66 117 }, 67 118 { status: 400 }, 68 119 ); 69 120 } 70 121 } 71 - export type ErrorSchema = z.infer<ReturnType<typeof createErrorSchema>>; 72 122 73 123 export function createErrorSchema(code: ErrorCode) { 74 124 return z.object({ 75 - code: ErrorCodeEnum.openapi({ 125 + code: z.enum(ErrorCodes).openapi({ 76 126 example: code, 77 127 description: "The error code related to the status code.", 78 128 }), 79 129 message: z.string().openapi({ 80 130 description: "A human readable message describing the issue.", 81 - example: "Missing required field 'name'.", 131 + example: "<string>", 82 132 }), 83 133 docs: z.string().openapi({ 84 134 description: "A link to the documentation for the error.", 85 135 example: `https://docs.openstatus.dev/api-references/errors/code/${code}`, 86 136 }), 137 + requestId: z.string().openapi({ 138 + description: 139 + "The request id to be used for debugging and error reporting.", 140 + example: "<uuid>", 141 + }), 87 142 }); 88 143 } 144 + 145 + export type ErrorSchema = z.infer<ReturnType<typeof createErrorSchema>>;
+94
apps/server/src/libs/middlewares/auth.ts
··· 1 + import { verifyKey } from "@unkey/api"; 2 + import type { Context, Next } from "hono"; 3 + 4 + import { env } from "@/env"; 5 + import { OpenStatusApiError } from "@/libs/errors"; 6 + import type { Variables } from "@/types"; 7 + import { db, eq } from "@openstatus/db"; 8 + import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; 9 + 10 + export async function authMiddleware( 11 + c: Context<{ Variables: Variables }, "/*">, 12 + next: Next, 13 + ) { 14 + const key = c.req.header("x-openstatus-key"); 15 + if (!key) 16 + throw new OpenStatusApiError({ 17 + code: "UNAUTHORIZED", 18 + message: "Missing 'x-openstatus-key' header", 19 + }); 20 + 21 + const { error, result } = await validateKey(key); 22 + 23 + if (error) { 24 + throw new OpenStatusApiError({ 25 + code: "INTERNAL_SERVER_ERROR", 26 + message: error.message, 27 + }); 28 + } 29 + if (!result?.valid || !result?.ownerId) { 30 + throw new OpenStatusApiError({ 31 + code: "UNAUTHORIZED", 32 + message: "Invalid API Key", 33 + }); 34 + } 35 + 36 + const _workspace = await db 37 + .select() 38 + .from(workspace) 39 + .where(eq(workspace.id, Number.parseInt(result.ownerId))) 40 + .get(); 41 + 42 + if (!_workspace) { 43 + console.error("Workspace not found"); 44 + throw new OpenStatusApiError({ 45 + code: "NOT_FOUND", 46 + message: "Workspace not found, please contact support", 47 + }); 48 + } 49 + 50 + const validation = selectWorkspaceSchema.safeParse(_workspace); 51 + 52 + if (!validation.success) { 53 + throw new OpenStatusApiError({ 54 + code: "BAD_REQUEST", 55 + message: "Workspace data is invalid", 56 + }); 57 + } 58 + 59 + c.set("workspace", validation.data); 60 + 61 + await next(); 62 + } 63 + 64 + async function validateKey(key: string): Promise<{ 65 + result: { valid: boolean; ownerId?: string }; 66 + error?: { message: string }; 67 + }> { 68 + if (env.NODE_ENV === "production") { 69 + /** 70 + * The Unkey api key starts with `os_` - that's how we can differentiate if we 71 + * want to roll out our own key verification in the future. 72 + * > We cannot use `os_` as a prefix for our own keys. 73 + */ 74 + if (key.startsWith("os_")) { 75 + const { result, error } = await verifyKey(key); 76 + return { 77 + result: { valid: result?.valid ?? false, ownerId: result?.ownerId }, 78 + error: error ? { message: error.message } : undefined, 79 + }; 80 + } 81 + // Special bypass for our workspace 82 + if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) { 83 + return { result: { valid: true, ownerId: "1" } }; 84 + } 85 + // In production, we only accept Unkey keys 86 + throw new OpenStatusApiError({ 87 + code: "UNAUTHORIZED", 88 + message: "Invalid API Key", 89 + }); 90 + } 91 + 92 + // In dev / test mode we can use the key as the ownerId 93 + return { result: { valid: true, ownerId: key } }; 94 + }
+3
apps/server/src/libs/middlewares/index.ts
··· 1 + export * from "./auth"; 2 + export * from "./track"; 3 + export * from "./plan";
+25
apps/server/src/libs/middlewares/plan.ts
··· 1 + import type { Variables } from "@/types"; 2 + import { 3 + type Workspace, 4 + workspacePlanHierarchy, 5 + } from "@openstatus/db/src/schema"; 6 + import type { Context, Next } from "hono"; 7 + import { OpenStatusApiError } from "../errors"; 8 + 9 + /** 10 + * Checks if the workspace has a minimum required plan to access the endpoint 11 + */ 12 + export function minPlanMiddleware({ plan }: { plan: Workspace["plan"] }) { 13 + return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { 14 + const workspace = c.get("workspace"); 15 + 16 + if (workspacePlanHierarchy[workspace.plan] < workspacePlanHierarchy[plan]) { 17 + throw new OpenStatusApiError({ 18 + code: "PAYMENT_REQUIRED", 19 + message: "You need to upgrade your plan to access this feature", 20 + }); 21 + } 22 + 23 + await next(); 24 + }; 25 + }
+40
apps/server/src/libs/middlewares/track.ts
··· 1 + import type { Variables } from "@/types"; 2 + import { 3 + type EventProps, 4 + parseInputToProps, 5 + setupAnalytics, 6 + } from "@openstatus/analytics"; 7 + import type { Context, Next } from "hono"; 8 + 9 + export function trackMiddleware(event: EventProps, eventProps?: string[]) { 10 + return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { 11 + await next(); 12 + 13 + // REMINDER: only track the event if the request was successful 14 + const isValid = c.res.status.toString().startsWith("2") && !c.error; 15 + 16 + if (isValid) { 17 + // We have checked the request to be valid already 18 + let json: unknown; 19 + if (c.req.raw.bodyUsed) { 20 + try { 21 + json = await c.req.json(); 22 + } catch { 23 + json = {}; 24 + } 25 + } 26 + const additionalProps = parseInputToProps(json, eventProps); 27 + const workspace = c.get("workspace"); 28 + 29 + // REMINDER: use setTimeout to avoid blocking the response 30 + setTimeout(async () => { 31 + const analytics = await setupAnalytics({ 32 + userId: `api_${workspace.id}`, 33 + workspaceId: `${workspace.id}`, 34 + plan: workspace.plan, 35 + }); 36 + await analytics.track({ ...event, additionalProps }); 37 + }, 0); 38 + } 39 + }; 40 + }
+23
apps/server/src/libs/test/preload.ts
··· 1 + import { mock } from "bun:test"; 2 + 3 + mock.module("@openstatus/upstash", () => ({ 4 + Redis: { 5 + fromEnv() { 6 + return { 7 + get: () => Promise.resolve(undefined), 8 + set: () => Promise.resolve([]), 9 + }; 10 + }, 11 + }, 12 + })); 13 + 14 + mock.module("@openstatus/tinybird", () => ({ 15 + OSTinybird: class { 16 + httpStatus45d() { 17 + return Promise.resolve({ data: [] }); 18 + } 19 + tcpStatus45d() { 20 + return Promise.resolve({ data: [] }); 21 + } 22 + }, 23 + }));
-2
apps/server/src/public/index.ts apps/server/src/routes/public/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { cors } from "hono/cors"; 3 - import { logger } from "hono/logger"; 4 3 import { timing } from "hono/timing"; 5 4 6 5 import { status } from "./status"; 7 6 8 7 export const publicRoute = new Hono(); 9 8 publicRoute.use("*", cors()); 10 - publicRoute.use("*", logger()); 11 9 publicRoute.use("*", timing()); 12 10 13 11 publicRoute.route("/status", status);
+40 -35
apps/server/src/public/status.ts apps/server/src/routes/public/status.ts
··· 14 14 import { Status, Tracker } from "@openstatus/tracker"; 15 15 import { Redis } from "@openstatus/upstash"; 16 16 17 - import { notEmpty } from "../utils/not-empty"; 17 + import { notEmpty } from "@/utils/not-empty"; 18 18 19 19 // TODO: include ratelimiting 20 20 ··· 23 23 export const status = new Hono(); 24 24 25 25 status.get("/:slug", async (c) => { 26 - const { slug } = c.req.param(); 26 + try { 27 + const { slug } = c.req.param(); 27 28 28 - const cache = await redis.get(slug); 29 + const cache = await redis.get(slug); 29 30 30 - if (cache) { 31 - setMetric(c, "OpenStatus-Cache", "HIT"); 32 - return c.json({ status: cache }); 33 - } 31 + if (cache) { 32 + setMetric(c, "OpenStatus-Cache", "HIT"); 33 + return c.json({ status: cache }); 34 + } 34 35 35 - startTime(c, "database"); 36 + startTime(c, "database"); 36 37 37 - const currentPage = await db 38 - .select() 39 - .from(page) 40 - .where(eq(page.slug, slug)) 41 - .get(); 38 + const currentPage = await db 39 + .select() 40 + .from(page) 41 + .where(eq(page.slug, slug)) 42 + .get(); 42 43 43 - if (!currentPage) { 44 - return c.json({ status: Status.Unknown }); 45 - } 44 + if (!currentPage) { 45 + return c.json({ status: Status.Unknown }); 46 + } 46 47 47 - const { 48 - pageStatusReportData, 49 - monitorStatusReportData, 50 - ongoingIncidents, 51 - maintenanceData, 52 - } = await getStatusPageData(currentPage.id); 53 - endTime(c, "database"); 48 + const { 49 + pageStatusReportData, 50 + monitorStatusReportData, 51 + ongoingIncidents, 52 + maintenanceData, 53 + } = await getStatusPageData(currentPage.id); 54 + endTime(c, "database"); 54 55 55 - const statusReports = [...monitorStatusReportData].map((item) => { 56 - return item.status_report; 57 - }); 56 + const statusReports = [...monitorStatusReportData].map((item) => { 57 + return item.status_report; 58 + }); 58 59 59 - statusReports.push(...pageStatusReportData); 60 + statusReports.push(...pageStatusReportData); 60 61 61 - const tracker = new Tracker({ 62 - incidents: ongoingIncidents, 63 - statusReports, 64 - maintenances: maintenanceData, 65 - }); 62 + const tracker = new Tracker({ 63 + incidents: ongoingIncidents, 64 + statusReports, 65 + maintenances: maintenanceData, 66 + }); 66 67 67 - const status = tracker.currentStatus; 68 - await redis.set(slug, status, { ex: 60 }); // 1m cache 68 + const status = tracker.currentStatus; 69 + await redis.set(slug, status, { ex: 60 }); // 1m cache 69 70 70 - return c.json({ status }); 71 + return c.json({ status }); 72 + } catch (e) { 73 + console.error(`Error in public status page: ${e}`); 74 + return c.json({ status: Status.Unknown }); 75 + } 71 76 }); 72 77 73 78 async function getStatusPageData(pageId: number) {
+14
apps/server/src/routes/v1/check/index.ts
··· 1 + import { OpenAPIHono } from "@hono/zod-openapi"; 2 + 3 + import type { Variables } from "../index"; 4 + 5 + import { handleZodError } from "@/libs/errors"; 6 + import { registerHTTPPostCheck } from "./http/post"; 7 + 8 + const checkApi = new OpenAPIHono<{ Variables: Variables }>({ 9 + defaultHook: handleZodError, 10 + }); 11 + 12 + registerHTTPPostCheck(checkApi); 13 + 14 + export { checkApi };
+32
apps/server/src/routes/v1/incidents/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { IncidentSchema } from "./schema"; 5 + 6 + test("return the incident", async () => { 7 + const res = await app.request("/v1/incident/2", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = IncidentSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/incident/2"); 20 + 21 + expect(res.status).toBe(401); 22 + }); 23 + 24 + test("invalid incident id should return 404", async () => { 25 + const res = await app.request("/v1/incident/2", { 26 + headers: { 27 + "x-openstatus-key": "2", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + });
+42
apps/server/src/routes/v1/incidents/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { IncidentSchema } from "./schema"; 5 + 6 + test("return all incidents", async () => { 7 + const res = await app.request("/v1/incident", { 8 + method: "GET", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = IncidentSchema.array().safeParse(await res.json()); 15 + 16 + expect(res.status).toBe(200); 17 + expect(result.success).toBe(true); 18 + expect(result.data?.length).toBeGreaterThan(0); 19 + }); 20 + 21 + test("return empty incidents", async () => { 22 + const res = await app.request("/v1/incident", { 23 + method: "GET", 24 + headers: { 25 + "x-openstatus-key": "2", 26 + }, 27 + }); 28 + 29 + const result = IncidentSchema.array().safeParse(await res.json()); 30 + 31 + expect(result.success).toBe(true); 32 + expect(res.status).toBe(200); 33 + expect(result.data?.length).toBe(0); 34 + }); 35 + 36 + test("no auth key should return 401", async () => { 37 + const res = await app.request("/v1/incident", { 38 + method: "GET", 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + });
+43
apps/server/src/routes/v1/incidents/get_all.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + 3 + import { db, eq } from "@openstatus/db"; 4 + import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 + 6 + import { openApiErrorResponses } from "@/libs/errors"; 7 + import type { incidentsApi } from "./index"; 8 + import { IncidentSchema } from "./schema"; 9 + 10 + const getAllRoute = createRoute({ 11 + method: "get", 12 + tags: ["incident"], 13 + summary: "List all incidents", 14 + path: "/", 15 + request: {}, 16 + responses: { 17 + 200: { 18 + content: { 19 + "application/json": { 20 + schema: IncidentSchema.array(), 21 + }, 22 + }, 23 + description: "Get all incidents", 24 + }, 25 + ...openApiErrorResponses, 26 + }, 27 + }); 28 + 29 + export function registerGetAllIncidents(app: typeof incidentsApi) { 30 + app.openapi(getAllRoute, async (c) => { 31 + const workspaceId = c.get("workspace").id; 32 + 33 + const _incidents = await db 34 + .select() 35 + .from(incidentTable) 36 + .where(eq(incidentTable.workspaceId, workspaceId)) 37 + .all(); 38 + 39 + const data = IncidentSchema.array().parse(_incidents); 40 + 41 + return c.json(data, 200); 42 + }); 43 + }
+107
apps/server/src/routes/v1/incidents/put.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { IncidentSchema } from "./schema"; 5 + 6 + test("acknlowledge the incident", async () => { 7 + const date = new Date(); 8 + date.setMilliseconds(0); 9 + 10 + const res = await app.request("/v1/incident/2", { 11 + method: "PUT", 12 + headers: { 13 + "x-openstatus-key": "1", 14 + "Content-Type": "application/json", 15 + }, 16 + body: JSON.stringify({ 17 + acknowledgedAt: date.toISOString(), 18 + }), 19 + }); 20 + 21 + const result = IncidentSchema.safeParse(await res.json()); 22 + 23 + expect(res.status).toBe(200); 24 + expect(result.success).toBe(true); 25 + expect(result.data?.acknowledgedAt?.toISOString()).toBe(date.toISOString()); 26 + }); 27 + 28 + test("resolve the incident", async () => { 29 + const date = new Date(); 30 + date.setMilliseconds(0); 31 + 32 + const res = await app.request("/v1/incident/2", { 33 + method: "PUT", 34 + headers: { 35 + "x-openstatus-key": "1", 36 + "Content-Type": "application/json", 37 + }, 38 + body: JSON.stringify({ 39 + resolvedAt: date.toISOString(), 40 + }), 41 + }); 42 + 43 + const result = IncidentSchema.safeParse(await res.json()); 44 + 45 + expect(res.status).toBe(200); 46 + expect(result.success).toBe(true); 47 + expect(result.data?.resolvedAt?.toISOString()).toBe(date.toISOString()); 48 + }); 49 + 50 + test("invalid payload should return 400", async () => { 51 + const res = await app.request("/v1/incident/2", { 52 + method: "PUT", 53 + headers: { 54 + "x-openstatus-key": "1", 55 + "Content-Type": "application/json", 56 + }, 57 + body: JSON.stringify({ 58 + acknowledgedAt: "helloworld", 59 + }), 60 + }); 61 + 62 + const result = (await res.json()) as Record<string, unknown>; 63 + expect(result.message).toBe("invalid_date in 'acknowledgedAt': Invalid date"); 64 + expect(res.status).toBe(400); 65 + }); 66 + 67 + test("invalid incident id should return 404", async () => { 68 + const res = await app.request("/v1/incident/404", { 69 + method: "PUT", 70 + headers: { 71 + "x-openstatus-key": "1", 72 + "Content-Type": "application/json", 73 + }, 74 + body: JSON.stringify({ 75 + acknowledgedAt: new Date().toISOString(), 76 + }), 77 + }); 78 + 79 + expect(res.status).toBe(404); 80 + }); 81 + 82 + test("no auth key should return 401", async () => { 83 + const res = await app.request("/v1/incident/2", { 84 + method: "PUT", 85 + headers: { 86 + "content-type": "application/json", 87 + }, 88 + body: JSON.stringify({ 89 + acknowledgedAt: new Date().toISOString(), 90 + }), 91 + }); 92 + expect(res.status).toBe(401); 93 + }); 94 + 95 + test("update the incident with invalid data should return 400", async () => { 96 + const res = await app.request("/v1/incident/2", { 97 + method: "PUT", 98 + headers: { 99 + "x-openstatus-key": "1", 100 + "content-type": "application/json", 101 + }, 102 + body: JSON.stringify({ 103 + acknowledgedAt: "2023-11-0", 104 + }), 105 + }); 106 + expect(res.status).toBe(400); 107 + });
+45
apps/server/src/routes/v1/incidents/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 Incident", 13 + example: "1", 14 + }), 15 + }); 16 + 17 + export const IncidentSchema = z 18 + .object({ 19 + id: z.number().openapi({ 20 + description: "The id of the incident", 21 + example: 1, 22 + }), 23 + startedAt: z.coerce.date().openapi({ 24 + description: "The date the incident started", 25 + }), 26 + monitorId: z.number().nullable().openapi({ 27 + description: "The id of the monitor associated with the incident", 28 + example: 1, 29 + }), 30 + acknowledgedAt: z.coerce.date().optional().nullable().openapi({ 31 + description: "The date the incident was acknowledged", 32 + }), 33 + acknowledgedBy: z.number().nullable().openapi({ 34 + description: "The user who acknowledged the incident", 35 + }), 36 + resolvedAt: z.coerce.date().optional().nullable().openapi({ 37 + description: "The date the incident was resolved", 38 + }), 39 + resolvedBy: z.number().nullable().openapi({ 40 + description: "The user who resolved the incident", 41 + }), 42 + }) 43 + .openapi("Incident"); 44 + 45 + export type IncidentSchema = z.infer<typeof IncidentSchema>;
+136
apps/server/src/routes/v1/index.ts
··· 1 + import { OpenAPIHono } from "@hono/zod-openapi"; 2 + import { apiReference } from "@scalar/hono-api-reference"; 3 + import { cors } from "hono/cors"; 4 + import type { RequestIdVariables } from "hono/request-id"; 5 + 6 + import { handleZodError } from "@/libs/errors"; 7 + import { authMiddleware } from "@/libs/middlewares"; 8 + import type { Workspace } from "@openstatus/db/src/schema"; 9 + import { checkApi } from "./check"; 10 + import { incidentsApi } from "./incidents"; 11 + import { monitorsApi } from "./monitors"; 12 + import { notificationsApi } from "./notifications"; 13 + import { pageSubscribersApi } from "./pageSubscribers"; 14 + import { pagesApi } from "./pages"; 15 + import { statusReportUpdatesApi } from "./statusReportUpdates"; 16 + import { statusReportsApi } from "./statusReports"; 17 + import { whoamiApi } from "./whoami"; 18 + 19 + export type Variables = RequestIdVariables & { 20 + workspace: Workspace; 21 + }; 22 + 23 + export const api = new OpenAPIHono<{ Variables: Variables }>({ 24 + defaultHook: handleZodError, 25 + }); 26 + 27 + api.use("/openapi", cors()); 28 + 29 + api.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", { 30 + type: "apiKey", 31 + in: "header", 32 + name: "x-openstatus-key", 33 + "x-openstatus-key": "string", 34 + }); 35 + 36 + api.doc("/openapi", { 37 + openapi: "3.0.0", 38 + info: { 39 + version: "1.0.0", 40 + title: "OpenStatus API", 41 + contact: { 42 + email: "ping@openstatus.dev", 43 + url: "https://www.openstatus.dev", 44 + }, 45 + description: 46 + "OpenStatus is a open-source synthetic monitoring tool that allows you to monitor your website and API's uptime, latency, and more. \n\n The OpenStatus API allows you to interact with the OpenStatus platform programmatically. \n\n To get started you need to create an account on https://www.openstatus.dev/ and create an api token in your settings.", 47 + }, 48 + tags: [ 49 + { 50 + name: "monitor", 51 + description: "Monitor related endpoints", 52 + "x-displayName": "Monitor", 53 + }, 54 + { 55 + name: "page", 56 + description: "Page related endpoints", 57 + "x-displayName": "Page", 58 + }, 59 + { 60 + name: "status_report", 61 + description: "Status report related endpoints", 62 + "x-displayName": "Status Report", 63 + }, 64 + { 65 + name: "status_report_update", 66 + description: "Status report update related endpoints", 67 + "x-displayName": "Status Report Update", 68 + }, 69 + { 70 + name: "incident", 71 + description: "Incident related endpoints", 72 + "x-displayName": "Incident", 73 + }, 74 + { 75 + name: "notification", 76 + description: "Notification related endpoints", 77 + "x-displayName": "Notification", 78 + }, 79 + { 80 + name: "page_subscriber", 81 + description: "Page subscriber related endpoints", 82 + "x-displayName": "Page Subscriber", 83 + }, 84 + { 85 + name: "check", 86 + description: "Check related endpoints", 87 + "x-displayName": "Check", 88 + }, 89 + { 90 + name: "whoami", 91 + description: "WhoAmI related endpoints", 92 + "x-displayName": "WhoAmI", 93 + }, 94 + ], 95 + security: [ 96 + { 97 + ApiKeyAuth: [], 98 + }, 99 + ], 100 + }); 101 + 102 + api.get( 103 + "/", 104 + apiReference({ 105 + spec: { 106 + url: "/v1/openapi", 107 + }, 108 + baseServerURL: "https://api.openstatus.dev/v1", 109 + metaData: { 110 + title: "OpenStatus API", 111 + description: "API Reference", 112 + ogDescription: "API Reference", 113 + ogTitle: "OpenStatus API", 114 + ogImage: 115 + "https://openstatus.dev/api/og?title=OpenStatus%20API&description=API%20Reference", 116 + twitterCard: "summary_large_image", 117 + }, 118 + }), 119 + ); 120 + /** 121 + * Middlewares 122 + */ 123 + api.use("/*", authMiddleware); 124 + 125 + /** 126 + * Routes 127 + */ 128 + api.route("/monitor", monitorsApi); 129 + api.route("/page", pagesApi); 130 + api.route("/status_report", statusReportsApi); 131 + api.route("/status_report_update", statusReportUpdatesApi); 132 + api.route("/incident", incidentsApi); 133 + api.route("/notification", notificationsApi); 134 + api.route("/page_subscriber", pageSubscribersApi); 135 + api.route("/check", checkApi); 136 + api.route("/whoami", whoamiApi);
+31
apps/server/src/routes/v1/monitors/delete.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + 4 + test("delete the monitor", async () => { 5 + const res = await app.request("/v1/monitor/3", { 6 + method: "DELETE", 7 + headers: { 8 + "x-openstatus-key": "1", 9 + }, 10 + }); 11 + 12 + expect(res.status).toBe(200); 13 + expect(await res.json()).toMatchObject({}); 14 + }); 15 + 16 + test("no auth key should return 401", async () => { 17 + const res = await app.request("/v1/monitor/2", { method: "DELETE" }); 18 + 19 + expect(res.status).toBe(401); 20 + }); 21 + 22 + test("invalid monitor id should return 404", async () => { 23 + const res = await app.request("/v1/monitor/404", { 24 + method: "DELETE", 25 + headers: { 26 + "x-openstatus-key": "2", 27 + }, 28 + }); 29 + 30 + expect(res.status).toBe(404); 31 + });
+32
apps/server/src/routes/v1/monitors/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("return the monitor", async () => { 7 + const res = await app.request("/v1/monitor/1", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = MonitorSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/monitor/2"); 20 + 21 + expect(res.status).toBe(401); 22 + }); 23 + 24 + test("invalid monitor id should return 404", async () => { 25 + const res = await app.request("/v1/monitor/2", { 26 + headers: { 27 + "x-openstatus-key": "2", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + });
+42
apps/server/src/routes/v1/monitors/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("return all monitors", async () => { 7 + const res = await app.request("/v1/monitor", { 8 + method: "GET", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = MonitorSchema.array().safeParse(await res.json()); 15 + 16 + expect(res.status).toBe(200); 17 + expect(result.success).toBe(true); 18 + expect(result.data?.length).toBeGreaterThan(0); 19 + }); 20 + 21 + test("return empty monitors", async () => { 22 + const res = await app.request("/v1/monitor", { 23 + method: "GET", 24 + headers: { 25 + "x-openstatus-key": "2", 26 + }, 27 + }); 28 + 29 + const result = MonitorSchema.array().safeParse(await res.json()); 30 + 31 + expect(result.success).toBe(true); 32 + expect(res.status).toBe(200); 33 + expect(result.data?.length).toBe(0); 34 + }); 35 + 36 + test("no auth key should return 401", async () => { 37 + const res = await app.request("/v1/monitor", { 38 + method: "GET", 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + });
+93
apps/server/src/routes/v1/monitors/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("create a valid monitor", async () => { 7 + const res = await app.request("/v1/monitor", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + periodicity: "10m", 15 + url: "https://www.openstatus.dev", 16 + name: "OpenStatus", 17 + description: "OpenStatus website", 18 + regions: ["ams", "gru"], 19 + method: "POST", 20 + body: '{"hello":"world"}', 21 + headers: [{ key: "key", value: "value" }], 22 + active: true, 23 + public: true, 24 + assertions: [ 25 + { 26 + type: "status", 27 + compare: "eq", 28 + target: 200, 29 + }, 30 + { type: "header", compare: "not_eq", key: "key", target: "value" }, 31 + ], 32 + }), 33 + }); 34 + 35 + const result = MonitorSchema.safeParse(await res.json()); 36 + 37 + expect(res.status).toBe(200); 38 + expect(result.success).toBe(true); 39 + }); 40 + 41 + test("create a status report with invalid payload should return 400", async () => { 42 + const res = await app.request("/v1/monitor", { 43 + method: "POST", 44 + headers: { 45 + "x-openstatus-key": "1", 46 + "content-type": "application/json", 47 + }, 48 + body: JSON.stringify({ 49 + periodicity: 32, //not valid value 50 + url: "https://www.openstatus.dev", 51 + name: "OpenStatus", 52 + description: "OpenStatus website", 53 + regions: ["ams", "gru"], 54 + method: "POST", 55 + body: '{"hello":"world"}', 56 + headers: [{ key: "key", value: "value" }], 57 + active: true, 58 + public: false, 59 + }), 60 + }); 61 + 62 + expect(res.status).toBe(400); 63 + }); 64 + 65 + test("create a monitor with invalid page id should return 400", async () => { 66 + const res = await app.request("/v1/monitor", { 67 + method: "POST", 68 + headers: { 69 + "x-openstatus-key": "1", 70 + "content-type": "application/json", 71 + }, 72 + body: JSON.stringify({ 73 + status: "investigating", 74 + title: "New Status Report", 75 + message: "Message", 76 + monitorIds: [1], 77 + pageId: 404, 78 + }), 79 + }); 80 + 81 + expect(res.status).toBe(400); 82 + }); 83 + 84 + test("no auth key should return 401", async () => { 85 + const res = await app.request("/v1/monitor", { 86 + method: "POST", 87 + headers: { 88 + "content-type": "application/json", 89 + }, 90 + }); 91 + 92 + expect(res.status).toBe(401); 93 + });
+67
apps/server/src/routes/v1/monitors/put.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("update the monitor", async () => { 7 + const res = await app.request("/v1/monitor/1", { 8 + method: "PUT", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "Content-Type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + name: "New Name", 15 + }), 16 + }); 17 + 18 + const result = MonitorSchema.safeParse(await res.json()); 19 + 20 + expect(res.status).toBe(200); 21 + expect(result.success).toBe(true); 22 + expect(result.data?.name).toBe("New Name"); 23 + }); 24 + 25 + test("update the monitor with a different jobType should return 400", async () => { 26 + const res = await app.request("/v1/monitor/1", { 27 + method: "PUT", 28 + headers: { 29 + "x-openstatus-key": "1", 30 + "Content-Type": "application/json", 31 + }, 32 + body: JSON.stringify({ 33 + jobType: "tcp", 34 + }), 35 + }); 36 + 37 + expect(res.status).toBe(400); 38 + }); 39 + 40 + test("invalid monitor id should return 404", async () => { 41 + const res = await app.request("/v1/page/404", { 42 + method: "PUT", 43 + headers: { 44 + "x-openstatus-key": "1", 45 + "Content-Type": "application/json", 46 + }, 47 + body: JSON.stringify({ 48 + /* */ 49 + }), 50 + }); 51 + 52 + expect(res.status).toBe(404); 53 + }); 54 + 55 + test("no auth key should return 401", async () => { 56 + const res = await app.request("/v1/page/2", { 57 + method: "PUT", 58 + headers: { 59 + "content-type": "application/json", 60 + }, 61 + body: JSON.stringify({ 62 + /* */ 63 + }), 64 + }); 65 + 66 + expect(res.status).toBe(401); 67 + });
+117
apps/server/src/routes/v1/monitors/put.ts
··· 1 + import { createRoute, z } from "@hono/zod-openapi"; 2 + 3 + import { and, db, eq, isNull } from "@openstatus/db"; 4 + import { monitor } from "@openstatus/db/src/schema"; 5 + 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 + import { trackMiddleware } from "@/libs/middlewares"; 8 + import { Events } from "@openstatus/analytics"; 9 + import { serialize } from "@openstatus/assertions"; 10 + import type { monitorsApi } from "./index"; 11 + import { MonitorSchema, ParamsSchema } from "./schema"; 12 + import { getAssertions } from "./utils"; 13 + 14 + const putRoute = createRoute({ 15 + method: "put", 16 + tags: ["monitor"], 17 + summary: "Update a monitor", 18 + path: "/:id", 19 + middleware: [trackMiddleware(Events.UpdateMonitor)], 20 + request: { 21 + params: ParamsSchema, 22 + body: { 23 + description: "The monitor to update", 24 + content: { 25 + "application/json": { 26 + schema: MonitorSchema.omit({ id: true }).partial(), 27 + }, 28 + }, 29 + }, 30 + }, 31 + responses: { 32 + 200: { 33 + content: { 34 + "application/json": { 35 + schema: MonitorSchema, 36 + }, 37 + }, 38 + description: "Update a monitor", 39 + }, 40 + ...openApiErrorResponses, 41 + }, 42 + }); 43 + 44 + export function registerPutMonitor(api: typeof monitorsApi) { 45 + return api.openapi(putRoute, async (c) => { 46 + const workspaceId = c.get("workspace").id; 47 + const limits = c.get("workspace").limits; 48 + const { id } = c.req.valid("param"); 49 + const input = c.req.valid("json"); 50 + 51 + if (input.periodicity && !limits.periodicity.includes(input.periodicity)) { 52 + throw new OpenStatusApiError({ 53 + code: "PAYMENT_REQUIRED", 54 + message: "Upgrade for more periodicity", 55 + }); 56 + } 57 + 58 + if (input.regions) { 59 + for (const region of input.regions) { 60 + if (!limits.regions.includes(region)) { 61 + throw new OpenStatusApiError({ 62 + code: "PAYMENT_REQUIRED", 63 + message: "Upgrade for more regions", 64 + }); 65 + } 66 + } 67 + } 68 + 69 + const _monitor = await db 70 + .select() 71 + .from(monitor) 72 + .where( 73 + and( 74 + eq(monitor.id, Number(id)), 75 + isNull(monitor.deletedAt), 76 + eq(monitor.workspaceId, workspaceId), 77 + ), 78 + ) 79 + .get(); 80 + 81 + if (!_monitor) { 82 + throw new OpenStatusApiError({ 83 + code: "NOT_FOUND", 84 + message: `Monitor ${id} not found`, 85 + }); 86 + } 87 + 88 + if (input.jobType && input.jobType !== _monitor.jobType) { 89 + throw new OpenStatusApiError({ 90 + code: "BAD_REQUEST", 91 + message: 92 + "Cannot change jobType. Please delete and create a new monitor instead.", 93 + }); 94 + } 95 + 96 + const { headers, regions, assertions, ...rest } = input; 97 + 98 + const assert = assertions ? getAssertions(assertions) : []; 99 + 100 + const _newMonitor = await db 101 + .update(monitor) 102 + .set({ 103 + ...rest, 104 + regions: regions ? regions.join(",") : undefined, 105 + headers: input.headers ? JSON.stringify(input.headers) : undefined, 106 + assertions: assert.length > 0 ? serialize(assert) : undefined, 107 + timeout: input.timeout || 45000, 108 + updatedAt: new Date(), 109 + }) 110 + .where(eq(monitor.id, Number(_monitor.id))) 111 + .returning() 112 + .get(); 113 + 114 + const data = MonitorSchema.parse(_newMonitor); 115 + return c.json(data, 200); 116 + }); 117 + }
+163
apps/server/src/routes/v1/monitors/run/post.ts
··· 1 + import { env } from "@/env"; 2 + import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; 3 + import { openApiErrorResponses } from "@/libs/errors"; 4 + import { createRoute, z } from "@hono/zod-openapi"; 5 + import { and, eq, gte, isNull, sql } from "@openstatus/db"; 6 + import { db } from "@openstatus/db/src/db"; 7 + import { monitorRun } from "@openstatus/db/src/schema"; 8 + import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; 9 + import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; 10 + import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; 11 + import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; 12 + import { HTTPException } from "hono/http-exception"; 13 + import type { monitorsApi } from ".."; 14 + import { ParamsSchema, TriggerResult } from "../schema"; 15 + import { QuerySchema } from "./schema"; 16 + 17 + const postMonitor = createRoute({ 18 + method: "post", 19 + tags: ["monitor"], 20 + summary: "Create a monitor run", 21 + description: 22 + "Run a synthetic check for a specific monitor. It will take all configs into account.", 23 + path: "/:id/run", 24 + request: { 25 + params: ParamsSchema, 26 + query: QuerySchema, 27 + }, 28 + responses: { 29 + 200: { 30 + content: { 31 + "application/json": { 32 + schema: TriggerResult.array(), 33 + }, 34 + }, 35 + description: "All the historical metrics", 36 + }, 37 + ...openApiErrorResponses, 38 + }, 39 + }); 40 + 41 + export function registerRunMonitor(api: typeof monitorsApi) { 42 + return api.openapi(postMonitor, async (c) => { 43 + const workspaceId = c.get("workspace").id; 44 + const { id } = c.req.valid("param"); 45 + const limits = c.get("workspace").limits; 46 + const { "no-wait": noWait } = c.req.valid("query"); 47 + const lastMonth = new Date().setMonth(new Date().getMonth() - 1); 48 + 49 + const count = ( 50 + await db 51 + .select({ count: sql<number>`count(*)` }) 52 + .from(monitorRun) 53 + .where( 54 + and( 55 + eq(monitorRun.workspaceId, workspaceId), 56 + gte(monitorRun.createdAt, new Date(lastMonth)), 57 + ), 58 + ) 59 + .all() 60 + )[0].count; 61 + 62 + if (count >= limits["synthetic-checks"]) { 63 + throw new HTTPException(403, { 64 + message: "Upgrade for more checks", 65 + }); 66 + } 67 + 68 + const monitorData = await db 69 + .select() 70 + .from(monitor) 71 + .where( 72 + and( 73 + eq(monitor.id, Number(id)), 74 + eq(monitor.workspaceId, workspaceId), 75 + isNull(monitor.deletedAt), 76 + ), 77 + ) 78 + .get(); 79 + 80 + if (!monitorData) { 81 + throw new HTTPException(404, { message: "Not Found" }); 82 + } 83 + 84 + const parseMonitor = selectMonitorSchema.safeParse(monitorData); 85 + 86 + if (!parseMonitor.success) { 87 + throw new HTTPException(400, { message: "Something went wrong" }); 88 + } 89 + 90 + const row = parseMonitor.data; 91 + 92 + // Maybe later overwrite the region 93 + 94 + const monitorStatusData = await db 95 + .select() 96 + .from(monitorStatusTable) 97 + .where(eq(monitorStatusTable.monitorId, monitorData.id)) 98 + .all(); 99 + 100 + const monitorStatus = selectMonitorStatusSchema 101 + .array() 102 + .safeParse(monitorStatusData); 103 + 104 + if (!monitorStatus.success) { 105 + console.log(monitorStatus.error); 106 + throw new HTTPException(400, { message: "Something went wrong" }); 107 + } 108 + 109 + const timestamp = Date.now(); 110 + 111 + const newRun = await db 112 + .insert(monitorRun) 113 + .values({ 114 + monitorId: row.id, 115 + workspaceId: row.workspaceId, 116 + runnedAt: new Date(timestamp), 117 + }) 118 + .returning(); 119 + 120 + if (!newRun[0]) { 121 + throw new HTTPException(400, { message: "Something went wrong" }); 122 + } 123 + 124 + const allResult = []; 125 + for (const region of parseMonitor.data.regions) { 126 + const status = 127 + monitorStatus.data.find((m) => region === m.region)?.status || "active"; 128 + const payload = getCheckerPayload(row, status); 129 + const url = getCheckerUrl(row, { data: true }); 130 + 131 + const result = fetch(url, { 132 + headers: { 133 + "Content-Type": "application/json", 134 + "fly-prefer-region": region, // Specify the region you want the request to be sent to 135 + Authorization: `Basic ${env.CRON_SECRET}`, 136 + }, 137 + method: "POST", 138 + body: JSON.stringify(payload), 139 + }); 140 + allResult.push(result); 141 + } 142 + 143 + if (noWait) { 144 + return c.json([], 200); 145 + } 146 + 147 + const result = await Promise.all(allResult); 148 + const bodies = await Promise.all(result.map((r) => r.json())); 149 + 150 + const data = TriggerResult.array().safeParse(bodies); 151 + 152 + if (!data) { 153 + throw new HTTPException(400, { message: "Something went wrong" }); 154 + } 155 + 156 + if (!data.success) { 157 + console.log(data.error); 158 + throw new HTTPException(400, { message: "Something went wrong" }); 159 + } 160 + 161 + return c.json(data.data, 200); 162 + }); 163 + }
+11
apps/server/src/routes/v1/monitors/run/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + 3 + export const QuerySchema = z 4 + .object({ 5 + "no-wait": z.coerce.boolean().optional().default(false).openapi({ 6 + description: "Don't wait for the result", 7 + }), 8 + }) 9 + .openapi({ 10 + description: "Query parameters", 11 + });
+36
apps/server/src/routes/v1/monitors/summary/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { z } from "@hono/zod-openapi"; 3 + 4 + import { app } from "@/index"; 5 + import { SummarySchema } from "./schema"; 6 + 7 + test("return the summary of the monitor", async () => { 8 + const res = await app.request("/v1/monitor/1/summary", { 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = z 15 + .object({ data: SummarySchema.array() }) 16 + .safeParse(await res.json()); 17 + 18 + expect(res.status).toBe(200); 19 + expect(result.success).toBe(true); 20 + }); 21 + 22 + test("no auth key should return 401", async () => { 23 + const res = await app.request("/v1/monitor/1/summary"); 24 + 25 + expect(res.status).toBe(401); 26 + }); 27 + 28 + test("invalid monitor id should return 404", async () => { 29 + const res = await app.request("/v1/monitor/404/summary", { 30 + headers: { 31 + "x-openstatus-key": "2", 32 + }, 33 + }); 34 + 35 + expect(res.status).toBe(404); 36 + });
+87
apps/server/src/routes/v1/monitors/summary/get.ts
··· 1 + import { createRoute, z } from "@hono/zod-openapi"; 2 + 3 + import { and, db, eq, isNull } from "@openstatus/db"; 4 + import { monitor } from "@openstatus/db/src/schema"; 5 + import { OSTinybird } from "@openstatus/tinybird"; 6 + import { Redis } from "@openstatus/upstash"; 7 + 8 + import { env } from "@/env"; 9 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 10 + import type { monitorsApi } from "../index"; 11 + import { ParamsSchema, SummarySchema } from "./schema"; 12 + 13 + // TODO: is there another better way to mock Redis/Tinybird? 14 + if (process.env.NODE_ENV === "test") { 15 + require("@/libs/test/preload"); 16 + } 17 + 18 + const tb = new OSTinybird(env.TINY_BIRD_API_KEY); 19 + const redis = Redis.fromEnv(); 20 + 21 + const getMonitorStats = createRoute({ 22 + method: "get", 23 + tags: ["monitor"], 24 + summary: "Get a monitor summary", 25 + description: 26 + "Get a monitor summary of the last 45 days of data to be used within a status page", 27 + path: "/:id/summary", 28 + request: { 29 + params: ParamsSchema, 30 + }, 31 + responses: { 32 + 200: { 33 + content: { 34 + "application/json": { 35 + schema: z.object({ 36 + data: SummarySchema.array(), 37 + }), 38 + }, 39 + }, 40 + description: "All the historical metrics", 41 + }, 42 + ...openApiErrorResponses, 43 + }, 44 + }); 45 + 46 + export function registerGetMonitorSummary(api: typeof monitorsApi) { 47 + return api.openapi(getMonitorStats, async (c) => { 48 + const workspaceId = c.get("workspace").id; 49 + const { id } = c.req.valid("param"); 50 + 51 + const _monitor = await db 52 + .select() 53 + .from(monitor) 54 + .where( 55 + and( 56 + eq(monitor.id, Number(id)), 57 + eq(monitor.workspaceId, workspaceId), 58 + isNull(monitor.deletedAt), 59 + ), 60 + ) 61 + .get(); 62 + 63 + if (!_monitor) { 64 + throw new OpenStatusApiError({ 65 + code: "NOT_FOUND", 66 + message: `Monitor ${id} not found`, 67 + }); 68 + } 69 + 70 + const cache = await redis.get<SummarySchema[]>(`${id}-daily-stats`); 71 + 72 + if (cache) { 73 + console.log("fetching from cache"); 74 + return c.json({ data: cache }, 200); 75 + } 76 + 77 + console.log("fetching from tinybird"); 78 + const res = 79 + _monitor.jobType === "http" 80 + ? await tb.httpStatus45d({ monitorId: id }) 81 + : await tb.tcpStatus45d({ monitorId: id }); 82 + 83 + await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); 84 + 85 + return c.json({ data: res.data }, 200); 86 + }); 87 + }
+20
apps/server/src/routes/v1/monitors/summary/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + import { ParamsSchema } from "../schema"; 3 + 4 + export { ParamsSchema }; 5 + 6 + export const SummarySchema = z.object({ 7 + ok: z.number().int().openapi({ 8 + description: 9 + "The number of ok responses (defined by the assertions - or by default status code 200)", 10 + }), 11 + count: z 12 + .number() 13 + .int() 14 + .openapi({ description: "The total number of request" }), 15 + day: z.coerce 16 + .date() 17 + .openapi({ description: "The date of the daily stat in ISO8601 format" }), 18 + }); 19 + 20 + export type SummarySchema = z.infer<typeof SummarySchema>;
+156
apps/server/src/routes/v1/monitors/trigger/post.ts
··· 1 + import { env } from "@/env"; 2 + import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { createRoute, z } from "@hono/zod-openapi"; 5 + import { and, eq, gte, isNull, sql } from "@openstatus/db"; 6 + import { db } from "@openstatus/db/src/db"; 7 + import { monitorRun } from "@openstatus/db/src/schema"; 8 + import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; 9 + import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; 10 + import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; 11 + import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; 12 + import { HTTPException } from "hono/http-exception"; 13 + import type { monitorsApi } from ".."; 14 + import { ParamsSchema, TriggerSchema } from "./schema"; 15 + 16 + const postRoute = createRoute({ 17 + method: "post", 18 + tags: ["monitor"], 19 + summary: "Create a monitor trigger", 20 + description: "Trigger a monitor check without waiting the result", 21 + path: "/:id/trigger", 22 + request: { 23 + params: ParamsSchema, 24 + }, 25 + responses: { 26 + 200: { 27 + content: { 28 + "application/json": { 29 + schema: TriggerSchema, 30 + }, 31 + }, 32 + description: 33 + "Returns a result id that can be used to get the result of your trigger", 34 + }, 35 + ...openApiErrorResponses, 36 + }, 37 + }); 38 + 39 + export function registerTriggerMonitor(api: typeof monitorsApi) { 40 + return api.openapi(postRoute, async (c) => { 41 + const workspaceId = c.get("workspace").id; 42 + const { id } = c.req.valid("param"); 43 + const limits = c.get("workspace").limits; 44 + 45 + const lastMonth = new Date().setMonth(new Date().getMonth() - 1); 46 + 47 + const count = ( 48 + await db 49 + .select({ count: sql<number>`count(*)` }) 50 + .from(monitorRun) 51 + .where( 52 + and( 53 + eq(monitorRun.workspaceId, workspaceId), 54 + gte(monitorRun.createdAt, new Date(lastMonth)), 55 + ), 56 + ) 57 + .all() 58 + )[0].count; 59 + 60 + if (count >= limits["synthetic-checks"]) { 61 + throw new OpenStatusApiError({ 62 + code: "PAYMENT_REQUIRED", 63 + message: "Upgrade for more checks", 64 + }); 65 + } 66 + 67 + const _monitor = await db 68 + .select() 69 + .from(monitor) 70 + .where( 71 + and( 72 + eq(monitor.id, Number(id)), 73 + eq(monitor.workspaceId, workspaceId), 74 + isNull(monitor.deletedAt), 75 + ), 76 + ) 77 + .get(); 78 + 79 + if (!_monitor) { 80 + throw new OpenStatusApiError({ 81 + code: "NOT_FOUND", 82 + message: `Monitor ${id} not found`, 83 + }); 84 + } 85 + 86 + const validateMonitor = selectMonitorSchema.safeParse(_monitor); 87 + 88 + if (!validateMonitor.success) { 89 + throw new OpenStatusApiError({ 90 + code: "BAD_REQUEST", 91 + message: "Invalid monitor, please contact support", 92 + }); 93 + } 94 + 95 + const row = validateMonitor.data; 96 + 97 + // Maybe later overwrite the region 98 + 99 + const _monitorStatus = await db 100 + .select() 101 + .from(monitorStatusTable) 102 + .where(eq(monitorStatusTable.monitorId, _monitor.id)) 103 + .all(); 104 + 105 + const monitorStatus = z 106 + .array(selectMonitorStatusSchema) 107 + .safeParse(_monitorStatus); 108 + 109 + if (!monitorStatus.success) { 110 + throw new OpenStatusApiError({ 111 + code: "BAD_REQUEST", 112 + message: "Invalid monitor status, please contact support", 113 + }); 114 + } 115 + 116 + const timestamp = Date.now(); 117 + 118 + const newRun = await db 119 + .insert(monitorRun) 120 + .values({ 121 + monitorId: row.id, 122 + workspaceId: row.workspaceId, 123 + runnedAt: new Date(timestamp), 124 + }) 125 + .returning(); 126 + 127 + if (!newRun[0]) { 128 + throw new HTTPException(400, { message: "Something went wrong" }); 129 + } 130 + 131 + const allResult = []; 132 + 133 + for (const region of validateMonitor.data.regions) { 134 + const status = 135 + monitorStatus.data.find((m) => region === m.region)?.status || "active"; 136 + const payload = getCheckerPayload(row, status); 137 + const url = getCheckerUrl(row); 138 + 139 + const result = fetch(url, { 140 + headers: { 141 + "Content-Type": "application/json", 142 + "fly-prefer-region": region, // Specify the region you want the request to be sent to 143 + Authorization: `Basic ${env.CRON_SECRET}`, 144 + }, 145 + method: "POST", 146 + body: JSON.stringify(payload), 147 + }); 148 + 149 + allResult.push(result); 150 + } 151 + 152 + await Promise.all(allResult); 153 + 154 + return c.json({ resultId: newRun[0].id }, 200); 155 + }); 156 + }
+10
apps/server/src/routes/v1/monitors/trigger/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + import { ParamsSchema } from "../schema"; 3 + 4 + export { ParamsSchema }; 5 + 6 + export const TriggerSchema = z.object({ 7 + resultId: z.number().openapi({ description: "the id of your check result" }), 8 + }); 9 + 10 + export type TriggerSchema = z.infer<typeof TriggerSchema>;
+42
apps/server/src/routes/v1/notifications/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { NotificationSchema } from "./schema"; 5 + 6 + test("return the notification", async () => { 7 + const res = await app.request("/v1/notification/1", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = NotificationSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/notification/1"); 20 + 21 + expect(res.status).toBe(401); 22 + }); 23 + 24 + test("invalid notification id should return 404", async () => { 25 + const res = await app.request("/v1/notification/404", { 26 + headers: { 27 + "x-openstatus-key": "1", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + }); 33 + 34 + test("invalid auth key should return 404", async () => { 35 + const res = await app.request("/v1/notification/1", { 36 + headers: { 37 + "x-openstatus-key": "2", 38 + }, 39 + }); 40 + 41 + expect(res.status).toBe(404); 42 + });
+42
apps/server/src/routes/v1/notifications/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { NotificationSchema } from "./schema"; 5 + 6 + test("return all notifications", async () => { 7 + const res = await app.request("/v1/notification", { 8 + method: "GET", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = NotificationSchema.array().safeParse(await res.json()); 15 + 16 + expect(res.status).toBe(200); 17 + expect(result.success).toBe(true); 18 + expect(result.data?.length).toBeGreaterThan(0); 19 + }); 20 + 21 + test("return empty notifications", async () => { 22 + const res = await app.request("/v1/notification", { 23 + method: "GET", 24 + headers: { 25 + "x-openstatus-key": "2", 26 + }, 27 + }); 28 + 29 + const result = NotificationSchema.array().safeParse(await res.json()); 30 + 31 + expect(result.success).toBe(true); 32 + expect(res.status).toBe(200); 33 + expect(result.data?.length).toBe(0); 34 + }); 35 + 36 + test("no auth key should return 401", async () => { 37 + const res = await app.request("/v1/notification", { 38 + method: "GET", 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + });
+64
apps/server/src/routes/v1/notifications/get_all.ts
··· 1 + import { createRoute } from "@hono/zod-openapi"; 2 + 3 + import { openApiErrorResponses } from "@/libs/errors"; 4 + import { db, eq, inArray } from "@openstatus/db"; 5 + import { 6 + notification, 7 + notificationsToMonitors, 8 + } from "@openstatus/db/src/schema"; 9 + import type { notificationsApi } from "./index"; 10 + import { NotificationSchema } from "./schema"; 11 + 12 + const getAllRoute = createRoute({ 13 + method: "get", 14 + tags: ["notification"], 15 + summary: "List all notifications", 16 + path: "/", 17 + 18 + responses: { 19 + 200: { 20 + content: { 21 + "application/json": { 22 + schema: NotificationSchema.array(), 23 + }, 24 + }, 25 + description: "Get all your workspace notification", 26 + }, 27 + ...openApiErrorResponses, 28 + }, 29 + }); 30 + 31 + export function registerGetAllNotifications(app: typeof notificationsApi) { 32 + return app.openapi(getAllRoute, async (c) => { 33 + const workspaceId = c.get("workspace").id; 34 + 35 + const _notifications = await db 36 + .select() 37 + .from(notification) 38 + .where(eq(notification.workspaceId, workspaceId)) 39 + .all(); 40 + 41 + const _monitors = await db 42 + .select() 43 + .from(notificationsToMonitors) 44 + .where( 45 + inArray( 46 + notificationsToMonitors.notificationId, 47 + _notifications.map((n) => n.id), 48 + ), 49 + ) 50 + .all(); 51 + 52 + const data = NotificationSchema.array().parse( 53 + _notifications.map((n) => ({ 54 + ...n, 55 + payload: JSON.parse(n.data || "{}"), 56 + monitors: _monitors 57 + .filter((m) => m.notificationId === n.id) 58 + .map((m) => m.monitorId), 59 + })), 60 + ); 61 + 62 + return c.json(data, 200); 63 + }); 64 + }
+71
apps/server/src/routes/v1/notifications/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { NotificationSchema } from "./schema"; 5 + 6 + test("create a notification", async () => { 7 + const res = await app.request("/v1/notification", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + name: "OpenStatus", 15 + provider: "email", 16 + payload: { email: "ping@openstatus.dev" }, 17 + monitors: [1], 18 + }), 19 + }); 20 + 21 + const result = NotificationSchema.safeParse(await res.json()); 22 + 23 + expect(res.status).toBe(200); 24 + expect(result.success).toBe(true); 25 + }); 26 + 27 + test("create a notification with invalid monitor ids should return a 400", async () => { 28 + const res = await app.request("/v1/notification", { 29 + method: "POST", 30 + headers: { 31 + "x-openstatus-key": "1", 32 + "content-type": "application/json", 33 + }, 34 + body: JSON.stringify({ 35 + name: "OpenStatus", 36 + provider: "email", 37 + payload: { email: "ping@openstatus.dev" }, 38 + monitors: [404], 39 + }), 40 + }); 41 + 42 + expect(res.status).toBe(400); 43 + }); 44 + 45 + test("create a email notification with invalid payload should return a 400", async () => { 46 + const res = await app.request("/v1/notification", { 47 + method: "POST", 48 + headers: { 49 + "x-openstatus-key": "1", 50 + "content-type": "application/json", 51 + }, 52 + body: JSON.stringify({ 53 + name: "OpenStatus", 54 + provider: "email", 55 + payload: { hello: "world" }, 56 + }), 57 + }); 58 + 59 + expect(res.status).toBe(400); 60 + }); 61 + 62 + test("no auth key should return 401", async () => { 63 + const res = await app.request("/v1/notification", { 64 + method: "POST", 65 + headers: { 66 + "content-type": "application/json", 67 + }, 68 + }); 69 + 70 + expect(res.status).toBe(401); 71 + });
+47
apps/server/src/routes/v1/notifications/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + import { 3 + NotificationDataSchema, 4 + notificationProviderSchema, 5 + } from "@openstatus/db/src/schema"; 6 + 7 + export const ParamsSchema = z.object({ 8 + id: z 9 + .string() 10 + .min(1) 11 + .openapi({ 12 + param: { 13 + name: "id", 14 + in: "path", 15 + }, 16 + description: "The id of the notification", 17 + example: "1", 18 + }), 19 + }); 20 + 21 + export const NotificationSchema = z 22 + .object({ 23 + id: z 24 + .number() 25 + .openapi({ description: "The id of the notification", example: 1 }), 26 + name: z.string().openapi({ 27 + description: "The name of the notification", 28 + example: "OpenStatus Discord", 29 + }), 30 + provider: notificationProviderSchema.openapi({ 31 + description: "The provider of the notification", 32 + example: "discord", 33 + }), 34 + payload: NotificationDataSchema.openapi({ 35 + description: "The data of the notification", 36 + }), 37 + monitors: z 38 + .array(z.number()) 39 + .nullish() 40 + .openapi({ 41 + description: "The monitors that the notification is linked to", 42 + example: [1, 2], 43 + }), 44 + }) 45 + .openapi("Notification"); 46 + 47 + export type NotificationSchema = z.infer<typeof NotificationSchema>;
+44
apps/server/src/routes/v1/pageSubscribers/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { PageSubscriberSchema } from "./schema"; 5 + 6 + test("create a page subscription", async () => { 7 + const res = await app.request("/v1/page_subscriber/1/update", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ email: "ping@openstatus.dev" }), 14 + }); 15 + 16 + const result = PageSubscriberSchema.safeParse(await res.json()); 17 + 18 + expect(res.status).toBe(200); 19 + expect(result.success).toBe(true); 20 + }); 21 + 22 + test("create a scubscriber with invalid email should return a 400", async () => { 23 + const res = await app.request("/v1/page_subscriber/1/update", { 24 + method: "POST", 25 + headers: { 26 + "x-openstatus-key": "1", 27 + "content-type": "application/json", 28 + }, 29 + body: JSON.stringify({ email: "ping" }), 30 + }); 31 + 32 + expect(res.status).toBe(400); 33 + }); 34 + 35 + test("no auth key should return 401", async () => { 36 + const res = await app.request("/v1/page_subscriber/1/update", { 37 + method: "POST", 38 + headers: { 39 + "content-type": "application/json", 40 + }, 41 + }); 42 + 43 + expect(res.status).toBe(401); 44 + });
+33
apps/server/src/routes/v1/pageSubscribers/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 page", 13 + example: "1", 14 + }), 15 + }); 16 + 17 + export const PageSubscriberSchema = z 18 + .object({ 19 + id: z.number().openapi({ 20 + description: "The id of the subscriber", 21 + example: 1, 22 + }), 23 + email: z.string().email().openapi({ 24 + description: "The email of the subscriber", 25 + }), 26 + pageId: z.number().openapi({ 27 + description: "The id of the page to subscribe to", 28 + example: 1, 29 + }), 30 + }) 31 + .openapi("PageSubscriber"); 32 + 33 + export type PageSubscriberSchema = z.infer<typeof PageSubscriberSchema>;
+32
apps/server/src/routes/v1/pages/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { PageSchema } from "./schema"; 5 + 6 + test("return the page", async () => { 7 + const res = await app.request("/v1/page/1", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = PageSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/page/2"); 20 + 21 + expect(res.status).toBe(401); 22 + }); 23 + 24 + test("invalid page id should return 404", async () => { 25 + const res = await app.request("/v1/page/2", { 26 + headers: { 27 + "x-openstatus-key": "2", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + });
+42
apps/server/src/routes/v1/pages/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { PageSchema } from "./schema"; 5 + 6 + test("return all pages", async () => { 7 + const res = await app.request("/v1/page", { 8 + method: "GET", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = PageSchema.array().safeParse(await res.json()); 15 + 16 + expect(res.status).toBe(200); 17 + expect(result.success).toBe(true); 18 + expect(result.data?.length).toBeGreaterThan(0); 19 + }); 20 + 21 + test("return empty pages", async () => { 22 + const res = await app.request("/v1/page", { 23 + method: "GET", 24 + headers: { 25 + "x-openstatus-key": "2", 26 + }, 27 + }); 28 + 29 + const result = PageSchema.array().safeParse(await res.json()); 30 + 31 + expect(result.success).toBe(true); 32 + expect(res.status).toBe(200); 33 + expect(result.data?.length).toBe(0); 34 + }); 35 + 36 + test("no auth key should return 401", async () => { 37 + const res = await app.request("/v1/page", { 38 + method: "GET", 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + });
+40
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"; 7 + import { PageSchema } from "./schema"; 8 + 9 + const getAllRoute = createRoute({ 10 + method: "get", 11 + tags: ["page"], 12 + summary: "List all status pages", 13 + path: "/", 14 + responses: { 15 + 200: { 16 + content: { 17 + "application/json": { 18 + schema: PageSchema.array(), 19 + }, 20 + }, 21 + description: "A list of your status pages", 22 + }, 23 + ...openApiErrorResponses, 24 + }, 25 + }); 26 + 27 + export function registerGetAllPages(api: typeof pagesApi) { 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().parse(_pages); 37 + 38 + return c.json(data, 200); 39 + }); 40 + }
+89
apps/server/src/routes/v1/pages/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { PageSchema } from "./schema"; 5 + 6 + test("create a valid page", async () => { 7 + const res = await app.request("/v1/page", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + title: "OpenStatus", 15 + description: "OpenStatus website", 16 + slug: "openstatus", 17 + monitors: [1], 18 + }), 19 + }); 20 + 21 + const result = PageSchema.safeParse(await res.json()); 22 + 23 + expect(res.status).toBe(200); 24 + expect(result.success).toBe(true); 25 + }); 26 + 27 + test("create a page with invalid monitor ids should return a 400", async () => { 28 + const res = await app.request("/v1/page", { 29 + method: "POST", 30 + headers: { 31 + "x-openstatus-key": "1", 32 + "content-type": "application/json", 33 + }, 34 + body: JSON.stringify({ 35 + title: "OpenStatus", 36 + description: "OpenStatus website", 37 + slug: "another-openstatus", 38 + monitors: [404], 39 + }), 40 + }); 41 + 42 + expect(res.status).toBe(400); 43 + }); 44 + 45 + test("create a page with password on free plan should return a 402", async () => { 46 + const res = await app.request("/v1/page", { 47 + method: "POST", 48 + headers: { 49 + "x-openstatus-key": "2", 50 + "content-type": "application/json", 51 + }, 52 + body: JSON.stringify({ 53 + title: "OpenStatus", 54 + description: "OpenStatus website", 55 + slug: "password-openstatus", 56 + passwordProtected: true, 57 + }), 58 + }); 59 + 60 + expect(res.status).toBe(402); 61 + }); 62 + 63 + test("create a email page with invalid payload should return a 400", async () => { 64 + const res = await app.request("/v1/page", { 65 + method: "POST", 66 + headers: { 67 + "x-openstatus-key": "1", 68 + "content-type": "application/json", 69 + }, 70 + body: JSON.stringify({ 71 + name: "OpenStatus", 72 + provider: "email", 73 + payload: { hello: "world" }, 74 + }), 75 + }); 76 + 77 + expect(res.status).toBe(400); 78 + }); 79 + 80 + test("no auth key should return 401", async () => { 81 + const res = await app.request("/v1/page", { 82 + method: "POST", 83 + headers: { 84 + "content-type": "application/json", 85 + }, 86 + }); 87 + 88 + expect(res.status).toBe(401); 89 + });
+92
apps/server/src/routes/v1/pages/put.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { PageSchema } from "./schema"; 5 + 6 + test("update the page with monitor ids", async () => { 7 + const res = await app.request("/v1/page/1", { 8 + method: "PUT", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "Content-Type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + title: "New Title", 15 + monitors: [1, 2], 16 + }), 17 + }); 18 + 19 + const result = PageSchema.safeParse(await res.json()); 20 + 21 + expect(res.status).toBe(200); 22 + expect(result.success).toBe(true); 23 + expect(result.data?.title).toBe("New Title"); 24 + expect(result.data?.monitors).toEqual([1, 2]); 25 + }); 26 + 27 + test("update the page with monitor objects", async () => { 28 + const res = await app.request("/v1/page/1", { 29 + method: "PUT", 30 + headers: { 31 + "x-openstatus-key": "1", 32 + "Content-Type": "application/json", 33 + }, 34 + body: JSON.stringify({ 35 + monitors: [ 36 + { monitorId: 1, order: 1 }, 37 + { monitorId: 2, order: 2 }, 38 + ], 39 + }), 40 + }); 41 + 42 + const result = PageSchema.safeParse(await res.json()); 43 + 44 + expect(res.status).toBe(200); 45 + expect(result.success).toBe(true); 46 + expect(result.data?.monitors).toEqual([ 47 + { monitorId: 1, order: 1 }, 48 + { monitorId: 2, order: 2 }, 49 + ]); 50 + }); 51 + 52 + test("update the page with invalid monitors should return 400", async () => { 53 + const res = await app.request("/v1/page/1", { 54 + method: "PUT", 55 + headers: { 56 + "x-openstatus-key": "1", 57 + "Content-Type": "application/json", 58 + }, 59 + body: JSON.stringify({ 60 + monitors: [404], 61 + }), 62 + }); 63 + expect(res.status).toBe(400); 64 + }); 65 + 66 + test("invalid page id should return 404", async () => { 67 + const res = await app.request("/v1/page/404", { 68 + method: "PUT", 69 + headers: { 70 + "x-openstatus-key": "1", 71 + "Content-Type": "application/json", 72 + }, 73 + body: JSON.stringify({ 74 + acknowledgedAt: new Date().toISOString(), 75 + }), 76 + }); 77 + 78 + expect(res.status).toBe(404); 79 + }); 80 + 81 + test("no auth key should return 401", async () => { 82 + const res = await app.request("/v1/page/2", { 83 + method: "PUT", 84 + headers: { 85 + "content-type": "application/json", 86 + }, 87 + body: JSON.stringify({ 88 + acknowledgedAt: new Date().toISOString(), 89 + }), 90 + }); 91 + expect(res.status).toBe(401); 92 + });
+92
apps/server/src/routes/v1/pages/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 page", 13 + example: "1", 14 + }), 15 + }); 16 + 17 + export const PageSchema = z 18 + .object({ 19 + id: z.number().openapi({ 20 + description: "The id of the page", 21 + example: 1, 22 + }), 23 + title: z.string().openapi({ 24 + description: "The title of the page", 25 + example: "My Page", 26 + }), 27 + description: z.string().openapi({ 28 + description: "The description of the page", 29 + example: "My awesome status page", 30 + }), 31 + slug: z.string().openapi({ 32 + description: "The slug of the page", 33 + example: "my-page", 34 + }), 35 + // REMINDER: needs to be configured on Dashboard UI 36 + customDomain: z 37 + .string() 38 + .transform((val) => (val ? val : undefined)) 39 + .nullish() 40 + .openapi({ 41 + description: 42 + "The custom domain of the page. To be configured within the dashboard.", 43 + example: "status.acme.com", 44 + }), 45 + icon: z 46 + .string() 47 + .url() 48 + .or(z.literal("")) 49 + .transform((val) => (val ? val : undefined)) 50 + .nullish() 51 + .openapi({ 52 + description: "The icon of the page", 53 + example: "https://example.com/icon.png", 54 + }), 55 + passwordProtected: z.boolean().optional().default(false).openapi({ 56 + description: 57 + "Make the page password protected. Used with the 'passwordProtected' property.", 58 + example: true, 59 + }), 60 + password: z.string().optional().nullish().openapi({ 61 + description: "Your password to protect the page from the public", 62 + example: "hidden-password", 63 + }), 64 + showMonitorValues: z.boolean().optional().nullish().default(true).openapi({ 65 + description: 66 + "Displays the total and failed request numbers for each monitor", 67 + example: true, 68 + }), 69 + monitors: z 70 + .array(z.number()) 71 + .openapi({ 72 + description: 73 + "The monitors of the page as an array of ids. We recommend using the object format to include the order.", 74 + deprecated: true, 75 + example: [1, 2], 76 + }) 77 + .or( 78 + z 79 + .array(z.object({ monitorId: z.number(), order: z.number() })) 80 + .openapi({ 81 + description: "The monitor as object allowing to pass id and order", 82 + example: [ 83 + { monitorId: 1, order: 0 }, 84 + { monitorId: 2, order: 1 }, 85 + ], 86 + }), 87 + ) 88 + .optional(), 89 + }) 90 + .openapi("Page"); 91 + 92 + export type PageSchema = z.infer<typeof PageSchema>;
+32
apps/server/src/routes/v1/statusReportUpdates/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { StatusReportUpdateSchema } from "./schema"; 5 + 6 + test("return the status report update", async () => { 7 + const res = await app.request("/v1/status_report_update/2", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = StatusReportUpdateSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/status_report_update/2"); 20 + 21 + expect(res.status).toBe(401); 22 + }); 23 + 24 + test("invalid status report id should return 404", async () => { 25 + const res = await app.request("/v1/status_report_update/2", { 26 + headers: { 27 + "x-openstatus-key": "2", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + });
+52
apps/server/src/routes/v1/statusReportUpdates/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { StatusReportUpdateSchema } from "./schema"; 5 + 6 + test("create a valid status report update", async () => { 7 + const res = await app.request("/v1/status_report_update", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + status: "investigating", 15 + date: new Date().toISOString(), 16 + message: "Message", 17 + statusReportId: 1, 18 + }), 19 + }); 20 + 21 + const result = StatusReportUpdateSchema.safeParse(await res.json()); 22 + 23 + expect(res.status).toBe(200); 24 + expect(result.success).toBe(true); 25 + }); 26 + 27 + test("create a status report update without valid payload should return 400", async () => { 28 + const res = await app.request("/v1/status_report_update", { 29 + method: "POST", 30 + headers: { 31 + "x-openstatus-key": "1", 32 + "content-type": "application/json", 33 + }, 34 + body: JSON.stringify({ 35 + status: "investigating", 36 + date: "test", 37 + }), 38 + }); 39 + 40 + expect(res.status).toBe(400); 41 + }); 42 + 43 + test("no auth key should return 401", async () => { 44 + const res = await app.request("/v1/status_report_update", { 45 + method: "POST", 46 + headers: { 47 + "content-type": "application/json", 48 + }, 49 + }); 50 + 51 + expect(res.status).toBe(401); 52 + });
+37
apps/server/src/routes/v1/statusReportUpdates/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + 3 + import { statusReportStatus } from "@openstatus/db/src/schema"; 4 + 5 + export const ParamsSchema = z.object({ 6 + id: z 7 + .string() 8 + .min(1) 9 + .openapi({ 10 + param: { 11 + name: "id", 12 + in: "path", 13 + }, 14 + description: "The id of the update", 15 + example: "1", 16 + }), 17 + }); 18 + 19 + export const StatusReportUpdateSchema = z 20 + .object({ 21 + id: z.coerce.string().openapi({ description: "The id of the update" }), 22 + status: z.enum(statusReportStatus).openapi({ 23 + description: "The status of the update", 24 + }), 25 + date: z.coerce.date().default(new Date()).openapi({ 26 + description: "The date of the update in ISO8601 format", 27 + }), 28 + message: z.string().openapi({ 29 + description: "The message of the update", 30 + }), 31 + statusReportId: z.number().openapi({ 32 + description: "The id of the status report", 33 + }), 34 + }) 35 + .openapi("StatusReportUpdate"); 36 + 37 + export type StatusReportUpdateSchema = z.infer<typeof StatusReportUpdateSchema>;
+32
apps/server/src/routes/v1/statusReports/delete.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + 5 + test("delete the status report", async () => { 6 + const res = await app.request("/v1/status_report/3", { 7 + method: "DELETE", 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + 13 + expect(res.status).toBe(200); 14 + expect(await res.json()).toMatchObject({}); 15 + }); 16 + 17 + test("no auth key should return 401", async () => { 18 + const res = await app.request("/v1/status_report/2", { method: "DELETE" }); 19 + 20 + expect(res.status).toBe(401); 21 + }); 22 + 23 + test("invalid status report id should return 404", async () => { 24 + const res = await app.request("/v1/status_report/2", { 25 + method: "DELETE", 26 + headers: { 27 + "x-openstatus-key": "2", 28 + }, 29 + }); 30 + 31 + expect(res.status).toBe(404); 32 + });
+34
apps/server/src/routes/v1/statusReports/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { StatusReportSchema } from "./schema"; 5 + 6 + test("return the status report", async () => { 7 + const res = await app.request("/v1/status_report/2", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = StatusReportSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); 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 + 23 + expect(res.status).toBe(401); 24 + }); 25 + 26 + test("invalid status report id should return 404", async () => { 27 + const res = await app.request("/v1/status_report/2", { 28 + headers: { 29 + "x-openstatus-key": "2", 30 + }, 31 + }); 32 + 33 + expect(res.status).toBe(404); 34 + });
+42
apps/server/src/routes/v1/statusReports/get_all.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { StatusReportSchema } from "./schema"; 5 + 6 + test("return all status reports", async () => { 7 + const res = await app.request("/v1/status_report", { 8 + method: "GET", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + }, 12 + }); 13 + 14 + const result = StatusReportSchema.array().safeParse(await res.json()); 15 + 16 + expect(res.status).toBe(200); 17 + expect(result.success).toBe(true); 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", 24 + headers: { 25 + "x-openstatus-key": "2", 26 + }, 27 + }); 28 + 29 + const result = StatusReportSchema.array().safeParse(await res.json()); 30 + 31 + expect(result.success).toBe(true); 32 + expect(res.status).toBe(200); 33 + expect(result.data?.length).toBe(0); 34 + }); 35 + 36 + test("no auth key should return 401", async () => { 37 + const res = await app.request("/v1/status_report", { 38 + method: "GET", 39 + }); 40 + 41 + expect(res.status).toBe(401); 42 + });
+81
apps/server/src/routes/v1/statusReports/post.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { StatusReportSchema } from "./schema"; 5 + 6 + test("create a valid status report", async () => { 7 + const date = new Date(); 8 + date.setMilliseconds(0); 9 + 10 + const res = await app.request("/v1/status_report", { 11 + method: "POST", 12 + headers: { 13 + "x-openstatus-key": "1", 14 + "content-type": "application/json", 15 + }, 16 + body: JSON.stringify({ 17 + status: "investigating", 18 + title: "New Status Report", 19 + message: "Message", 20 + monitorIds: [1], 21 + date: date.toISOString(), 22 + pageId: 1, 23 + }), 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?.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 () => { 35 + const res = await app.request("/v1/status_report", { 36 + method: "POST", 37 + headers: { 38 + "x-openstatus-key": "1", 39 + "content-type": "application/json", 40 + }, 41 + body: JSON.stringify({ 42 + status: "investigating", 43 + title: "New Status Report", 44 + message: "Message", 45 + monitorIds: [404], 46 + pageId: 1, 47 + }), 48 + }); 49 + 50 + expect(res.status).toBe(400); 51 + }); 52 + 53 + test("create a status report with invalid page id should return 400", async () => { 54 + const res = await app.request("/v1/status_report", { 55 + method: "POST", 56 + headers: { 57 + "x-openstatus-key": "1", 58 + "content-type": "application/json", 59 + }, 60 + body: JSON.stringify({ 61 + status: "investigating", 62 + title: "New Status Report", 63 + message: "Message", 64 + monitorIds: [1], 65 + pageId: 404, 66 + }), 67 + }); 68 + 69 + expect(res.status).toBe(400); 70 + }); 71 + 72 + test("no auth key should return 401", async () => { 73 + const res = await app.request("/v1/status_report", { 74 + method: "POST", 75 + headers: { 76 + "content-type": "application/json", 77 + }, 78 + }); 79 + 80 + expect(res.status).toBe(401); 81 + });
+48
apps/server/src/routes/v1/statusReports/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + 3 + import { statusReportStatusSchema } from "@openstatus/db/src/schema"; 4 + 5 + export const ParamsSchema = z.object({ 6 + id: z 7 + .string() 8 + .min(1) 9 + .openapi({ 10 + param: { 11 + name: "id", 12 + in: "path", 13 + }, 14 + description: "The id of the status report", 15 + example: "1", 16 + }), 17 + }); 18 + 19 + export const StatusReportSchema = z 20 + .object({ 21 + id: z.number().openapi({ description: "The id of the status report" }), 22 + title: z.string().openapi({ 23 + example: "Documenso", 24 + description: "The title of the status report", 25 + }), 26 + status: statusReportStatusSchema.openapi({ 27 + description: "The current status of the report", 28 + }), 29 + statusReportUpdateIds: z 30 + .array(z.number()) 31 + .optional() 32 + .nullable() 33 + .default([]) 34 + .openapi({ 35 + description: "The ids of the status report updates", 36 + }), 37 + monitorIds: z 38 + .array(z.number()) 39 + .optional() 40 + .default([]) 41 + .openapi({ description: "Ids of the monitors the status report." }), 42 + pageId: z.number().openapi({ 43 + description: "The id of the page this status report belongs to", 44 + }), 45 + }) 46 + .openapi("StatusReport"); 47 + 48 + export type StatusReportSchema = z.infer<typeof StatusReportSchema>;
+24
apps/server/src/routes/v1/utils.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + import { ZodError } from "zod"; 3 + 4 + export const isoDate = z.preprocess((val) => { 5 + try { 6 + if (val) { 7 + return new Date(String(val)).toISOString(); 8 + } 9 + return new Date().toISOString(); 10 + } catch (e) { 11 + throw new ZodError([ 12 + { code: "invalid_date", message: "Invalid date", path: [] }, 13 + ]); 14 + } 15 + }, z.string()); 16 + 17 + export function isNumberArray<T>( 18 + monitors: number[] | T[], 19 + ): monitors is number[] { 20 + return ( 21 + Array.isArray(monitors) && 22 + monitors.every((item) => typeof item === "number") 23 + ); 24 + }
+22
apps/server/src/routes/v1/whoami/get.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { WorkspaceSchema } from "./schema"; 5 + 6 + test("return the whoami", async () => { 7 + const res = await app.request("/v1/whoami", { 8 + headers: { 9 + "x-openstatus-key": "1", 10 + }, 11 + }); 12 + const result = WorkspaceSchema.safeParse(await res.json()); 13 + 14 + expect(res.status).toBe(200); 15 + expect(result.success).toBe(true); 16 + }); 17 + 18 + test("no auth key should return 401", async () => { 19 + const res = await app.request("/v1/whoami"); 20 + 21 + expect(res.status).toBe(401); 22 + });
+48
apps/server/src/routes/v1/whoami/get.ts
··· 1 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 2 + import { createRoute } from "@hono/zod-openapi"; 3 + import { eq } from "@openstatus/db"; 4 + import { db } from "@openstatus/db/src/db"; 5 + import { workspace } from "@openstatus/db/src/schema/workspaces"; 6 + import type { whoamiApi } from "."; 7 + import { WorkspaceSchema } from "./schema"; 8 + 9 + const getRoute = createRoute({ 10 + method: "get", 11 + tags: ["whoami"], 12 + path: "/", 13 + summary: "Get your informations", 14 + description: "Get the current workspace information attached to the API key.", 15 + responses: { 16 + 200: { 17 + content: { 18 + "application/json": { 19 + schema: WorkspaceSchema, 20 + }, 21 + }, 22 + description: "The current workspace information with the limits", 23 + }, 24 + ...openApiErrorResponses, 25 + }, 26 + }); 27 + 28 + export function registerGetWhoami(api: typeof whoamiApi) { 29 + return api.openapi(getRoute, async (c) => { 30 + const workspaceId = c.get("workspace").id; 31 + 32 + const _workspace = await db 33 + .select() 34 + .from(workspace) 35 + .where(eq(workspace.id, workspaceId)) 36 + .get(); 37 + 38 + if (!_workspace) { 39 + throw new OpenStatusApiError({ 40 + code: "NOT_FOUND", 41 + message: `Workspace ${workspaceId} not found`, 42 + }); 43 + } 44 + 45 + const data = WorkspaceSchema.parse(_workspace); 46 + return c.json(data, 200); 47 + }); 48 + }
+18
apps/server/src/routes/v1/whoami/schema.ts
··· 1 + import { z } from "@hono/zod-openapi"; 2 + 3 + import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; 4 + 5 + export const WorkspaceSchema = z 6 + .object({ 7 + name: z 8 + .string() 9 + .optional() 10 + .openapi({ description: "The current workspace name" }), 11 + slug: z.string().openapi({ description: "The current workspace slug" }), 12 + plan: z.enum(workspacePlans).nullable().default("free").openapi({ 13 + description: "The current workspace plan", 14 + }), 15 + }) 16 + .openapi("Workspace"); 17 + 18 + export type WorkspaceSchema = z.infer<typeof WorkspaceSchema>;
+6
apps/server/src/types/index.ts
··· 1 + import type { Workspace } from "@openstatus/db/src/schema"; 2 + import type { RequestIdVariables } from "hono/request-id"; 3 + 4 + export type Variables = RequestIdVariables & { 5 + workspace: Workspace; 6 + };
+4 -4
apps/server/src/v1/check/http/post.test.ts apps/server/src/routes/v1/check/http/post.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 2 3 - import { api } from "../../index"; 4 - 5 3 import { afterEach, mock } from "bun:test"; 4 + import { app } from "@/index"; 6 5 6 + // @ts-expect-error - FIXME: requires a function... 7 7 const mockFetch = mock(); 8 8 9 9 global.fetch = mockFetch; ··· 30 30 ), 31 31 ); 32 32 33 - const res = await api.request("/check/http", { 33 + const res = await app.request("/v1/check/http", { 34 34 method: "POST", 35 35 headers: { 36 36 "x-openstatus-key": "1", ··· 99 99 ), 100 100 ); 101 101 102 - const res = await api.request("/check", { 102 + const res = await app.request("/v1/check", { 103 103 method: "POST", 104 104 headers: { 105 105 "x-openstatus-key": "1",
+8 -8
apps/server/src/v1/check/http/post.ts apps/server/src/routes/v1/check/http/post.ts
··· 1 1 import { createRoute, type z } from "@hono/zod-openapi"; 2 2 3 + import { env } from "@/env"; 4 + import { openApiErrorResponses } from "@/libs/errors"; 3 5 import { db } from "@openstatus/db"; 4 6 import { check } from "@openstatus/db/src/schema/check"; 5 7 import percentile from "percentile"; 6 - import { env } from "../../../env"; 7 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 8 - import type { checkAPI } from "../index"; 8 + import type { checkApi } from "../index"; 9 9 import { 10 10 AggregatedResponseSchema, 11 11 AggregatedResult, ··· 16 16 17 17 const postRoute = createRoute({ 18 18 method: "post", 19 - tags: ["page"], 20 - description: "Run a single check", 19 + tags: ["check"], 20 + summary: "Run a single check", 21 21 path: "/http", 22 22 request: { 23 23 body: { ··· 42 42 }, 43 43 }); 44 44 45 - export function registerHTTPPostCheck(api: typeof checkAPI) { 45 + export function registerHTTPPostCheck(api: typeof checkApi) { 46 46 return api.openapi(postRoute, async (c) => { 47 47 const data = c.req.valid("json"); 48 - const workspaceId = Number(c.get("workspaceId")); 48 + const workspaceId = c.get("workspace").id; 49 49 const input = c.req.valid("json"); 50 50 51 51 const { headers, regions, runCount, aggregated, ...rest } = data; ··· 53 53 const newCheck = await db 54 54 .insert(check) 55 55 .values({ 56 - workspaceId: Number(workspaceId), 56 + workspaceId: workspaceId, 57 57 regions: regions.join(","), 58 58 countRequests: runCount, 59 59 ...rest,
apps/server/src/v1/check/http/schema.ts apps/server/src/routes/v1/check/http/schema.ts
-14
apps/server/src/v1/check/index.ts
··· 1 - import { OpenAPIHono } from "@hono/zod-openapi"; 2 - 3 - import type { Variables } from "../index"; 4 - 5 - import { handleZodError } from "../../libs/errors"; 6 - import { registerHTTPPostCheck } from "./http/post"; 7 - 8 - const checkAPI = new OpenAPIHono<{ Variables: Variables }>({ 9 - defaultHook: handleZodError, 10 - }); 11 - 12 - registerHTTPPostCheck(checkAPI); 13 - 14 - export { checkAPI };
+8 -6
apps/server/src/v1/incidents/get.ts apps/server/src/routes/v1/incidents/get.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 7 import type { incidentsApi } from "./index"; 9 8 import { IncidentSchema, ParamsSchema } from "./schema"; 10 9 11 10 const getRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["incident"], 14 - description: "Get an incident", 13 + summary: "Get an incident", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 31 30 32 31 export function registerGetIncident(app: typeof incidentsApi) { 33 32 return app.openapi(getRoute, async (c) => { 34 - const workspaceId = c.get("workspaceId"); 33 + const workspaceId = c.get("workspace").id; 35 34 const { id } = c.req.valid("param"); 36 35 37 36 const _incident = await db ··· 39 38 .from(incidentTable) 40 39 .where( 41 40 and( 42 - eq(incidentTable.workspaceId, Number(workspaceId)), 41 + eq(incidentTable.workspaceId, workspaceId), 43 42 eq(incidentTable.id, Number(id)), 44 43 ), 45 44 ) 46 45 .get(); 47 46 48 47 if (!_incident) { 49 - throw new HTTPException(404, { message: "Not Found" }); 48 + throw new OpenStatusApiError({ 49 + code: "NOT_FOUND", 50 + message: `Incident ${id} not found`, 51 + }); 50 52 } 51 53 52 54 const data = IncidentSchema.parse(_incident);
-47
apps/server/src/v1/incidents/get_all.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - 3 - import { db, eq } from "@openstatus/db"; 4 - import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 - 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 8 - import type { incidentsApi } from "./index"; 9 - import { IncidentSchema } from "./schema"; 10 - 11 - const getAllRoute = createRoute({ 12 - method: "get", 13 - tags: ["incident"], 14 - description: "Get all Incidents", 15 - path: "/", 16 - request: {}, 17 - responses: { 18 - 200: { 19 - content: { 20 - "application/json": { 21 - schema: z.array(IncidentSchema), 22 - }, 23 - }, 24 - description: "Get all incidents", 25 - }, 26 - ...openApiErrorResponses, 27 - }, 28 - }); 29 - 30 - export function registerGetAllIncidents(app: typeof incidentsApi) { 31 - app.openapi(getAllRoute, async (c) => { 32 - const workspaceId = c.get("workspaceId"); 33 - 34 - const _incidents = await db 35 - .select() 36 - .from(incidentTable) 37 - .where(eq(incidentTable.workspaceId, Number(workspaceId))) 38 - .all(); 39 - 40 - if (!_incidents) { 41 - throw new HTTPException(404, { message: "Not Found" }); 42 - } 43 - 44 - const returnValues = z.array(IncidentSchema).parse(_incidents); // TODO: think of using safeParse with SchemaError.fromZod 45 - return c.json(returnValues, 200); 46 - }); 47 - }
-115
apps/server/src/v1/incidents/incidents.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - import { iso8601Regex } from "../test-utils"; 5 - 6 - import type { IncidentSchema } from "./schema"; 7 - 8 - test("GET one Incident", async () => { 9 - const res = await api.request("/incident/2", { 10 - headers: { 11 - "x-openstatus-key": "1", 12 - }, 13 - }); 14 - expect(res.status).toBe(200); 15 - expect(await res.json()).toMatchObject({ 16 - id: 2, 17 - startedAt: expect.stringMatching(iso8601Regex), 18 - monitorId: 1, 19 - acknowledgedAt: null, 20 - resolvedAt: null, 21 - resolvedBy: null, 22 - acknowledgedBy: null, 23 - }); 24 - }); 25 - 26 - test("Update an incident", async () => { 27 - const res = await api.request("/incident/2", { 28 - method: "PUT", 29 - headers: { 30 - "x-openstatus-key": "1", 31 - "content-type": "application/json", 32 - }, 33 - body: JSON.stringify({ 34 - acknowledgedAt: "2023-11-08T21:03:13.000Z", 35 - }), 36 - }); 37 - const json = await res.json(); 38 - expect(res.status).toBe(200); 39 - expect(json).toMatchObject({ 40 - acknowledgedAt: expect.stringMatching(iso8601Regex), 41 - monitorId: 1, 42 - id: 2, 43 - startedAt: expect.stringMatching(iso8601Regex), 44 - resolvedAt: null, 45 - resolvedBy: null, 46 - acknowledgedBy: null, 47 - }); 48 - }); 49 - 50 - test("Update an incident not in db should return 404", async () => { 51 - const res = await api.request("/incident/404", { 52 - //accessing invalid monitor 53 - method: "PUT", 54 - headers: { 55 - "x-openstatus-key": "1", 56 - "content-type": "application/json", 57 - }, 58 - body: JSON.stringify({ 59 - acknowledgedAt: "2023-11-08T21:03:13.000Z", 60 - }), 61 - }); 62 - 63 - expect(res.status).toBe(404); 64 - }); 65 - 66 - test("Update an incident without auth key should return 401", async () => { 67 - const res = await api.request("/incident/2", { 68 - method: "PUT", 69 - headers: { 70 - //not passing correct key 71 - "content-type": "application/json", 72 - }, 73 - body: JSON.stringify({ 74 - acknowledgedAt: "2023-11-08T21:03:13.000Z", 75 - }), 76 - }); 77 - expect(res.status).toBe(401); 78 - }); 79 - 80 - test("Update an incident with invalid data should return 403", async () => { 81 - const res = await api.request("/incident/2", { 82 - method: "PUT", 83 - headers: { 84 - "x-openstatus-key": "1", 85 - "content-type": "application/json", 86 - }, 87 - body: JSON.stringify({ 88 - //passing incorrect body 89 - acknowledgedAt: "2023-11-0", 90 - }), 91 - }); 92 - expect(res.status).toBe(400); 93 - }); 94 - 95 - test("Get all Incidents", async () => { 96 - const res = await api.request("/incident", { 97 - method: "GET", 98 - headers: { 99 - "x-openstatus-key": "1", 100 - }, 101 - }); 102 - 103 - const body = (await res.json()) as IncidentSchema[]; 104 - 105 - expect(res.status).toBe(200); 106 - expect(body[0]).toMatchObject({ 107 - acknowledgedAt: null, 108 - monitorId: 1, 109 - id: 1, 110 - startedAt: expect.stringMatching(iso8601Regex), 111 - resolvedAt: null, 112 - resolvedBy: null, 113 - acknowledgedBy: null, 114 - }); 115 - });
+1 -1
apps/server/src/v1/incidents/index.ts apps/server/src/routes/v1/incidents/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerGetIncident } from "./get"; 6 6 import { registerGetAllIncidents } from "./get_all";
+14 -21
apps/server/src/v1/incidents/put.ts apps/server/src/routes/v1/incidents/put.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 5 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 + import { trackMiddleware } from "@/libs/middlewares"; 6 8 import { Events } from "@openstatus/analytics"; 7 - import { HTTPException } from "hono/http-exception"; 8 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 - import { trackMiddleware } from "../middleware"; 10 9 import type { incidentsApi } from "./index"; 11 10 import { IncidentSchema, ParamsSchema } from "./schema"; 12 11 13 12 const putRoute = createRoute({ 14 13 method: "put", 15 14 tags: ["incident"], 16 - description: "Update an incident", 15 + summary: "Update an incident", 16 + description: "Acknowledge or resolve an incident", 17 17 path: "/:id", 18 18 middleware: [trackMiddleware(Events.UpdateIncident)], 19 19 request: { ··· 25 25 schema: IncidentSchema.pick({ 26 26 acknowledgedAt: true, 27 27 resolvedAt: true, 28 - }) 29 - .extend({ 30 - acknowledgedAt: z.coerce.date().optional(), 31 - resolvedAt: z.coerce.date().optional(), 32 - }) 33 - .openapi({ 34 - description: "The incident to update", 35 - }), 28 + }).partial(), 36 29 }, 37 30 }, 38 31 }, ··· 52 45 53 46 export function registerPutIncident(app: typeof incidentsApi) { 54 47 return app.openapi(putRoute, async (c) => { 55 - const inputValues = c.req.valid("json"); 56 - const workspaceId = c.get("workspaceId"); 48 + const input = c.req.valid("json"); 49 + const workspaceId = c.get("workspace").id; 57 50 const { id } = c.req.valid("param"); 58 51 59 52 const _incident = await db ··· 62 55 .where( 63 56 and( 64 57 eq(incidentTable.id, Number(id)), 65 - eq(incidentTable.workspaceId, Number(workspaceId)), 58 + eq(incidentTable.workspaceId, workspaceId), 66 59 ), 67 60 ) 68 61 .get(); 69 62 70 63 if (!_incident) { 71 - throw new HTTPException(404, { message: "Not Found" }); 72 - } 73 - 74 - if (Number(workspaceId) !== _incident.workspaceId) { 75 - throw new HTTPException(401, { message: "Unauthorized" }); 64 + throw new OpenStatusApiError({ 65 + code: "NOT_FOUND", 66 + message: `Incident ${id} not found`, 67 + }); 76 68 } 77 69 78 70 const _newIncident = await db 79 71 .update(incidentTable) 80 - .set({ ...inputValues }) 72 + // TODO: we should set the acknowledgedBy and resolvedBy fields 73 + .set({ ...input, updatedAt: new Date() }) 81 74 .where(eq(incidentTable.id, Number(id))) 82 75 .returning() 83 76 .get();
-60
apps/server/src/v1/incidents/schema.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - 3 - import { isoDate } from "../utils"; 4 - 5 - export const ParamsSchema = z.object({ 6 - id: z 7 - .string() 8 - .min(1) 9 - .openapi({ 10 - param: { 11 - name: "id", 12 - in: "path", 13 - }, 14 - description: "The id of the Incident", 15 - example: "1", 16 - }), 17 - }); 18 - 19 - export const IncidentSchema = z.object({ 20 - id: z.number().openapi({ 21 - description: "The id of the incident", 22 - example: 1, 23 - }), 24 - startedAt: isoDate.openapi({ 25 - description: "The date the incident started", 26 - }), 27 - monitorId: z 28 - .number() 29 - .openapi({ 30 - description: "The id of the monitor associated with the incident", 31 - example: 1, 32 - }) 33 - .nullable(), 34 - acknowledgedAt: isoDate 35 - .openapi({ 36 - description: "The date the incident was acknowledged", 37 - }) 38 - .optional() 39 - .nullable(), 40 - acknowledgedBy: z 41 - .number() 42 - .openapi({ 43 - description: "The user who acknowledged the incident", 44 - }) 45 - .nullable(), 46 - resolvedAt: isoDate 47 - .openapi({ 48 - description: "The date the incident was resolved", 49 - }) 50 - .optional() 51 - .nullable(), 52 - resolvedBy: z 53 - .number() 54 - .openapi({ 55 - description: "The user who resolved the incident", 56 - }) 57 - .nullable(), 58 - }); 59 - 60 - export type IncidentSchema = z.infer<typeof IncidentSchema>;
-92
apps/server/src/v1/index.ts
··· 1 - import { OpenAPIHono } from "@hono/zod-openapi"; 2 - import { apiReference } from "@scalar/hono-api-reference"; 3 - import { cors } from "hono/cors"; 4 - import { logger } from "hono/logger"; 5 - 6 - import type { WorkspacePlan } from "@openstatus/db/src/schema"; 7 - import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 8 - import { handleError, handleZodError } from "../libs/errors"; 9 - import { checkAPI } from "./check"; 10 - import { incidentsApi } from "./incidents"; 11 - import { secureMiddleware } from "./middleware"; 12 - import { monitorsApi } from "./monitors"; 13 - import { notificationsApi } from "./notifications"; 14 - import { pageSubscribersApi } from "./pageSubscribers"; 15 - import { pagesApi } from "./pages"; 16 - import { statusReportUpdatesApi } from "./statusReportUpdates"; 17 - import { statusReportsApi } from "./statusReports"; 18 - import { whoamiApi } from "./whoami"; 19 - 20 - export type Variables = { 21 - workspaceId: string; 22 - workspacePlan: { 23 - title: "Hobby" | "Starter" | "Growth" | "Pro"; 24 - id: WorkspacePlan; 25 - description: string; 26 - price: number; 27 - }; 28 - limits: Limits; 29 - }; 30 - 31 - export const api = new OpenAPIHono<{ Variables: Variables }>({ 32 - defaultHook: handleZodError, 33 - }); 34 - 35 - api.onError(handleError); 36 - 37 - api.use("/openapi", cors()); 38 - 39 - api.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", { 40 - type: "apiKey", 41 - in: "header", 42 - name: "x-openstatus-key", 43 - "x-openstatus-key": "string", 44 - }); 45 - 46 - api.doc("/openapi", { 47 - openapi: "3.0.0", 48 - info: { 49 - version: "1.0.0", 50 - title: "OpenStatus API", 51 - contact: { 52 - email: "ping@openstatus.dev", 53 - url: "https://www.openstatus.dev", 54 - }, 55 - description: 56 - "OpenStatus is a open-source synthetic monitoring tool that allows you to monitor your website and API's uptime, latency, and more. \n\n The OpenStatus API allows you to interact with the OpenStatus platform programmatically. \n\n To get started you need to create an account on https://www.openstatus.dev/ and create an api token in your settings.", 57 - }, 58 - security: [ 59 - { 60 - ApiKeyAuth: [], 61 - }, 62 - ], 63 - }); 64 - 65 - api.get( 66 - "/", 67 - apiReference({ 68 - spec: { 69 - url: "/v1/openapi", 70 - }, 71 - baseServerURL: "https://api.openstatus.dev/v1", 72 - }), 73 - ); 74 - /** 75 - * Authentification Middleware 76 - */ 77 - api.use("/*", secureMiddleware); 78 - api.use("/*", logger()); 79 - 80 - /** 81 - * Routes 82 - */ 83 - api.route("/incident", incidentsApi); 84 - api.route("/monitor", monitorsApi); 85 - api.route("/notification", notificationsApi); 86 - api.route("/page", pagesApi); 87 - api.route("/page_subscriber", pageSubscribersApi); 88 - api.route("/status_report", statusReportsApi); 89 - api.route("/status_report_update", statusReportUpdatesApi); 90 - api.route("/check", checkAPI); 91 - 92 - api.route("/whoami", whoamiApi);
-15
apps/server/src/v1/middleware.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "./index"; 4 - 5 - test("Middleware error should return json", async () => { 6 - const res = await api.request("/status_report/1", {}); 7 - 8 - const json = await res.json(); 9 - expect(res.status).toBe(401); 10 - expect(json).toMatchObject({ 11 - code: "UNAUTHORIZED", 12 - message: "Unauthorized", 13 - docs: "https://docs.openstatus.dev/api-references/errors/code/UNAUTHORIZED", 14 - }); 15 - });
-90
apps/server/src/v1/middleware.ts
··· 1 - import { verifyKey } from "@unkey/api"; 2 - import type { Context, Next } from "hono"; 3 - 4 - import { 5 - type EventProps, 6 - parseInputToProps, 7 - setupAnalytics, 8 - } from "@openstatus/analytics"; 9 - import { db, eq } from "@openstatus/db"; 10 - import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; 11 - import { getPlanConfig } from "@openstatus/db/src/schema/plan/utils"; 12 - import { HTTPException } from "hono/http-exception"; 13 - import { env } from "../env"; 14 - import type { Variables } from "./index"; 15 - 16 - export async function secureMiddleware( 17 - c: Context<{ Variables: Variables }, "/*">, 18 - next: Next, 19 - ) { 20 - const key = c.req.header("x-openstatus-key"); 21 - if (!key) throw new HTTPException(401, { message: "Unauthorized" }); 22 - 23 - const { error, result } = 24 - env.NODE_ENV === "production" 25 - ? await verifyKey(key) 26 - : { result: { valid: true, ownerId: "1" }, error: null }; 27 - 28 - if (error) throw new HTTPException(500, { message: error.message }); 29 - if (!result.valid) throw new HTTPException(401, { message: "Unauthorized" }); 30 - if (!result.ownerId) 31 - throw new HTTPException(401, { message: "Unauthorized" }); 32 - 33 - const _workspace = await db 34 - .select() 35 - .from(workspace) 36 - .where(eq(workspace.id, Number.parseInt(result.ownerId))) 37 - .get(); 38 - 39 - if (!_workspace) { 40 - console.error("Workspace not found"); 41 - throw new HTTPException(401, { message: "Unauthorized" }); 42 - } 43 - 44 - const _work = selectWorkspaceSchema.parse(_workspace); 45 - 46 - c.set("workspacePlan", getPlanConfig(_workspace.plan)); 47 - c.set("workspaceId", `${result.ownerId}`); 48 - c.set("limits", _work.limits); 49 - 50 - await next(); 51 - } 52 - 53 - export function trackMiddleware(event: EventProps, eventProps?: string[]) { 54 - return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { 55 - await next(); 56 - 57 - // REMINDER: only track the event if the request was successful 58 - const isValid = c.res.status.toString().startsWith("2") && !c.error; 59 - 60 - if (isValid) { 61 - // We have checked the request to be valid already 62 - let json: unknown; 63 - if (c.req.raw.bodyUsed) { 64 - try { 65 - json = await c.req.json(); 66 - } catch { 67 - json = {}; 68 - } 69 - } 70 - const additionalProps = parseInputToProps(json, eventProps); 71 - 72 - // REMINDER: use setTimeout to avoid blocking the response 73 - setTimeout(async () => { 74 - const analytics = await setupAnalytics({ 75 - userId: `api_${c.get("workspaceId")}`, 76 - workspaceId: c.get("workspaceId"), 77 - plan: c.get("workspacePlan").id, 78 - }); 79 - await analytics.track({ ...event, additionalProps }); 80 - }, 0); 81 - } 82 - }; 83 - } 84 - 85 - /** 86 - * TODO: move the plan limit into the Unkey `{ meta }` to avoid an additional db call. 87 - * When an API Key is created, we need to include the `{ meta: { plan: "free" } }` to the key. 88 - * Then, we can just read the plan from the key and use it in the middleware. 89 - * Don't forget to update the key whenever a user changes their plan. (via `stripeRoute` webhook) 90 - */
+17 -13
apps/server/src/v1/monitors/delete.ts apps/server/src/routes/v1/monitors/delete.ts
··· 1 1 import { createRoute, z } from "@hono/zod-openapi"; 2 2 3 - import { db, eq } from "@openstatus/db"; 3 + import { and, db, eq, isNull } from "@openstatus/db"; 4 4 import { monitor } from "@openstatus/db/src/schema"; 5 5 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 7 + import { trackMiddleware } from "@/libs/middlewares"; 6 8 import { Events } from "@openstatus/analytics"; 7 - import { HTTPException } from "hono/http-exception"; 8 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 - import { trackMiddleware } from "../middleware"; 10 9 import type { monitorsApi } from "./index"; 11 10 import { ParamsSchema } from "./schema"; 12 11 13 12 const deleteRoute = createRoute({ 14 13 method: "delete", 15 14 tags: ["monitor"], 16 - description: "Delete a monitor", 15 + summary: "Delete a monitor", 17 16 path: "/:id", 18 17 request: { 19 18 params: ParamsSchema, ··· 26 25 schema: z.object({}), 27 26 }, 28 27 }, 29 - description: "Delete the monitor", 28 + description: "The monitor was successfully deleted", 30 29 }, 31 30 ...openApiErrorResponses, 32 31 }, ··· 34 33 35 34 export function registerDeleteMonitor(app: typeof monitorsApi) { 36 35 return app.openapi(deleteRoute, async (c) => { 37 - const workspaceId = c.get("workspaceId"); 36 + const workspaceId = c.get("workspace").id; 38 37 const { id } = c.req.valid("param"); 39 38 40 39 const _monitor = await db 41 40 .select() 42 41 .from(monitor) 43 - .where(eq(monitor.id, Number(id))) 42 + .where( 43 + and( 44 + eq(monitor.id, Number(id)), 45 + eq(monitor.workspaceId, workspaceId), 46 + isNull(monitor.deletedAt), 47 + ), 48 + ) 44 49 .get(); 45 50 46 51 if (!_monitor) { 47 - throw new HTTPException(404, { message: "Not Found" }); 48 - } 49 - 50 - if (Number(workspaceId) !== _monitor.workspaceId) { 51 - throw new HTTPException(401, { message: "Unauthorized" }); 52 + throw new OpenStatusApiError({ 53 + code: "NOT_FOUND", 54 + message: `Monitor ${id} not found`, 55 + }); 52 56 } 53 57 54 58 await db
+9 -7
apps/server/src/v1/monitors/get.ts apps/server/src/routes/v1/monitors/get.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 1 + import { createRoute } from "@hono/zod-openapi"; 2 2 3 3 import { and, db, eq, isNull } from "@openstatus/db"; 4 4 import { monitor } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 7 import type { monitorsApi } from "./index"; 9 8 import { MonitorSchema, ParamsSchema } from "./schema"; 10 9 11 10 const getRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["monitor"], 14 - description: "Get a monitor", 13 + summary: "Get a monitor", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 31 30 32 31 export function registerGetMonitor(api: typeof monitorsApi) { 33 32 return api.openapi(getRoute, async (c) => { 34 - const workspaceId = c.get("workspaceId"); 33 + const workspaceId = c.get("workspace").id; 35 34 const { id } = c.req.valid("param"); 36 35 37 36 const _monitor = await db ··· 40 39 .where( 41 40 and( 42 41 eq(monitor.id, Number(id)), 43 - eq(monitor.workspaceId, Number(workspaceId)), 42 + eq(monitor.workspaceId, workspaceId), 44 43 isNull(monitor.deletedAt), 45 44 ), 46 45 ) 47 46 .get(); 48 47 49 48 if (!_monitor) { 50 - throw new HTTPException(404, { message: "Not Found" }); 49 + throw new OpenStatusApiError({ 50 + code: "NOT_FOUND", 51 + message: `Monitor ${id} not found`, 52 + }); 51 53 } 52 54 53 55 const data = MonitorSchema.parse(_monitor);
+4 -12
apps/server/src/v1/monitors/get_all.ts apps/server/src/routes/v1/monitors/get_all.ts
··· 3 3 import { and, db, eq, isNull } from "@openstatus/db"; 4 4 import { monitor } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { openApiErrorResponses } from "@/libs/errors"; 8 7 import type { monitorsApi } from "./index"; 9 8 import { MonitorSchema } from "./schema"; 10 9 11 10 const getAllRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["monitor"], 14 - description: "Get all monitors", 13 + summary: "List all monitors", 15 14 path: "/", 16 15 request: {}, 17 16 responses: { ··· 29 28 30 29 export function registerGetAllMonitors(app: typeof monitorsApi) { 31 30 return app.openapi(getAllRoute, async (c) => { 32 - const workspaceId = c.get("workspaceId"); 31 + const workspaceId = c.get("workspace").id; 33 32 34 33 const _monitors = await db 35 34 .select() 36 35 .from(monitor) 37 36 .where( 38 - and( 39 - eq(monitor.workspaceId, Number(workspaceId)), 40 - isNull(monitor.deletedAt), 41 - ), 37 + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), 42 38 ) 43 39 .all(); 44 - 45 - if (!_monitors) { 46 - throw new HTTPException(404, { message: "Not Found" }); 47 - } 48 40 49 41 const data = z.array(MonitorSchema).parse(_monitors); 50 42
+3 -2
apps/server/src/v1/monitors/index.ts apps/server/src/routes/v1/monitors/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerDeleteMonitor } from "./delete"; 6 6 import { registerGetMonitor } from "./get"; ··· 20 20 registerGetMonitor(monitorsApi); 21 21 registerPutMonitor(monitorsApi); 22 22 registerDeleteMonitor(monitorsApi); 23 + registerPostMonitor(monitorsApi); 24 + // 23 25 registerGetMonitorSummary(monitorsApi); 24 - registerPostMonitor(monitorsApi); 25 26 registerTriggerMonitor(monitorsApi); 26 27 registerGetMonitorResult(monitorsApi); 27 28 registerRunMonitor(monitorsApi);
-318
apps/server/src/v1/monitors/monitors.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - import type { MonitorSchema } from "./schema"; 5 - 6 - test("GET one monitor", async () => { 7 - const res = await api.request("/monitor/1", { 8 - headers: { 9 - "x-openstatus-key": "1", 10 - }, 11 - }); 12 - const json = await res.json(); 13 - 14 - expect(res.status).toBe(200); 15 - expect(json).toMatchObject({ 16 - id: 1, 17 - periodicity: "1m", 18 - url: "https://www.openstatus.dev", 19 - regions: ["ams"], 20 - name: "OpenStatus", 21 - description: "OpenStatus website", 22 - method: "POST", 23 - body: '{"hello":"world"}', 24 - headers: [{ key: "key", value: "value" }], 25 - active: true, 26 - public: false, 27 - assertions: null, 28 - }); 29 - }); 30 - 31 - test("GET all monitor", async () => { 32 - const res = await api.request("/monitor", { 33 - headers: { 34 - "x-openstatus-key": "1", 35 - }, 36 - }); 37 - const json = (await res.json()) as MonitorSchema[]; 38 - 39 - expect(res.status).toBe(200); 40 - expect(json[0]).toMatchObject({ 41 - id: 1, 42 - periodicity: "1m", 43 - url: "https://www.openstatus.dev", 44 - regions: ["ams"], 45 - name: "OpenStatus", 46 - description: "OpenStatus website", 47 - method: "POST", 48 - body: '{"hello":"world"}', 49 - headers: [{ key: "key", value: "value" }], 50 - active: true, 51 - public: false, 52 - }); 53 - }); 54 - 55 - test("Create a monitor", async () => { 56 - const data = { 57 - periodicity: "10m", 58 - url: "https://www.openstatus.dev", 59 - name: "OpenStatus", 60 - description: "OpenStatus website", 61 - regions: ["ams", "gru"], 62 - method: "POST", 63 - body: '{"hello":"world"}', 64 - headers: [{ key: "key", value: "value" }], 65 - active: true, 66 - public: true, 67 - assertions: [ 68 - { 69 - type: "status", 70 - compare: "eq", 71 - target: 200, 72 - }, 73 - { type: "header", compare: "not_eq", key: "key", target: "value" }, 74 - ], 75 - }; 76 - 77 - const res = await api.request("/monitor", { 78 - method: "POST", 79 - headers: { 80 - "x-openstatus-key": "1", 81 - "content-type": "application/json", 82 - }, 83 - body: JSON.stringify(data), 84 - }); 85 - 86 - expect(res.status).toBe(200); 87 - 88 - expect(await res.json()).toMatchObject({ 89 - id: expect.any(Number), 90 - ...data, 91 - }); 92 - }); 93 - 94 - test("Create a monitor with Assertion ", async () => { 95 - const data = { 96 - periodicity: "10m", 97 - url: "https://www.openstatus.dev", 98 - name: "OpenStatus", 99 - description: "OpenStatus website", 100 - regions: ["ams", "gru", "iad"], 101 - method: "POST", 102 - body: '{"hello":"world"}', 103 - headers: [{ key: "key", value: "value" }], 104 - active: true, 105 - public: true, 106 - assertions: [ 107 - { 108 - type: "status", 109 - compare: "eq", 110 - target: 200, 111 - }, 112 - { type: "header", compare: "not_eq", key: "key", target: "value" }, 113 - ], 114 - }; 115 - const res = await api.request("/monitor", { 116 - method: "POST", 117 - headers: { 118 - "x-openstatus-key": "1", 119 - "content-type": "application/json", 120 - }, 121 - body: JSON.stringify(data), 122 - }); 123 - 124 - expect(res.status).toBe(200); 125 - 126 - expect(await res.json()).toMatchObject({ 127 - id: expect.any(Number), 128 - ...data, 129 - }); 130 - }); 131 - 132 - test("Create a monitor without auth key should return 401", async () => { 133 - const data = { 134 - periodicity: "10m", 135 - url: "https://www.openstatus.dev", 136 - name: "OpenStatus", 137 - description: "OpenStatus website", 138 - regions: ["ams", "gru"], 139 - method: "POST", 140 - body: '{"hello":"world"}', 141 - headers: [{ key: "key", value: "value" }], 142 - active: true, 143 - public: false, 144 - }; 145 - const res = await api.request("/monitor", { 146 - method: "POST", 147 - headers: { 148 - "content-type": "application/json", 149 - }, 150 - body: JSON.stringify(data), 151 - }); 152 - expect(res.status).toBe(401); 153 - }); 154 - 155 - test("Create a monitor with invalid data should return 403", async () => { 156 - const data = { 157 - periodicity: 32, //not valid value 158 - url: "https://www.openstatus.dev", 159 - name: "OpenStatus", 160 - description: "OpenStatus website", 161 - regions: ["ams", "gru"], 162 - method: "POST", 163 - body: '{"hello":"world"}', 164 - headers: [{ key: "key", value: "value" }], 165 - active: true, 166 - public: false, 167 - }; 168 - const res = await api.request("/monitor", { 169 - method: "POST", 170 - headers: { 171 - "x-openstatus-key": "1", 172 - "content-type": "application/json", 173 - }, 174 - body: JSON.stringify(data), 175 - }); 176 - 177 - expect(res.status).toBe(400); 178 - }); 179 - 180 - test("Update a Monitor ", async () => { 181 - const data = { 182 - periodicity: "10m", 183 - url: "https://www.openstatus.dev", 184 - name: "OpenStatus", 185 - description: "OpenStatus website", 186 - regions: ["ams"], 187 - method: "GET", 188 - body: '{"hello":"world"}', 189 - headers: [{ key: "key", value: "value" }], 190 - active: true, 191 - public: true, 192 - }; 193 - 194 - const res = await api.request("/monitor/1", { 195 - method: "PUT", 196 - headers: { 197 - "x-openstatus-key": "1", 198 - "content-type": "application/json", 199 - }, 200 - body: JSON.stringify(data), 201 - }); 202 - expect(res.status).toBe(200); 203 - 204 - expect(await res.json()).toMatchObject({ 205 - id: 1, 206 - periodicity: "10m", 207 - url: "https://www.openstatus.dev", 208 - regions: ["ams"], 209 - name: "OpenStatus", 210 - description: "OpenStatus website", 211 - method: "GET", 212 - body: '{"hello":"world"}', 213 - headers: [{ key: "key", value: "value" }], 214 - active: true, 215 - public: true, 216 - }); 217 - }); 218 - 219 - test("Update a monitor not in db should return 404", async () => { 220 - const data = { 221 - periodicity: "10m", 222 - url: "https://www.openstatus.dev", 223 - name: "OpenStatus", 224 - description: "OpenStatus website", 225 - regions: ["ams"], 226 - method: "GET", 227 - body: '{"hello":"world"}', 228 - headers: [{ key: "key", value: "value" }], 229 - active: true, 230 - public: false, 231 - }; 232 - 233 - const res = await api.request("/monitor/404", { 234 - //accessing wrong monitor, just returns 404 235 - method: "PUT", 236 - headers: { 237 - "x-openstatus-key": "1", 238 - "content-type": "application/json", 239 - }, 240 - body: JSON.stringify(data), 241 - }); 242 - expect(res.status).toBe(404); 243 - }); 244 - 245 - test("Update a monitor without auth key should return 401", async () => { 246 - const data = { 247 - periodicity: "5m", 248 - url: "https://www.openstatus.dev", 249 - name: "OpenStatus", 250 - description: "OpenStatus website", 251 - regions: ["ams"], 252 - method: "GET", 253 - body: '{"hello":"world"}', 254 - headers: [{ key: "key", value: "value" }], 255 - active: true, 256 - public: false, 257 - }; 258 - const res = await api.request("/monitor/2", { 259 - method: "PUT", 260 - headers: { 261 - "content-type": "application/json", 262 - }, 263 - body: JSON.stringify(data), 264 - }); 265 - expect(res.status).toBe(401); 266 - }); 267 - test("Update a monitor with invalid data should return 403", async () => { 268 - const data = { 269 - periodicity: 9, //not passing correct value returns 403 270 - url: "https://www.openstatus.dev", 271 - name: "OpenStatus", 272 - description: "OpenStatus website", 273 - regions: ["ams"], 274 - method: "GET", 275 - body: '{"hello":"world"}', 276 - headers: [{ key: "key", value: "value" }], 277 - active: true, 278 - public: false, 279 - }; 280 - const res = await api.request("/monitor/2", { 281 - method: "PUT", 282 - headers: { 283 - "x-openstatus-key": "1", 284 - "content-type": "application/json", 285 - }, 286 - body: JSON.stringify(data), 287 - }); 288 - 289 - expect(res.status).toBe(400); 290 - }); 291 - 292 - test("Delete one monitor", async () => { 293 - const res = await api.request("/monitor/3", { 294 - method: "DELETE", 295 - headers: { 296 - "x-openstatus-key": "1", 297 - }, 298 - }); 299 - expect(res.status).toBe(200); 300 - 301 - expect(await res.json()).toMatchObject({}); 302 - }); 303 - 304 - test.todo("Get monitor daily Summary"); 305 - // test("Get monitor daily Summary", async () => { 306 - // const res = await api.request("/monitor/1/summary", { 307 - // headers: { 308 - // "x-openstatus-key": "1", 309 - // }, 310 - // }); 311 - // expect(res.status).toBe(200); 312 - // expect(await res.json()).toMatchObject({ 313 - // ok: 4, 314 - // count: 13, 315 - // avgLatency: 1, 316 - // day: expect.stringMatching(iso8601Regex) 317 - // }); 318 - // });
+29 -19
apps/server/src/v1/monitors/post.ts apps/server/src/routes/v1/monitors/post.ts
··· 4 4 import { and, db, eq, isNull, sql } from "@openstatus/db"; 5 5 import { monitor } from "@openstatus/db/src/schema"; 6 6 7 - import { HTTPException } from "hono/http-exception"; 8 - import { serialize } from "../../../../../packages/assertions/src"; 7 + import { serialize } from "@openstatus/assertions"; 9 8 10 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 11 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 12 - import { trackMiddleware } from "../middleware"; 9 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 10 + import { trackMiddleware } from "@/libs/middlewares"; 13 11 import type { monitorsApi } from "./index"; 14 12 import { MonitorSchema } from "./schema"; 15 13 import { getAssertions } from "./utils"; ··· 17 15 const postRoute = createRoute({ 18 16 method: "post", 19 17 tags: ["monitor"], 20 - description: "Create a monitor", 18 + summary: "Create a monitor", 21 19 path: "/", 22 20 middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], 23 21 request: { ··· 45 43 46 44 export function registerPostMonitor(api: typeof monitorsApi) { 47 45 return api.openapi(postRoute, async (c) => { 48 - const workspaceId = c.get("workspaceId"); 49 - const limits = c.get("limits"); 46 + const workspaceId = c.get("workspace").id; 47 + const limits = c.get("workspace").limits; 50 48 const input = c.req.valid("json"); 51 49 const count = ( 52 50 await db 53 51 .select({ count: sql<number>`count(*)` }) 54 52 .from(monitor) 55 53 .where( 56 - and( 57 - eq(monitor.workspaceId, Number(workspaceId)), 58 - isNull(monitor.deletedAt), 59 - ), 54 + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), 60 55 ) 61 56 .all() 62 57 )[0].count; 63 58 64 - if (count >= getLimit(limits, "monitors")) { 65 - throw new HTTPException(403, { 59 + if (count >= limits.monitors) { 60 + throw new OpenStatusApiError({ 61 + code: "PAYMENT_REQUIRED", 66 62 message: "Upgrade for more monitors", 67 63 }); 68 64 } 69 65 70 - if (!getLimit(limits, "periodicity").includes(input.periodicity)) { 71 - throw new HTTPException(403, { message: "Forbidden" }); 66 + if (!limits.periodicity.includes(input.periodicity)) { 67 + throw new OpenStatusApiError({ 68 + code: "PAYMENT_REQUIRED", 69 + message: "Upgrade for more periodicity", 70 + }); 72 71 } 73 72 74 73 for (const region of input.regions) { 75 - if (!getLimit(limits, "regions").includes(region)) { 76 - throw new HTTPException(403, { message: "Upgrade for more region" }); 74 + if (!limits.regions.includes(region)) { 75 + throw new OpenStatusApiError({ 76 + code: "PAYMENT_REQUIRED", 77 + message: "Upgrade for more regions", 78 + }); 77 79 } 78 80 } 79 81 82 + if (input.jobType && !["http", "tcp"].includes(input.jobType)) { 83 + throw new OpenStatusApiError({ 84 + code: "BAD_REQUEST", 85 + message: 86 + "Invalid jobType, currently only 'http' and 'tcp' are supported", 87 + }); 88 + } 89 + 80 90 const { headers, regions, assertions, ...rest } = input; 81 91 82 92 const assert = assertions ? getAssertions(assertions) : []; ··· 85 95 .insert(monitor) 86 96 .values({ 87 97 ...rest, 88 - workspaceId: Number(workspaceId), 98 + workspaceId: workspaceId, 89 99 regions: regions ? regions.join(",") : undefined, 90 100 headers: input.headers ? JSON.stringify(input.headers) : undefined, 91 101 assertions: assert.length > 0 ? serialize(assert) : undefined,
-95
apps/server/src/v1/monitors/put.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - 3 - import { and, db, eq } from "@openstatus/db"; 4 - import { monitor } from "@openstatus/db/src/schema"; 5 - 6 - import { Events } from "@openstatus/analytics"; 7 - import { HTTPException } from "hono/http-exception"; 8 - import { serialize } from "../../../../../packages/assertions/src/serializing"; 9 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 10 - import { trackMiddleware } from "../middleware"; 11 - import type { monitorsApi } from "./index"; 12 - import { MonitorSchema, ParamsSchema } from "./schema"; 13 - import { getAssertions } from "./utils"; 14 - 15 - const putRoute = createRoute({ 16 - method: "put", 17 - tags: ["monitor"], 18 - description: "Update a monitor", 19 - path: "/:id", 20 - middleware: [trackMiddleware(Events.UpdateMonitor)], 21 - request: { 22 - params: ParamsSchema, 23 - body: { 24 - description: "The monitor to update", 25 - content: { 26 - "application/json": { 27 - schema: MonitorSchema.omit({ id: true }), 28 - }, 29 - }, 30 - }, 31 - }, 32 - responses: { 33 - 200: { 34 - content: { 35 - "application/json": { 36 - schema: MonitorSchema, 37 - }, 38 - }, 39 - description: "Update a monitor", 40 - }, 41 - ...openApiErrorResponses, 42 - }, 43 - }); 44 - 45 - export function registerPutMonitor(api: typeof monitorsApi) { 46 - return api.openapi(putRoute, async (c) => { 47 - const workspaceId = c.get("workspaceId"); 48 - const limits = c.get("limits"); 49 - const { id } = c.req.valid("param"); 50 - const input = c.req.valid("json"); 51 - 52 - if (!limits.periodicity.includes(input.periodicity)) { 53 - throw new HTTPException(403, { message: "Forbidden" }); 54 - } 55 - 56 - for (const region of input.regions) { 57 - if (!limits.regions.includes(region)) { 58 - throw new HTTPException(403, { message: "Upgrade for more region" }); 59 - } 60 - } 61 - const _monitor = await db 62 - .select() 63 - .from(monitor) 64 - .where(eq(monitor.id, Number(id))) 65 - .get(); 66 - 67 - if (!_monitor) { 68 - throw new HTTPException(404, { message: "Not Found" }); 69 - } 70 - 71 - if (Number(workspaceId) !== _monitor.workspaceId) { 72 - throw new HTTPException(401, { message: "Unauthorized" }); 73 - } 74 - 75 - const { headers, regions, assertions, ...rest } = input; 76 - 77 - const assert = assertions ? getAssertions(assertions) : []; 78 - 79 - const _newMonitor = await db 80 - .update(monitor) 81 - .set({ 82 - ...rest, 83 - regions: regions ? regions.join(",") : undefined, 84 - headers: input.headers ? JSON.stringify(input.headers) : undefined, 85 - assertions: assert.length > 0 ? serialize(assert) : undefined, 86 - timeout: input.timeout || 45000, 87 - }) 88 - .where(eq(monitor.id, Number(_monitor.id))) 89 - .returning() 90 - .get(); 91 - 92 - const data = MonitorSchema.parse(_newMonitor); 93 - return c.json(data, 200); 94 - }); 95 - }
+23 -18
apps/server/src/v1/monitors/results/get.ts apps/server/src/routes/v1/monitors/results/get.ts
··· 1 1 import { createRoute, z } from "@hono/zod-openapi"; 2 2 3 - import { and, db, eq, isNull } from "@openstatus/db"; 3 + import { and, db, eq } from "@openstatus/db"; 4 4 import { monitor, monitorRun } from "@openstatus/db/src/schema"; 5 5 import { OSTinybird } from "@openstatus/tinybird"; 6 6 7 - import { flyRegions } from "@openstatus/db/src/schema/constants"; 8 - import { HTTPException } from "hono/http-exception"; 9 - import { env } from "../../../env"; 10 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 7 + import { env } from "@/env"; 8 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 11 9 import type { monitorsApi } from "../index"; 12 10 import { ParamsSchema, ResultRun } from "../schema"; 13 11 14 12 const tb = new OSTinybird(env.TINY_BIRD_API_KEY); 15 13 16 - const getMonitorStats = createRoute({ 14 + const getRoute = createRoute({ 17 15 method: "get", 18 16 tags: ["monitor"], 19 - description: "Get a monitor result", 17 + summary: "Get a monitor result", 18 + // FIXME: Should work for all types of monitors 19 + description: 20 + "**WARNING:** This works only for HTTP monitors. We will add support for other types of monitors soon.", 20 21 path: "/:id/result/:resultId", 21 22 request: { 22 23 params: ParamsSchema.extend({ ··· 29 30 200: { 30 31 content: { 31 32 "application/json": { 32 - schema: z.array(ResultRun), 33 + schema: ResultRun.array(), 33 34 }, 34 35 }, 35 - description: "All the metrics for the monitor", 36 + description: "All the metrics for the result id from the monitor", 36 37 }, 37 38 ...openApiErrorResponses, 38 39 }, 39 40 }); 40 41 41 42 export function registerGetMonitorResult(api: typeof monitorsApi) { 42 - return api.openapi(getMonitorStats, async (c) => { 43 - const workspaceId = c.get("workspaceId"); 43 + return api.openapi(getRoute, async (c) => { 44 + const workspaceId = c.get("workspace").id; 44 45 const { id, resultId } = c.req.valid("param"); 45 46 46 47 const _monitorRun = await db ··· 50 51 and( 51 52 eq(monitorRun.id, Number(resultId)), 52 53 eq(monitorRun.monitorId, Number(id)), 53 - eq(monitorRun.workspaceId, Number(workspaceId)), 54 + eq(monitorRun.workspaceId, workspaceId), 54 55 ), 55 56 ) 56 57 .get(); 57 58 58 59 if (!_monitorRun || !_monitorRun?.runnedAt) { 59 - throw new HTTPException(404, { message: "Not Found" }); 60 + throw new OpenStatusApiError({ 61 + code: "NOT_FOUND", 62 + message: `Monitor run ${resultId} not found`, 63 + }); 60 64 } 61 65 62 66 const _monitor = await db ··· 66 70 .get(); 67 71 68 72 if (!_monitor) { 69 - throw new HTTPException(404, { message: "Not Found" }); 73 + throw new OpenStatusApiError({ 74 + code: "NOT_FOUND", 75 + message: `Monitor ${id} not found`, 76 + }); 70 77 } 78 + 71 79 // Fetch result from tb pipe 72 80 const data = await tb.getResultForOnDemandCheckHttp({ 73 81 monitorId: _monitor.id, 74 82 timestamp: _monitorRun.runnedAt?.getTime(), 75 83 url: _monitor.url, 76 84 }); 77 - // return array of results 78 - if (!data || data.data.length === 0) { 79 - throw new HTTPException(404, { message: "Not Found" }); 80 - } 85 + 81 86 return c.json(data.data, 200); 82 87 }); 83 88 }
-227
apps/server/src/v1/monitors/run/post.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - import { and, eq, gte, isNull, sql } from "@openstatus/db"; 3 - import { db } from "@openstatus/db/src/db"; 4 - import { monitorRun } from "@openstatus/db/src/schema"; 5 - import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; 6 - import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; 7 - import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; 8 - import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; 9 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 10 - import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; 11 - import { HTTPException } from "hono/http-exception"; 12 - import type { monitorsApi } from ".."; 13 - import { env } from "../../../env"; 14 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 15 - import { 16 - HTTPTriggerResult, 17 - ParamsSchema, 18 - TCPTriggerResult, 19 - TriggerResult, 20 - } from "../schema"; 21 - 22 - const triggerMonitor = createRoute({ 23 - method: "post", 24 - tags: ["monitor"], 25 - description: "Run a monitor check", 26 - path: "/:id/run", 27 - request: { 28 - params: ParamsSchema, 29 - query: z 30 - .object({ 31 - "no-wait": z.coerce 32 - .boolean() 33 - .optional() 34 - .openapi({ 35 - description: "Don't wait for the result", 36 - }) 37 - .default(false), 38 - }) 39 - .openapi({}), 40 - }, 41 - responses: { 42 - 200: { 43 - content: { 44 - "application/json": { 45 - schema: z.array(TriggerResult), 46 - }, 47 - }, 48 - description: "All the historical metrics", 49 - }, 50 - ...openApiErrorResponses, 51 - }, 52 - }); 53 - 54 - export function registerRunMonitor(api: typeof monitorsApi) { 55 - return api.openapi(triggerMonitor, async (c) => { 56 - const workspaceId = c.get("workspaceId"); 57 - const { id } = c.req.valid("param"); 58 - const limits = c.get("limits"); 59 - const { "no-wait": noWait } = c.req.valid("query"); 60 - const lastMonth = new Date().setMonth(new Date().getMonth() - 1); 61 - 62 - const count = ( 63 - await db 64 - .select({ count: sql<number>`count(*)` }) 65 - .from(monitorRun) 66 - .where( 67 - and( 68 - eq(monitorRun.workspaceId, Number(workspaceId)), 69 - gte(monitorRun.createdAt, new Date(lastMonth)), 70 - ), 71 - ) 72 - .all() 73 - )[0].count; 74 - 75 - if (count >= getLimit(limits, "synthetic-checks")) { 76 - throw new HTTPException(403, { 77 - message: "Upgrade for more checks", 78 - }); 79 - } 80 - 81 - const monitorData = await db 82 - .select() 83 - .from(monitor) 84 - .where( 85 - and( 86 - eq(monitor.id, Number(id)), 87 - eq(monitor.workspaceId, Number(workspaceId)), 88 - isNull(monitor.deletedAt), 89 - ), 90 - ) 91 - .get(); 92 - 93 - if (!monitorData) { 94 - throw new HTTPException(404, { message: "Not Found" }); 95 - } 96 - 97 - const parseMonitor = selectMonitorSchema.safeParse(monitorData); 98 - 99 - if (!parseMonitor.success) { 100 - throw new HTTPException(400, { message: "Something went wrong" }); 101 - } 102 - 103 - const row = parseMonitor.data; 104 - 105 - // Maybe later overwrite the region 106 - 107 - const monitorStatusData = await db 108 - .select() 109 - .from(monitorStatusTable) 110 - .where(eq(monitorStatusTable.monitorId, monitorData.id)) 111 - .all(); 112 - 113 - const monitorStatus = z 114 - .array(selectMonitorStatusSchema) 115 - .safeParse(monitorStatusData); 116 - if (!monitorStatus.success) { 117 - console.log(monitorStatus.error); 118 - throw new HTTPException(400, { message: "Something went wrong" }); 119 - } 120 - 121 - const timestamp = Date.now(); 122 - 123 - const newRun = await db 124 - .insert(monitorRun) 125 - .values({ 126 - monitorId: row.id, 127 - workspaceId: row.workspaceId, 128 - runnedAt: new Date(timestamp), 129 - }) 130 - .returning(); 131 - 132 - if (!newRun[0]) { 133 - throw new HTTPException(400, { message: "Something went wrong" }); 134 - } 135 - 136 - const allResult = []; 137 - for (const region of parseMonitor.data.regions) { 138 - const status = 139 - monitorStatus.data.find((m) => region === m.region)?.status || "active"; 140 - // Trigger the monitor 141 - 142 - let payload: 143 - | z.infer<typeof httpPayloadSchema> 144 - | z.infer<typeof tpcPayloadSchema> 145 - | null = null; 146 - // 147 - if (row.jobType === "http") { 148 - payload = { 149 - workspaceId: String(row.workspaceId), 150 - monitorId: String(row.id), 151 - url: row.url, 152 - method: row.method || "GET", 153 - cronTimestamp: timestamp, 154 - body: row.body, 155 - headers: row.headers, 156 - status: status, 157 - assertions: row.assertions ? JSON.parse(row.assertions) : null, 158 - degradedAfter: row.degradedAfter, 159 - timeout: row.timeout, 160 - trigger: "api", 161 - }; 162 - } 163 - if (row.jobType === "tcp") { 164 - payload = { 165 - workspaceId: String(row.workspaceId), 166 - monitorId: String(row.id), 167 - uri: row.url, 168 - status: status, 169 - assertions: row.assertions ? JSON.parse(row.assertions) : null, 170 - cronTimestamp: timestamp, 171 - degradedAfter: row.degradedAfter, 172 - timeout: row.timeout, 173 - trigger: "api", 174 - }; 175 - } 176 - 177 - if (!payload) { 178 - throw new Error("Invalid jobType"); 179 - } 180 - const url = generateUrl({ row }); 181 - const result = fetch(url, { 182 - headers: { 183 - "Content-Type": "application/json", 184 - "fly-prefer-region": region, // Specify the region you want the request to be sent to 185 - Authorization: `Basic ${env.CRON_SECRET}`, 186 - }, 187 - method: "POST", 188 - body: JSON.stringify(payload), 189 - }); 190 - allResult.push(result); 191 - } 192 - 193 - if (noWait) { 194 - return c.json([], 200); 195 - } 196 - 197 - const result = await Promise.all(allResult); 198 - // console.log(result); 199 - 200 - const bodies = await Promise.all(result.map((r) => r.json())); 201 - // let data = null; 202 - 203 - const data = z.array(TriggerResult).safeParse(bodies); 204 - 205 - if (!data) { 206 - throw new HTTPException(400, { message: "Something went wrong" }); 207 - } 208 - 209 - if (!data.success) { 210 - console.log(data.error); 211 - throw new HTTPException(400, { message: "Something went wrong" }); 212 - } 213 - 214 - return c.json(data.data, 200); 215 - }); 216 - } 217 - 218 - function generateUrl({ row }: { row: z.infer<typeof selectMonitorSchema> }) { 219 - switch (row.jobType) { 220 - case "http": 221 - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}&trigger=api&data=true`; 222 - case "tcp": 223 - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}&trigger=api&data=true`; 224 - default: 225 - throw new Error("Invalid jobType"); 226 - } 227 - }
+14 -26
apps/server/src/v1/monitors/schema.ts apps/server/src/routes/v1/monitors/schema.ts
··· 1 1 import { z } from "@hono/zod-openapi"; 2 2 3 + import { numberCompare, stringCompare } from "@openstatus/assertions"; 3 4 import { monitorJobTypes, monitorMethods } from "@openstatus/db/src/schema"; 4 5 import { 5 6 flyRegions, 6 7 monitorPeriodicitySchema, 7 8 } from "@openstatus/db/src/schema/constants"; 8 9 import { ZodError } from "zod"; 9 - import { 10 - numberCompare, 11 - stringCompare, 12 - } from "../../../../../packages/assertions/src/v1"; 13 10 14 11 const statusAssertion = z 15 12 .object({ ··· 124 121 example: "Documenso", 125 122 description: "The name of the monitor", 126 123 }), 127 - description: z 128 - .string() 129 - .openapi({ 130 - example: "Documenso website", 131 - description: "The description of your monitor", 132 - }) 133 - .optional(), 124 + description: z.string().optional().openapi({ 125 + example: "Documenso website", 126 + description: "The description of your monitor", 127 + }), 134 128 method: z.enum(monitorMethods).default("GET").openapi({ example: "GET" }), 135 129 body: z 136 130 .preprocess((val) => { ··· 205 199 timeout: z.number().nullish().default(45000).openapi({ 206 200 description: "The timeout of the request", 207 201 }), 208 - jobType: z 209 - .enum(monitorJobTypes) 210 - .openapi({ 211 - description: "The type of the monitor", 212 - }) 213 - .default("http") 214 - .optional(), 202 + jobType: z.enum(monitorJobTypes).optional().default("http").openapi({ 203 + description: "The type of the monitor", 204 + }), 215 205 }) 216 - .openapi({ 217 - description: "The monitor", 218 - required: ["periodicity", "url", "regions", "method"], 219 - }); 206 + .openapi("Monitor"); 220 207 221 208 export type MonitorSchema = z.infer<typeof MonitorSchema>; 222 209 210 + // TODO: Move to @/libs/checker/schema 223 211 const timingSchema = z.object({ 224 212 dnsStart: z.number(), 225 213 dnsDone: z.number(), ··· 232 220 transferStart: z.number(), 233 221 transferDone: z.number(), 234 222 }); 223 + 224 + // Use a baseSchema with 'latency', 'region', 'timestamp' 235 225 236 226 export const HTTPTriggerResult = z.object({ 237 227 jobType: z.literal("http"), ··· 255 245 region: z.enum(flyRegions), 256 246 timestamp: z.number(), 257 247 timing: tcptimingSchema, 248 + // check if it should be z.coerce.boolean()? 258 249 error: z.number().optional().nullable(), 259 250 errorMessage: z.string().optional().nullable(), 260 251 }); ··· 269 260 statusCode: z.number().int().nullable().default(null), 270 261 monitorId: z.string().default(""), 271 262 url: z.string().optional(), 272 - error: z 273 - .number() 274 - .default(0) 275 - .transform((val) => val !== 0), 263 + error: z.coerce.boolean().default(false), 276 264 region: z.enum(flyRegions), 277 265 timestamp: z.number().int().optional(), 278 266 message: z.string().nullable().optional(),
-95
apps/server/src/v1/monitors/summary/get.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - 3 - import { and, db, eq, isNull } from "@openstatus/db"; 4 - import { monitor } from "@openstatus/db/src/schema"; 5 - import { OSTinybird } from "@openstatus/tinybird"; 6 - import { Redis } from "@openstatus/upstash"; 7 - 8 - import { HTTPException } from "hono/http-exception"; 9 - import { env } from "../../../env"; 10 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 11 - import { isoDate } from "../../utils"; 12 - import type { monitorsApi } from "../index"; 13 - import { ParamsSchema } from "../schema"; 14 - 15 - const tb = new OSTinybird(env.TINY_BIRD_API_KEY); 16 - const redis = Redis.fromEnv(); 17 - 18 - const dailyStatsSchema = z.object({ 19 - ok: z.number().int().openapi({ 20 - description: "The number of ok responses", 21 - }), 22 - count: z 23 - .number() 24 - .int() 25 - .openapi({ description: "The total number of request" }), 26 - day: isoDate, 27 - }); 28 - 29 - const dailyStatsSchemaArray = z 30 - .array(dailyStatsSchema) 31 - .openapi({ description: "The daily stats" }); 32 - 33 - const getMonitorStats = createRoute({ 34 - method: "get", 35 - tags: ["monitor"], 36 - description: "Get a monitor daily summary", 37 - path: "/:id/summary", 38 - request: { 39 - params: ParamsSchema, 40 - }, 41 - responses: { 42 - 200: { 43 - content: { 44 - "application/json": { 45 - schema: z.object({ 46 - data: dailyStatsSchemaArray, 47 - }), 48 - }, 49 - }, 50 - description: "All the historical metrics", 51 - }, 52 - ...openApiErrorResponses, 53 - }, 54 - }); 55 - 56 - export function registerGetMonitorSummary(api: typeof monitorsApi) { 57 - return api.openapi(getMonitorStats, async (c) => { 58 - const workspaceId = c.get("workspaceId"); 59 - const { id } = c.req.valid("param"); 60 - 61 - const _monitor = await db 62 - .select() 63 - .from(monitor) 64 - .where( 65 - and( 66 - eq(monitor.id, Number(id)), 67 - eq(monitor.workspaceId, Number(workspaceId)), 68 - isNull(monitor.deletedAt), 69 - ), 70 - ) 71 - .get(); 72 - 73 - if (!_monitor) { 74 - throw new HTTPException(404, { message: "Not Found" }); 75 - } 76 - 77 - const cache = await redis.get<z.infer<typeof dailyStatsSchemaArray>>( 78 - `${id}-daily-stats`, 79 - ); 80 - if (cache) { 81 - console.log("fetching from cache"); 82 - return c.json({ data: cache }, 200); 83 - } 84 - 85 - console.log("fetching from tinybird"); 86 - const res = await tb.httpStatus45d({ monitorId: id }); 87 - 88 - if (!res || res.data.length === 0) { 89 - throw new HTTPException(404, { message: "Not Found" }); 90 - } 91 - await redis.set(`${id}-daily-stats`, res, { ex: 600 }); 92 - 93 - return c.json({ data: res.data }, 200); 94 - }); 95 - }
-195
apps/server/src/v1/monitors/trigger/post.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - import { and, eq, gte, isNull, sql } from "@openstatus/db"; 3 - import { db } from "@openstatus/db/src/db"; 4 - import { monitorRun } from "@openstatus/db/src/schema"; 5 - import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; 6 - import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; 7 - import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; 8 - import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; 9 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 10 - import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; 11 - import { HTTPException } from "hono/http-exception"; 12 - import type { monitorsApi } from ".."; 13 - import { env } from "../../../env"; 14 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 15 - import { ParamsSchema } from "../schema"; 16 - 17 - const triggerMonitor = createRoute({ 18 - method: "post", 19 - tags: ["monitor"], 20 - description: "Trigger a monitor check", 21 - path: "/:id/trigger", 22 - request: { 23 - params: ParamsSchema, 24 - }, 25 - responses: { 26 - 200: { 27 - content: { 28 - "application/json": { 29 - schema: z.object({ 30 - resultId: z 31 - .number() 32 - .openapi({ description: "the id of your check result" }), 33 - }), 34 - }, 35 - }, 36 - description: "All the historical metrics", 37 - }, 38 - ...openApiErrorResponses, 39 - }, 40 - }); 41 - 42 - export function registerTriggerMonitor(api: typeof monitorsApi) { 43 - return api.openapi(triggerMonitor, async (c) => { 44 - const workspaceId = c.get("workspaceId"); 45 - const { id } = c.req.valid("param"); 46 - const limits = c.get("limits"); 47 - 48 - const lastMonth = new Date().setMonth(new Date().getMonth() - 1); 49 - 50 - const count = ( 51 - await db 52 - .select({ count: sql<number>`count(*)` }) 53 - .from(monitorRun) 54 - .where( 55 - and( 56 - eq(monitorRun.workspaceId, Number(workspaceId)), 57 - gte(monitorRun.createdAt, new Date(lastMonth)), 58 - ), 59 - ) 60 - .all() 61 - )[0].count; 62 - 63 - if (count >= getLimit(limits, "synthetic-checks")) { 64 - throw new HTTPException(403, { 65 - message: "Upgrade for more checks", 66 - }); 67 - } 68 - 69 - const monitorData = await db 70 - .select() 71 - .from(monitor) 72 - .where( 73 - and( 74 - eq(monitor.id, Number(id)), 75 - eq(monitor.workspaceId, Number(workspaceId)), 76 - isNull(monitor.deletedAt), 77 - ), 78 - ) 79 - .get(); 80 - 81 - if (!monitorData) { 82 - throw new HTTPException(404, { message: "Not Found" }); 83 - } 84 - 85 - const parseMonitor = selectMonitorSchema.safeParse(monitorData); 86 - 87 - if (!parseMonitor.success) { 88 - throw new HTTPException(400, { message: "Something went wrong" }); 89 - } 90 - 91 - const row = parseMonitor.data; 92 - 93 - // Maybe later overwrite the region 94 - 95 - const monitorStatusData = await db 96 - .select() 97 - .from(monitorStatusTable) 98 - .where(eq(monitorStatusTable.monitorId, monitorData.id)) 99 - .all(); 100 - 101 - const monitorStatus = z 102 - .array(selectMonitorStatusSchema) 103 - .safeParse(monitorStatusData); 104 - if (!monitorStatus.success) { 105 - throw new HTTPException(400, { message: "Something went wrong" }); 106 - } 107 - 108 - const timestamp = Date.now(); 109 - 110 - const newRun = await db 111 - .insert(monitorRun) 112 - .values({ 113 - monitorId: row.id, 114 - workspaceId: row.workspaceId, 115 - runnedAt: new Date(timestamp), 116 - }) 117 - .returning(); 118 - 119 - if (!newRun[0]) { 120 - throw new HTTPException(400, { message: "Something went wrong" }); 121 - } 122 - 123 - const allResult = []; 124 - for (const region of parseMonitor.data.regions) { 125 - const status = 126 - monitorStatus.data.find((m) => region === m.region)?.status || "active"; 127 - // Trigger the monitor 128 - 129 - let payload: 130 - | z.infer<typeof httpPayloadSchema> 131 - | z.infer<typeof tpcPayloadSchema> 132 - | null = null; 133 - // 134 - if (row.jobType === "http") { 135 - payload = { 136 - workspaceId: String(row.workspaceId), 137 - monitorId: String(row.id), 138 - url: row.url, 139 - method: row.method || "GET", 140 - cronTimestamp: timestamp, 141 - body: row.body, 142 - headers: row.headers, 143 - status: status, 144 - assertions: row.assertions ? JSON.parse(row.assertions) : null, 145 - degradedAfter: row.degradedAfter, 146 - timeout: row.timeout, 147 - trigger: "api", 148 - }; 149 - } 150 - if (row.jobType === "tcp") { 151 - payload = { 152 - workspaceId: String(row.workspaceId), 153 - monitorId: String(row.id), 154 - uri: row.url, 155 - status: status, 156 - assertions: row.assertions ? JSON.parse(row.assertions) : null, 157 - cronTimestamp: timestamp, 158 - degradedAfter: row.degradedAfter, 159 - timeout: row.timeout, 160 - trigger: "api", 161 - }; 162 - } 163 - 164 - if (!payload) { 165 - throw new Error("Invalid jobType"); 166 - } 167 - const url = generateUrl({ row }); 168 - const result = fetch(url, { 169 - headers: { 170 - "Content-Type": "application/json", 171 - "fly-prefer-region": region, // Specify the region you want the request to be sent to 172 - Authorization: `Basic ${env.CRON_SECRET}`, 173 - }, 174 - method: "POST", 175 - body: JSON.stringify(payload), 176 - }); 177 - allResult.push(result); 178 - } 179 - 180 - await Promise.all(allResult); 181 - 182 - return c.json({ resultId: newRun[0].id }, 200); 183 - }); 184 - } 185 - 186 - function generateUrl({ row }: { row: z.infer<typeof selectMonitorSchema> }) { 187 - switch (row.jobType) { 188 - case "http": 189 - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}&trigger=api&data=false`; 190 - case "tcp": 191 - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}&trigger=api&data=false`; 192 - default: 193 - throw new Error("Invalid jobType"); 194 - } 195 - }
+3 -3
apps/server/src/v1/monitors/utils.ts apps/server/src/routes/v1/monitors/utils.ts
··· 1 - import type { z } from "zod"; 2 - import type { Assertion } from "../../../../../packages/assertions/src"; 1 + import type { Assertion } from "@openstatus/assertions"; 3 2 import { 4 3 HeaderAssertion, 5 4 StatusAssertion, 6 5 TextBodyAssertion, 7 - } from "../../../../../packages/assertions/src/v1"; 6 + } from "@openstatus/assertions"; 7 + import type { z } from "zod"; 8 8 import type { assertion } from "./schema"; 9 9 10 10 export const getAssertions = (
+10 -11
apps/server/src/v1/notifications/get.ts apps/server/src/routes/v1/notifications/get.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 3 4 import { and, db, eq } from "@openstatus/db"; 4 5 import { 5 6 notification, 6 7 notificationsToMonitors, 7 - page, 8 8 } from "@openstatus/db/src/schema"; 9 - import { HTTPException } from "hono/http-exception"; 10 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 11 9 import type { notificationsApi } from "./index"; 12 10 import { NotificationSchema, ParamsSchema } from "./schema"; 13 11 14 12 const getRoute = createRoute({ 15 13 method: "get", 16 14 tags: ["notification"], 17 - description: "Get a notification", 15 + summary: "Get a notification", 18 16 path: "/:id", 19 17 request: { 20 18 params: ParamsSchema, ··· 34 32 35 33 export function registerGetNotification(api: typeof notificationsApi) { 36 34 return api.openapi(getRoute, async (c) => { 37 - const workspaceId = c.get("workspaceId"); 35 + const workspaceId = c.get("workspace").id; 38 36 const { id } = c.req.valid("param"); 39 37 40 38 const _notification = await db ··· 42 40 .from(notification) 43 41 .where( 44 42 and( 45 - eq(page.workspaceId, Number(workspaceId)), 43 + eq(notification.workspaceId, workspaceId), 46 44 eq(notification.id, Number(id)), 47 45 ), 48 46 ) 49 47 .get(); 50 48 51 49 if (!_notification) { 52 - throw new HTTPException(404, { message: "Not Found" }); 50 + throw new OpenStatusApiError({ 51 + code: "NOT_FOUND", 52 + message: `Notification ${id} not found`, 53 + }); 53 54 } 54 55 55 - const linkedMonitors = await db 56 + const _monitors = await db 56 57 .select() 57 58 .from(notificationsToMonitors) 58 59 .where(eq(notificationsToMonitors.notificationId, Number(id))) 59 60 .all(); 60 - 61 - const monitors = linkedMonitors.map((m) => m.monitorId); 62 61 63 62 const data = NotificationSchema.parse({ 64 63 ..._notification, 65 64 payload: JSON.parse(_notification.data || "{}"), 66 - monitors, 65 + monitors: _monitors.map((m) => m.monitorId), 67 66 }); 68 67 69 68 return c.json(data, 200);
-69
apps/server/src/v1/notifications/get_all.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - 3 - import { and, db, eq } from "@openstatus/db"; 4 - import { 5 - notification, 6 - notificationsToMonitors, 7 - page, 8 - } from "@openstatus/db/src/schema"; 9 - import { HTTPException } from "hono/http-exception"; 10 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 11 - import type { notificationsApi } from "./index"; 12 - import { NotificationSchema } from "./schema"; 13 - 14 - const getAllRoute = createRoute({ 15 - method: "get", 16 - tags: ["notification"], 17 - description: "Get a notification", 18 - path: "/", 19 - 20 - responses: { 21 - 200: { 22 - content: { 23 - "application/json": { 24 - schema: z.array(NotificationSchema), 25 - }, 26 - }, 27 - description: "Get all your workspace notification", 28 - }, 29 - ...openApiErrorResponses, 30 - }, 31 - }); 32 - 33 - export function registerGetAllNotifications(app: typeof notificationsApi) { 34 - return app.openapi(getAllRoute, async (c) => { 35 - const workspaceId = c.get("workspaceId"); 36 - 37 - const _incidents = await db 38 - .select() 39 - .from(notification) 40 - .where(and(eq(page.workspaceId, Number(workspaceId)))) 41 - .all(); 42 - 43 - if (!_incidents) { 44 - throw new HTTPException(404, { message: "Not Found" }); 45 - } 46 - 47 - const data = []; 48 - 49 - for (const _incident of _incidents) { 50 - const linkedMonitors = await db 51 - .select() 52 - .from(notificationsToMonitors) 53 - .where(eq(notificationsToMonitors.notificationId, _incident.id)) 54 - .all(); 55 - 56 - const monitors = linkedMonitors.map((m) => m.monitorId); 57 - 58 - const p = NotificationSchema.parse({ 59 - ..._incidents, 60 - payload: JSON.parse(_incident.data || "{}"), 61 - monitors, 62 - }); 63 - 64 - data.push(p); 65 - } 66 - 67 - return c.json(data, 200); 68 - }); 69 - }
+1 -1
apps/server/src/v1/notifications/index.ts apps/server/src/routes/v1/notifications/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerGetNotification } from "./get"; 6 6 import { registerGetAllNotifications } from "./get_all";
-29
apps/server/src/v1/notifications/notifications.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - 5 - test("Create a notification", async () => { 6 - const data = { 7 - name: "OpenStatus", 8 - provider: "email", 9 - payload: { email: "ping@openstatus.dev" }, 10 - }; 11 - const res = await api.request("/notification", { 12 - method: "POST", 13 - headers: { 14 - "x-openstatus-key": "1", 15 - "content-type": "application/json", 16 - }, 17 - body: JSON.stringify(data), 18 - }); 19 - 20 - const json = await res.json(); 21 - 22 - expect(res.status).toBe(200); 23 - 24 - expect(json).toMatchObject({ 25 - id: expect.any(Number), 26 - provider: "email", 27 - payload: { email: "ping@openstatus.dev" }, 28 - }); 29 - });
+21 -16
apps/server/src/v1/notifications/post.ts apps/server/src/routes/v1/notifications/post.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { trackMiddleware } from "@/libs/middlewares"; 3 5 import { Events } from "@openstatus/analytics"; 4 6 import { and, db, eq, inArray, isNull, sql } from "@openstatus/db"; 5 7 import { ··· 9 11 notificationsToMonitors, 10 12 selectNotificationSchema, 11 13 } from "@openstatus/db/src/schema"; 12 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 13 - import { HTTPException } from "hono/http-exception"; 14 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 15 - import { trackMiddleware } from "../middleware"; 16 14 import type { notificationsApi } from "./index"; 17 15 import { NotificationSchema } from "./schema"; 18 16 19 17 const postRoute = createRoute({ 20 18 method: "post", 21 19 tags: ["notification"], 22 - description: "Create a notification", 20 + summary: "Create a notification", 23 21 path: "/", 24 22 middleware: [trackMiddleware(Events.CreateNotification)], 25 23 request: { ··· 47 45 48 46 export function registerPostNotification(api: typeof notificationsApi) { 49 47 return api.openapi(postRoute, async (c) => { 50 - const workspaceId = c.get("workspaceId"); 51 - const workspacePlan = c.get("workspacePlan"); 52 - const limits = c.get("limits"); 48 + const workspaceId = c.get("workspace").id; 49 + const workspacePlan = c.get("workspace").plan; 50 + const limits = c.get("workspace").limits; 53 51 const input = c.req.valid("json"); 54 52 55 - if (input.provider === "sms" && workspacePlan.title === "Hobby") { 56 - throw new HTTPException(403, { message: "Upgrade for SMS" }); 53 + if (input.provider === "sms" && workspacePlan === "free") { 54 + throw new OpenStatusApiError({ 55 + code: "PAYMENT_REQUIRED", 56 + message: "Upgrade for SMS", 57 + }); 57 58 } 58 59 59 60 const count = ( 60 61 await db 61 62 .select({ count: sql<number>`count(*)` }) 62 63 .from(notification) 63 - .where(eq(notification.workspaceId, Number(workspaceId))) 64 + .where(eq(notification.workspaceId, workspaceId)) 64 65 .all() 65 66 )[0].count; 66 67 67 - if (count >= getLimit(limits, "notification-channels")) { 68 - throw new HTTPException(403, { 68 + if (count >= limits["notification-channels"]) { 69 + throw new OpenStatusApiError({ 70 + code: "PAYMENT_REQUIRED", 69 71 message: "Upgrade for more notification channels", 70 72 }); 71 73 } ··· 79 81 .where( 80 82 and( 81 83 inArray(monitor.id, monitors), 82 - eq(monitor.workspaceId, Number(workspaceId)), 84 + eq(monitor.workspaceId, workspaceId), 83 85 isNull(monitor.deletedAt), 84 86 ), 85 87 ) 86 88 .all(); 87 89 88 90 if (_monitors.length !== monitors.length) { 89 - throw new HTTPException(400, { message: "Monitor not found" }); 91 + throw new OpenStatusApiError({ 92 + code: "BAD_REQUEST", 93 + message: `Some of the monitors ${monitors.join(", ")} not found`, 94 + }); 90 95 } 91 96 } 92 97 ··· 94 99 .insert(notification) 95 100 .values({ 96 101 ...rest, 97 - workspaceId: Number(workspaceId), 102 + workspaceId: workspaceId, 98 103 data: JSON.stringify(payload), 99 104 }) 100 105 .returning()
-45
apps/server/src/v1/notifications/schema.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - import { 3 - NotificationDataSchema, 4 - notificationProviderSchema, 5 - } from "@openstatus/db/src/schema"; 6 - 7 - export const ParamsSchema = z.object({ 8 - id: z 9 - .string() 10 - .min(1) 11 - .openapi({ 12 - param: { 13 - name: "id", 14 - in: "path", 15 - }, 16 - description: "The id of the notification", 17 - example: "1", 18 - }), 19 - }); 20 - 21 - export const NotificationSchema = z.object({ 22 - id: z 23 - .number() 24 - .openapi({ description: "The id of the notification", example: 1 }), 25 - name: z.string().openapi({ 26 - description: "The name of the notification", 27 - example: "OpenStatus Discord", 28 - }), 29 - provider: notificationProviderSchema.openapi({ 30 - description: "The provider of the notification", 31 - example: "discord", 32 - }), 33 - payload: NotificationDataSchema.openapi({ 34 - description: "The data of the notification", 35 - }), 36 - monitors: z 37 - .array(z.number()) 38 - .openapi({ 39 - description: "The monitors that the notification is linked to", 40 - example: [1, 2], 41 - }) 42 - .nullish(), 43 - }); 44 - 45 - export type NotificationSchema = z.infer<typeof NotificationSchema>;
+1 -1
apps/server/src/v1/pageSubscribers/index.ts apps/server/src/routes/v1/pageSubscribers/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerPostPageSubscriber } from "./post"; 6 6
+35 -29
apps/server/src/v1/pageSubscribers/post.ts apps/server/src/routes/v1/pageSubscribers/post.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { trackMiddleware } from "@/libs/middlewares"; 5 + import { Events } from "@openstatus/analytics"; 3 6 import { and, eq } from "@openstatus/db"; 4 7 import { db } from "@openstatus/db/src/db"; 5 8 import { page, pageSubscriber } from "@openstatus/db/src/schema"; 6 9 import { SubscribeEmail } from "@openstatus/emails"; 7 10 import { sendEmail } from "@openstatus/emails/src/send"; 8 - import { HTTPException } from "hono/http-exception"; 9 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 10 11 import type { pageSubscribersApi } from "./index"; 11 12 import { PageSubscriberSchema, ParamsSchema } from "./schema"; 12 13 13 14 const postRouteSubscriber = createRoute({ 14 15 method: "post", 15 - tags: ["page"], 16 + tags: ["page_subscriber"], 17 + summary: "Subscribe to a status page", 16 18 path: "/:id/update", 17 - description: "Add a subscriber to a status page", 19 + middleware: [trackMiddleware(Events.SubscribePage)], 20 + description: "Add a subscriber to a status page", // TODO: how to define legacy routes 18 21 request: { 19 22 params: ParamsSchema, 20 23 body: { 21 - description: "the subscriber payload", 24 + description: "The subscriber payload", 22 25 content: { 23 26 "application/json": { 24 - schema: PageSubscriberSchema, 27 + schema: PageSubscriberSchema.pick({ email: true }), 25 28 }, 26 29 }, 27 30 }, ··· 33 36 schema: PageSubscriberSchema, 34 37 }, 35 38 }, 36 - description: "The user", 39 + description: "The user has been subscribed", 37 40 }, 38 41 ...openApiErrorResponses, 39 42 }, ··· 41 44 42 45 export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { 43 46 return api.openapi(postRouteSubscriber, async (c) => { 44 - const workspaceId = c.get("workspaceId"); 45 - const limits = c.get("limits"); 47 + const workspaceId = c.get("workspace").id; 48 + const limits = c.get("workspace").limits; 46 49 const input = c.req.valid("json"); 47 50 const { id } = c.req.valid("param"); 48 51 49 52 if (!limits["status-subscribers"]) { 50 - throw new HTTPException(403, { 51 - message: "Upgrade for status page subscribers", 53 + throw new OpenStatusApiError({ 54 + code: "PAYMENT_REQUIRED", 55 + message: "Upgrade for status subscribers", 52 56 }); 53 57 } 54 58 55 59 const _page = await db 56 60 .select() 57 61 .from(page) 58 - .where( 59 - and(eq(page.id, Number(id)), eq(page.workspaceId, Number(workspaceId))), 60 - ) 62 + .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) 61 63 .get(); 62 64 63 65 if (!_page) { 64 - throw new HTTPException(401, { message: "Unauthorized" }); 66 + throw new OpenStatusApiError({ 67 + code: "NOT_FOUND", 68 + message: `Page ${id} not found`, 69 + }); 65 70 } 66 71 67 72 const alreadySubscribed = await db ··· 76 81 .get(); 77 82 78 83 if (alreadySubscribed) { 79 - throw new HTTPException(400, { 80 - message: "Bad request - Already subscribed", 84 + throw new OpenStatusApiError({ 85 + code: "CONFLICT", 86 + message: `Email ${input.email} already subscribed`, 81 87 }); 82 88 } 83 89 84 - const token = (Math.random() + 1).toString(36).substring(10); 90 + const token = crypto.randomUUID(); 85 91 const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); 86 92 87 - await sendEmail({ 88 - react: SubscribeEmail({ 89 - domain: _page.slug, 90 - token, 91 - page: _page.title, 92 - }), 93 - from: "OpenStatus <notification@notifications.openstatus.dev>", 94 - to: [input.email], 95 - subject: "Verify your subscription", 96 - }); 97 - 98 93 const _statusReportSubscriberUpdate = await db 99 94 .insert(pageSubscriber) 100 95 .values({ ··· 105 100 }) 106 101 .returning() 107 102 .get(); 103 + 104 + await sendEmail({ 105 + react: SubscribeEmail({ 106 + domain: _page.slug, 107 + token, 108 + page: _page.title, 109 + }), 110 + from: "OpenStatus <notification@notifications.openstatus.dev>", 111 + to: [input.email], 112 + subject: "Verify your subscription", 113 + }); 108 114 109 115 const data = PageSubscriberSchema.parse(_statusReportSubscriberUpdate); 110 116
-22
apps/server/src/v1/pageSubscribers/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 page", 13 - example: "1", 14 - }), 15 - }); 16 - export const PageSubscriberSchema = z.object({ 17 - email: z.string().email().openapi({ 18 - description: "The email of the subscriber", 19 - }), 20 - }); 21 - 22 - export type PageSubscriberSchema = z.infer<typeof PageSubscriberSchema>;
+8 -8
apps/server/src/v1/pages/get.ts apps/server/src/routes/v1/pages/get.ts
··· 1 1 import { createRoute, z } from "@hono/zod-openapi"; 2 2 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 3 4 import { and, eq } from "@openstatus/db"; 4 5 import { db } from "@openstatus/db/src/db"; 5 6 import { page } from "@openstatus/db/src/schema"; 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 8 7 import type { pagesApi } from "./index"; 9 8 import { PageSchema, ParamsSchema } from "./schema"; 10 9 11 10 const getRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["page"], 14 - description: "Get a status page", 13 + summary: "Get a status page", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 31 30 32 31 export function registerGetPage(api: typeof pagesApi) { 33 32 return api.openapi(getRoute, async (c) => { 34 - const workspaceId = c.get("workspaceId"); 33 + const workspaceId = c.get("workspace").id; 35 34 const { id } = c.req.valid("param"); 36 35 37 36 const _page = await db 38 37 .select() 39 38 .from(page) 40 - .where( 41 - and(eq(page.workspaceId, Number(workspaceId)), eq(page.id, Number(id))), 42 - ) 39 + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id)))) 43 40 .get(); 44 41 45 42 if (!_page) { 46 - throw new HTTPException(404, { message: "Not Found" }); 43 + throw new OpenStatusApiError({ 44 + code: "NOT_FOUND", 45 + message: `Page ${id} not found`, 46 + }); 47 47 } 48 48 49 49 const data = PageSchema.parse(_page);
-45
apps/server/src/v1/pages/get_all.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - 3 - import { db, eq } from "@openstatus/db"; 4 - import { page } from "@openstatus/db/src/schema"; 5 - import { HTTPException } from "hono/http-exception"; 6 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 7 - import type { pagesApi } from "./index"; 8 - import { PageSchema } from "./schema"; 9 - 10 - const getAllRoute = createRoute({ 11 - method: "get", 12 - tags: ["page"], 13 - description: "Get all your status page", 14 - path: "/", 15 - responses: { 16 - 200: { 17 - content: { 18 - "application/json": { 19 - schema: z.array(PageSchema), 20 - }, 21 - }, 22 - description: "Get an Status page", 23 - }, 24 - ...openApiErrorResponses, 25 - }, 26 - }); 27 - 28 - export function registerGetAllPages(api: typeof pagesApi) { 29 - return api.openapi(getAllRoute, async (c) => { 30 - const workspaceId = c.get("workspaceId"); 31 - 32 - const _pages = await db 33 - .select() 34 - .from(page) 35 - .where(eq(page.workspaceId, Number(workspaceId))); 36 - 37 - if (!_pages) { 38 - throw new HTTPException(404, { message: "Not Found" }); 39 - } 40 - 41 - const data = z.array(PageSchema).parse(_pages); 42 - 43 - return c.json(data, 200); 44 - }); 45 - }
+1 -1
apps/server/src/v1/pages/index.ts apps/server/src/routes/v1/pages/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerGetPage } from "./get"; 6 6 import { registerGetAllPages } from "./get_all";
-112
apps/server/src/v1/pages/pages.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - 5 - test("Create a page", async () => { 6 - const data = { 7 - title: "OpenStatus", 8 - description: "OpenStatus website", 9 - slug: "openstatus", 10 - }; 11 - const res = await api.request("/page", { 12 - method: "POST", 13 - headers: { 14 - "x-openstatus-key": "1", 15 - "content-type": "application/json", 16 - }, 17 - body: JSON.stringify(data), 18 - }); 19 - expect(res.status).toBe(200); 20 - 21 - expect(await res.json()).toMatchObject({ 22 - id: expect.any(Number), 23 - title: "OpenStatus", 24 - description: "OpenStatus website", 25 - slug: "openstatus", 26 - }); 27 - }); 28 - 29 - test("Create a page with monitors", async () => { 30 - const data = { 31 - title: "OpenStatus", 32 - description: "OpenStatus website", 33 - slug: "new-openstatus", 34 - monitors: [1, 2], 35 - }; 36 - const res = await api.request("/page", { 37 - method: "POST", 38 - headers: { 39 - "x-openstatus-key": "1", 40 - "content-type": "application/json", 41 - }, 42 - body: JSON.stringify(data), 43 - }); 44 - 45 - const json = await res.json(); 46 - 47 - expect(res.status).toBe(200); 48 - 49 - expect(json).toMatchObject({ 50 - id: expect.any(Number), 51 - title: "OpenStatus", 52 - description: "OpenStatus website", 53 - slug: "new-openstatus", 54 - }); 55 - }); 56 - 57 - test("Update a page with monitors as object including order", async () => { 58 - const data = { 59 - monitors: [ 60 - { monitorId: 1, order: 0 }, 61 - { monitorId: 2, order: 1 }, 62 - ], 63 - }; 64 - const res = await api.request("/page/3", { 65 - method: "PUT", 66 - headers: { 67 - "x-openstatus-key": "1", 68 - "content-type": "application/json", 69 - }, 70 - body: JSON.stringify(data), 71 - }); 72 - 73 - const json = await res.json(); 74 - 75 - expect(res.status).toBe(200); 76 - 77 - expect(json).toMatchObject({ 78 - id: 3, 79 - }); 80 - }); 81 - 82 - test("Create a page without auth key should return 401", async () => { 83 - const data = { 84 - title: "OpenStatus", 85 - description: "OpenStatus website", 86 - slug: "openstatus", 87 - }; 88 - const res = await api.request("/page", { 89 - method: "POST", 90 - headers: { 91 - "content-type": "application/json", 92 - }, 93 - body: JSON.stringify(data), 94 - }); 95 - expect(res.status).toBe(401); 96 - }); 97 - 98 - test("Create a page with invalid data should return 403", async () => { 99 - const data = { 100 - description: "OpenStatus website", 101 - slug: "openstatus", 102 - }; 103 - const res = await api.request("/page", { 104 - method: "POST", 105 - headers: { 106 - "x-openstatus-key": "1", 107 - "content-type": "application/json", 108 - }, 109 - body: JSON.stringify(data), 110 - }); 111 - expect(res.status).toBe(400); 112 - });
+38 -20
apps/server/src/v1/pages/post.ts apps/server/src/routes/v1/pages/post.ts
··· 2 2 3 3 import { and, eq, inArray, isNull, sql } from "@openstatus/db"; 4 4 import { db } from "@openstatus/db/src/db"; 5 - import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; 5 + import { 6 + monitor, 7 + monitorsToPages, 8 + page, 9 + subdomainSafeList, 10 + } from "@openstatus/db/src/schema"; 6 11 12 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 13 + import { trackMiddleware } from "@/libs/middlewares"; 7 14 import { Events } from "@openstatus/analytics"; 8 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 9 - import { HTTPException } from "hono/http-exception"; 10 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 11 - import { trackMiddleware } from "../middleware"; 12 15 import { isNumberArray } from "../utils"; 13 16 import type { pagesApi } from "./index"; 14 17 import { PageSchema } from "./schema"; ··· 16 19 const postRoute = createRoute({ 17 20 method: "post", 18 21 tags: ["page"], 19 - description: "Create a status page", 22 + summary: "Create a status page", 20 23 path: "/", 21 24 middleware: [trackMiddleware(Events.CreatePage, ["slug"])], 22 25 request: { ··· 44 47 45 48 export function registerPostPage(api: typeof pagesApi) { 46 49 return api.openapi(postRoute, async (c) => { 47 - const workspaceId = c.get("workspaceId"); 48 - const limits = c.get("limits"); 50 + const workspaceId = c.get("workspace").id; 51 + const limits = c.get("workspace").limits; 49 52 const input = c.req.valid("json"); 50 53 51 54 if (input.customDomain && !limits["custom-domain"]) { 52 - throw new HTTPException(403, { 55 + throw new OpenStatusApiError({ 56 + code: "PAYMENT_REQUIRED", 53 57 message: "Upgrade for custom domains", 54 58 }); 55 59 } 56 60 57 61 if (input.customDomain?.toLowerCase().includes("openstatus")) { 58 - throw new HTTPException(400, { 62 + throw new OpenStatusApiError({ 63 + code: "BAD_REQUEST", 59 64 message: "Domain cannot contain 'openstatus'", 60 65 }); 61 66 } ··· 64 69 await db 65 70 .select({ count: sql<number>`count(*)` }) 66 71 .from(page) 67 - .where(eq(page.workspaceId, Number(workspaceId))) 72 + .where(eq(page.workspaceId, workspaceId)) 68 73 .all() 69 74 )[0].count; 70 75 71 - if (count >= getLimit(limits, "status-pages")) { 72 - throw new HTTPException(403, { 76 + if (count >= limits["status-pages"]) { 77 + throw new OpenStatusApiError({ 78 + code: "PAYMENT_REQUIRED", 73 79 message: "Upgrade for more status pages", 74 80 }); 75 81 } 76 82 77 83 if ( 78 - getLimit(limits, "password-protection") === false && 79 - input?.passwordProtected === true 84 + !limits["password-protection"] && 85 + (input?.passwordProtected || input?.password) 80 86 ) { 81 - throw new HTTPException(403, { 87 + throw new OpenStatusApiError({ 88 + code: "PAYMENT_REQUIRED", 82 89 message: "Upgrade for password protection", 83 90 }); 84 91 } 85 92 93 + if (subdomainSafeList.includes(input.slug)) { 94 + throw new OpenStatusApiError({ 95 + code: "BAD_REQUEST", 96 + message: "Slug is reserved", 97 + }); 98 + } 99 + 86 100 const countSlug = ( 87 101 await db 88 102 .select({ count: sql<number>`count(*)` }) ··· 92 106 )[0].count; 93 107 94 108 if (countSlug > 0) { 95 - throw new HTTPException(409, { 109 + throw new OpenStatusApiError({ 110 + code: "BAD_REQUEST", 96 111 message: "Slug has to be unique and has already been taken", 97 112 }); 98 113 } ··· 110 125 .where( 111 126 and( 112 127 inArray(monitor.id, monitorIds), 113 - eq(monitor.workspaceId, Number(workspaceId)), 128 + eq(monitor.workspaceId, workspaceId), 114 129 isNull(monitor.deletedAt), 115 130 ), 116 131 ) 117 132 .all(); 118 133 119 134 if (_monitors.length !== monitors.length) { 120 - throw new HTTPException(400, { message: "Monitor not found" }); 135 + throw new OpenStatusApiError({ 136 + code: "BAD_REQUEST", 137 + message: `Some of the monitors ${monitorIds.join(", ")} not found`, 138 + }); 121 139 } 122 140 } 123 141 ··· 125 143 .insert(page) 126 144 .values({ 127 145 ...rest, 128 - workspaceId: Number(workspaceId), 146 + workspaceId: workspaceId, 129 147 customDomain: rest.customDomain ?? "", // TODO: make database migration to allow null 130 148 }) 131 149 .returning()
+48 -24
apps/server/src/v1/pages/put.ts apps/server/src/routes/v1/pages/put.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 + import { trackMiddleware } from "@/libs/middlewares"; 3 5 import { Events } from "@openstatus/analytics"; 4 6 import { and, eq, inArray, isNull, sql } from "@openstatus/db"; 5 7 import { db } from "@openstatus/db/src/db"; 6 - import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; 7 - import { HTTPException } from "hono/http-exception"; 8 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 - import { trackMiddleware } from "../middleware"; 8 + import { 9 + monitor, 10 + monitorsToPages, 11 + page, 12 + subdomainSafeList, 13 + } from "@openstatus/db/src/schema"; 10 14 import { isNumberArray } from "../utils"; 11 15 import type { pagesApi } from "./index"; 12 16 import { PageSchema, ParamsSchema } from "./schema"; ··· 14 18 const putRoute = createRoute({ 15 19 method: "put", 16 20 tags: ["page"], 17 - description: "Update a status page", 21 + summary: "Update a status page", 18 22 path: "/:id", 19 23 middleware: [trackMiddleware(Events.UpdatePage)], 20 24 request: { ··· 23 27 description: "The monitor to update", 24 28 content: { 25 29 "application/json": { 26 - // REMINDER: allow only partial updates 27 30 schema: PageSchema.omit({ id: true }).partial(), 28 31 }, 29 32 }, ··· 44 47 45 48 export function registerPutPage(api: typeof pagesApi) { 46 49 return api.openapi(putRoute, async (c) => { 47 - const workspaceId = c.get("workspaceId"); 48 - const limits = c.get("limits"); 50 + const workspaceId = c.get("workspace").id; 51 + const limits = c.get("workspace").limits; 49 52 const { id } = c.req.valid("param"); 50 53 const input = c.req.valid("json"); 51 54 52 55 if (input.customDomain && !limits["custom-domain"]) { 53 - throw new HTTPException(403, { 54 - message: "Upgrade for custom domains", 56 + throw new OpenStatusApiError({ 57 + code: "PAYMENT_REQUIRED", 58 + message: "Upgrade for custom domain", 55 59 }); 56 60 } 57 61 58 62 if (input.customDomain?.toLowerCase().includes("openstatus")) { 59 - throw new HTTPException(400, { 63 + throw new OpenStatusApiError({ 64 + code: "BAD_REQUEST", 60 65 message: "Domain cannot contain 'openstatus'", 61 66 }); 62 67 } ··· 65 70 limits["password-protection"] === false && 66 71 input?.passwordProtected === true 67 72 ) { 68 - throw new HTTPException(403, { 69 - message: "Forbidden - Upgrade for password protection", 73 + throw new OpenStatusApiError({ 74 + code: "PAYMENT_REQUIRED", 75 + message: "Upgrade for password protection", 70 76 }); 71 77 } 72 78 73 79 const _page = await db 74 80 .select() 75 81 .from(page) 76 - .where( 77 - and(eq(page.id, Number(id)), eq(page.workspaceId, Number(workspaceId))), 78 - ) 82 + .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) 79 83 .get(); 80 84 81 85 if (!_page) { 82 - throw new HTTPException(404, { message: "Not Found" }); 86 + throw new OpenStatusApiError({ 87 + code: "NOT_FOUND", 88 + message: `Page ${id} not found`, 89 + }); 83 90 } 84 91 85 92 if (input.slug && _page.slug !== input.slug) { 93 + if (subdomainSafeList.includes(input.slug)) { 94 + throw new OpenStatusApiError({ 95 + code: "BAD_REQUEST", 96 + message: "Slug is reserved", 97 + }); 98 + } 99 + 86 100 const countSlug = ( 87 101 await db 88 102 .select({ count: sql<number>`count(*)` }) ··· 92 106 )[0].count; 93 107 94 108 if (countSlug > 0) { 95 - throw new HTTPException(400, { 96 - message: "Forbidden - Slug already taken", 109 + throw new OpenStatusApiError({ 110 + code: "CONFLICT", 111 + message: "Slug has to be unique and has already been taken", 97 112 }); 98 113 } 99 114 } 115 + 100 116 const { monitors, ...rest } = input; 101 117 102 118 const monitorIds = monitors ··· 112 128 .where( 113 129 and( 114 130 inArray(monitor.id, monitorIds), 115 - eq(monitor.workspaceId, Number(workspaceId)), 131 + eq(monitor.workspaceId, workspaceId), 116 132 isNull(monitor.deletedAt), 117 133 ), 118 134 ) 119 135 .all(); 120 136 121 137 if (monitorsData.length !== monitors.length) { 122 - throw new HTTPException(400, { 123 - message: "Not Found - Wrong monitor configuration", 138 + throw new OpenStatusApiError({ 139 + code: "BAD_REQUEST", 140 + message: `Some of the monitors ${monitorIds.join(", ")} not found`, 124 141 }); 125 142 } 126 143 } 127 144 128 145 const newPage = await db 129 146 .update(page) 130 - .set({ ...rest, customDomain: input.customDomain ?? "" }) 147 + .set({ 148 + ...rest, 149 + customDomain: input.customDomain ?? "", 150 + updatedAt: new Date(), 151 + }) 131 152 .where(eq(page.id, _page.id)) 132 153 .returning() 133 154 .get(); ··· 167 188 } 168 189 } 169 190 170 - const data = PageSchema.parse(newPage); 191 + const data = PageSchema.parse({ 192 + ...newPage, 193 + monitors: monitors || currentMonitorsToPages, 194 + }); 171 195 172 196 return c.json(data, 200); 173 197 });
-98
apps/server/src/v1/pages/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 page", 13 - example: "1", 14 - }), 15 - }); 16 - 17 - export const PageSchema = z.object({ 18 - id: z.number().openapi({ 19 - description: "The id of the page", 20 - example: 1, 21 - }), 22 - title: z.string().openapi({ 23 - description: "The title of the page", 24 - example: "My Page", 25 - }), 26 - description: z.string().openapi({ 27 - description: "The description of the page", 28 - example: "My awesome status page", 29 - }), 30 - slug: z.string().openapi({ 31 - description: "The slug of the page", 32 - example: "my-page", 33 - }), 34 - // REMINDER: needs to be configured on Dashboard UI 35 - customDomain: z 36 - .string() 37 - .openapi({ 38 - description: 39 - "The custom domain of the page. To be configured within the dashboard.", 40 - example: "status.acme.com", 41 - }) 42 - .transform((val) => (val ? val : undefined)) 43 - .nullish(), 44 - icon: z 45 - .string() 46 - .openapi({ 47 - description: "The icon of the page", 48 - example: "https://example.com/icon.png", 49 - }) 50 - .url() 51 - .or(z.literal("")) 52 - .transform((val) => (val ? val : undefined)) 53 - .nullish(), 54 - passwordProtected: z 55 - .boolean() 56 - .openapi({ 57 - description: 58 - "Make the page password protected. Used with the 'passwordProtected' property.", 59 - example: true, 60 - }) 61 - .default(false) 62 - .optional(), 63 - password: z 64 - .string() 65 - .openapi({ 66 - description: "Your password to protect the page from the publi", 67 - example: "hidden-password", 68 - }) 69 - .optional() 70 - .nullish(), 71 - showMonitorValues: z 72 - .boolean() 73 - .openapi({ 74 - description: 75 - "Displays the total and failed request numbers for each monitor", 76 - example: true, 77 - }) 78 - .optional() 79 - .nullish(), 80 - monitors: z 81 - .array(z.number()) 82 - .openapi({ 83 - description: "The monitors of the page as an array of ids", 84 - example: [1, 2], 85 - }) 86 - .or( 87 - z.array(z.object({ monitorId: z.number(), order: z.number() })).openapi({ 88 - description: "The monitor as object allowing to pass id and order", 89 - example: [ 90 - { monitorId: 1, order: 0 }, 91 - { monitorId: 2, order: 1 }, 92 - ], 93 - }), 94 - ) 95 - .optional(), 96 - }); 97 - 98 - export type PageSchema = z.infer<typeof PageSchema>;
+11 -9
apps/server/src/v1/statusReportUpdates/get.ts apps/server/src/routes/v1/statusReportUpdates/get.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 7 import type { statusReportUpdatesApi } from "./index"; 9 8 import { ParamsSchema, StatusReportUpdateSchema } from "./schema"; 10 9 11 10 const getRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["status_report_update"], 14 - description: "Get a Status Reports Update", 13 + summary: "Get a status report update", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 33 32 api: typeof statusReportUpdatesApi, 34 33 ) { 35 34 return api.openapi(getRoute, async (c) => { 36 - const workspaceId = c.get("workspaceId"); 35 + const workspaceId = c.get("workspace").id; 37 36 const { id } = c.req.valid("param"); 38 37 39 - const _statusReportJoin = await db 38 + const _statusReport = await db 40 39 .select() 41 40 .from(statusReportUpdate) 42 41 .innerJoin( 43 42 statusReport, 44 43 and( 45 44 eq(statusReport.id, statusReportUpdate.statusReportId), 46 - eq(statusReport.workspaceId, Number(workspaceId)), 45 + eq(statusReport.workspaceId, workspaceId), 47 46 ), 48 47 ) 49 48 .where(eq(statusReportUpdate.id, Number(id))) 50 49 .get(); 51 50 52 - if (!_statusReportJoin) { 53 - throw new HTTPException(404, { message: "Not Found" }); 51 + if (!_statusReport) { 52 + throw new OpenStatusApiError({ 53 + code: "NOT_FOUND", 54 + message: `Status Report Update ${id} not found`, 55 + }); 54 56 } 55 57 56 58 const data = StatusReportUpdateSchema.parse( 57 - _statusReportJoin.status_report_update, 59 + _statusReport.status_report_update, 58 60 ); 59 61 60 62 return c.json(data, 200);
+1 -1
apps/server/src/v1/statusReportUpdates/index.ts apps/server/src/routes/v1/statusReportUpdates/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerGetStatusReportUpdate } from "./get"; 6 6 import { registerPostStatusReportUpdate } from "./post";
+10 -13
apps/server/src/v1/statusReportUpdates/post.ts apps/server/src/routes/v1/statusReportUpdates/post.ts
··· 9 9 } from "@openstatus/db/src/schema"; 10 10 import { sendEmailHtml } from "@openstatus/emails"; 11 11 12 - import { HTTPException } from "hono/http-exception"; 13 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 12 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 14 13 import type { statusReportUpdatesApi } from "./index"; 15 14 import { StatusReportUpdateSchema } from "./schema"; 16 15 17 16 const createStatusUpdate = createRoute({ 18 17 method: "post", 19 18 tags: ["status_report_update"], 20 - description: "Create a Status Report Update", 19 + summary: "Create a status report update", 21 20 path: "/", 22 21 request: { 23 22 body: { 24 - description: "the status report update", 23 + description: "The status report update to create", 25 24 content: { 26 25 "application/json": { 27 26 schema: StatusReportUpdateSchema.omit({ id: true }), ··· 36 35 schema: StatusReportUpdateSchema, 37 36 }, 38 37 }, 39 - description: "Get all status report updates", 38 + description: "The created status report update", 40 39 }, 41 40 ...openApiErrorResponses, 42 41 }, ··· 46 45 api: typeof statusReportUpdatesApi, 47 46 ) { 48 47 return api.openapi(createStatusUpdate, async (c) => { 49 - const workspaceId = c.get("workspaceId"); 48 + const workspaceId = c.get("workspace").id; 50 49 const input = c.req.valid("json"); 51 - const limits = c.get("limits"); 50 + const limits = c.get("workspace").limits; 52 51 53 52 const _statusReport = await db 54 53 .select() ··· 56 55 .where( 57 56 and( 58 57 eq(statusReport.id, input.statusReportId), 59 - eq(statusReport.workspaceId, Number(workspaceId)), 58 + eq(statusReport.workspaceId, workspaceId), 60 59 ), 61 60 ) 62 61 .get(); 63 62 64 63 if (!_statusReport) { 65 - throw new HTTPException(404, { 66 - message: 67 - "Not Found - Status report id does not exist within your workspace", 64 + throw new OpenStatusApiError({ 65 + code: "NOT_FOUND", 66 + message: `Status Report ${input.statusReportId} not found`, 68 67 }); 69 68 } 70 69 ··· 77 76 }) 78 77 .returning() 79 78 .get(); 80 - 81 - // send email 82 79 83 80 if (limits["status-subscribers"] && _statusReport.pageId) { 84 81 const subscribers = await db
-37
apps/server/src/v1/statusReportUpdates/schema.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - 3 - import { statusReportStatus } from "@openstatus/db/src/schema"; 4 - 5 - import { isoDate } from "../utils"; 6 - 7 - export const ParamsSchema = z.object({ 8 - id: z 9 - .string() 10 - .min(1) 11 - .openapi({ 12 - param: { 13 - name: "id", 14 - in: "path", 15 - }, 16 - description: "The id of the update", 17 - example: "1", 18 - }), 19 - }); 20 - 21 - export const StatusReportUpdateSchema = z.object({ 22 - id: z.coerce.string().openapi({ description: "The id of the update" }), 23 - status: z.enum(statusReportStatus).openapi({ 24 - description: "The status of the update", 25 - }), 26 - date: isoDate.openapi({ 27 - description: "The date of the update in ISO8601 format", 28 - }), 29 - message: z.string().openapi({ 30 - description: "The message of the update", 31 - }), 32 - statusReportId: z.number().openapi({ 33 - description: "The id of the status report", 34 - }), 35 - }); 36 - 37 - export type StatusReportUpdateSchema = z.infer<typeof StatusReportUpdateSchema>;
-75
apps/server/src/v1/statusReportUpdates/statusReportUpdates.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - import { iso8601Regex } from "../test-utils"; 5 - 6 - test("GET one status report update ", async () => { 7 - const res = await api.request("/status_report_update/1", { 8 - headers: { 9 - "x-openstatus-key": "1", 10 - }, 11 - }); 12 - const json = await res.json(); 13 - 14 - expect(res.status).toBe(200); 15 - expect(json).toMatchObject({ 16 - status: "investigating", 17 - message: "Message", 18 - date: expect.stringMatching(iso8601Regex), 19 - }); 20 - }); 21 - 22 - test("create one status report update ", async () => { 23 - const res = await api.request("/status_report_update", { 24 - method: "POST", 25 - headers: { 26 - "x-openstatus-key": "1", 27 - "content-type": "application/json", 28 - }, 29 - body: JSON.stringify({ 30 - status: "investigating", 31 - date: "2023-11-08T21:03:13.000Z", 32 - message: "test", 33 - statusReportId: 1, 34 - }), 35 - }); 36 - expect(res.status).toBe(200); 37 - expect(await res.json()).toMatchObject({ 38 - status: "investigating", 39 - message: "test", 40 - }); 41 - }); 42 - 43 - test("create one status report update without auth key should return 401", async () => { 44 - const res = await api.request("/status_report_update", { 45 - method: "POST", 46 - headers: { 47 - //not passing in the key 48 - "content-type": "application/json", 49 - }, 50 - body: JSON.stringify({ 51 - status: "investigating", 52 - date: expect.stringMatching(iso8601Regex), 53 - message: "test", 54 - statusReportId: 1, 55 - }), 56 - }); 57 - expect(res.status).toBe(401); 58 - }); 59 - 60 - test("create one status report update with invalid data should return 403", async () => { 61 - const res = await api.request("/status_report_update", { 62 - method: "POST", 63 - headers: { 64 - "x-openstatus-key": "1", 65 - "content-type": "application/json", 66 - }, 67 - body: JSON.stringify({ 68 - //incompelete body 69 - status: "investigating", 70 - date: "2023-11-08T21:03:13.000Z", 71 - }), 72 - }); 73 - 74 - expect(res.status).toBe(400); 75 - });
+8 -6
apps/server/src/v1/statusReports/delete.ts apps/server/src/routes/v1/statusReports/delete.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { statusReport } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 7 import type { statusReportsApi } from "./index"; 9 8 import { ParamsSchema } from "./schema"; 10 9 11 10 const deleteRoute = createRoute({ 12 11 method: "delete", 13 12 tags: ["status_report"], 14 - description: "Delete a Status Report", 13 + summary: "Delete a status report", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 31 30 32 31 export function registerDeleteStatusReport(api: typeof statusReportsApi) { 33 32 return api.openapi(deleteRoute, async (c) => { 34 - const workspaceId = c.get("workspaceId"); 33 + const workspaceId = c.get("workspace").id; 35 34 const { id } = c.req.valid("param"); 36 35 37 36 const _statusReport = await db ··· 40 39 .where( 41 40 and( 42 41 eq(statusReport.id, Number(id)), 43 - eq(statusReport.workspaceId, Number(workspaceId)), 42 + eq(statusReport.workspaceId, workspaceId), 44 43 ), 45 44 ) 46 45 .get(); 47 46 48 47 if (!_statusReport) { 49 - throw new HTTPException(404, { message: "Not Found" }); 48 + throw new OpenStatusApiError({ 49 + code: "NOT_FOUND", 50 + message: `Status Report ${id} not found`, 51 + }); 50 52 } 51 53 52 54 await db
+8 -6
apps/server/src/v1/statusReports/get.ts apps/server/src/routes/v1/statusReports/get.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { statusReport } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 7 import type { statusReportsApi } from "./index"; 9 8 import { ParamsSchema, StatusReportSchema } from "./schema"; 10 9 11 10 const getRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["status_report"], 14 - description: "Get a Status Report", 13 + summary: "Get a status report", 15 14 path: "/:id", 16 15 request: { 17 16 params: ParamsSchema, ··· 31 30 32 31 export function regsiterGetStatusReport(api: typeof statusReportsApi) { 33 32 return api.openapi(getRoute, async (c) => { 34 - const workspaceId = c.get("workspaceId"); 33 + const workspaceId = c.get("workspace").id; 35 34 const { id } = c.req.valid("param"); 36 35 37 36 const _statusUpdate = await db.query.statusReport.findFirst({ ··· 40 39 monitorsToStatusReports: true, 41 40 }, 42 41 where: and( 43 - eq(statusReport.workspaceId, Number(workspaceId)), 42 + eq(statusReport.workspaceId, workspaceId), 44 43 eq(statusReport.id, Number(id)), 45 44 ), 46 45 }); 47 46 48 47 if (!_statusUpdate) { 49 - throw new HTTPException(404, { message: "Not Found" }); 48 + throw new OpenStatusApiError({ 49 + code: "NOT_FOUND", 50 + message: `Status Report ${id} not found`, 51 + }); 50 52 } 51 53 52 54 const { statusReportUpdates, monitorsToStatusReports } = _statusUpdate;
+4 -9
apps/server/src/v1/statusReports/get_all.ts apps/server/src/routes/v1/statusReports/get_all.ts
··· 3 3 import { db, eq } from "@openstatus/db"; 4 4 import { statusReport } from "@openstatus/db/src/schema"; 5 5 6 - import { HTTPException } from "hono/http-exception"; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 6 + import { openApiErrorResponses } from "@/libs/errors"; 8 7 import type { statusReportsApi } from "./index"; 9 8 import { StatusReportSchema } from "./schema"; 10 9 11 10 const getAllRoute = createRoute({ 12 11 method: "get", 13 12 tags: ["status_report"], 14 - description: "Get all Status Reports", 13 + summary: "List all status reports", 15 14 path: "/", 16 15 request: {}, 17 16 responses: { ··· 29 28 30 29 export function registerGetAllStatusReports(api: typeof statusReportsApi) { 31 30 return api.openapi(getAllRoute, async (c) => { 32 - const workspaceId = c.get("workspaceId"); 31 + const workspaceId = c.get("workspace").id; 33 32 34 33 const _statusReports = await db.query.statusReport.findMany({ 35 34 with: { 36 35 statusReportUpdates: true, 37 36 monitorsToStatusReports: true, 38 37 }, 39 - where: eq(statusReport.workspaceId, Number(workspaceId)), 38 + where: eq(statusReport.workspaceId, workspaceId), 40 39 }); 41 - 42 - if (!_statusReports) { 43 - throw new HTTPException(404, { message: "Not Found" }); 44 - } 45 40 46 41 const data = z.array(StatusReportSchema).parse( 47 42 _statusReports.map((r) => ({
+1 -1
apps/server/src/v1/statusReports/index.ts apps/server/src/routes/v1/statusReports/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 3 - import { handleZodError } from "../../libs/errors"; 3 + import { handleZodError } from "@/libs/errors"; 4 4 import type { Variables } from "../index"; 5 5 import { registerDeleteStatusReport } from "./delete"; 6 6 import { regsiterGetStatusReport } from "./get";
+40 -39
apps/server/src/v1/statusReports/post.ts apps/server/src/routes/v1/statusReports/post.ts
··· 10 10 statusReportUpdate, 11 11 } from "@openstatus/db/src/schema"; 12 12 13 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 13 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 14 14 import { sendBatchEmailHtml } from "@openstatus/emails/src/send"; 15 - import { HTTPException } from "hono/http-exception"; 16 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 17 - import { isoDate } from "../utils"; 18 15 import type { statusReportsApi } from "./index"; 19 16 import { StatusReportSchema } from "./schema"; 20 17 21 18 const postRoute = createRoute({ 22 19 method: "post", 23 20 tags: ["status_report"], 24 - description: "Create a Status Report", 21 + summary: "Create a status report", 25 22 path: "/", 26 23 request: { 27 24 body: { ··· 32 29 id: true, 33 30 statusReportUpdateIds: true, 34 31 }).extend({ 35 - date: isoDate.optional().openapi({ 36 - description: "The date of the report in ISO8601 format", 32 + date: z.coerce.date().optional().default(new Date()).openapi({ 33 + description: 34 + "The date of the report in ISO8601 format, defaults to now", 37 35 }), 38 36 message: z.string().openapi({ 39 37 description: "The message of the current status of incident", ··· 50 48 schema: StatusReportSchema, 51 49 }, 52 50 }, 53 - description: "Status report created", 51 + description: "The created status report", 54 52 }, 55 53 ...openApiErrorResponses, 56 54 }, ··· 59 57 export function registerPostStatusReport(api: typeof statusReportsApi) { 60 58 return api.openapi(postRoute, async (c) => { 61 59 const input = c.req.valid("json"); 62 - const workspaceId = c.get("workspaceId"); 63 - const limits = c.get("limits"); 64 - 65 - const { monitorIds, date, ...rest } = input; 60 + const workspaceId = c.get("workspace").id; 61 + const limits = c.get("workspace").limits; 66 62 67 - if (monitorIds?.length) { 63 + if (input.monitorIds?.length) { 68 64 const _monitors = await db 69 65 .select() 70 66 .from(monitor) 71 67 .where( 72 68 and( 73 - eq(monitor.workspaceId, Number(workspaceId)), 74 - inArray(monitor.id, monitorIds), 69 + eq(monitor.workspaceId, workspaceId), 70 + inArray(monitor.id, input.monitorIds), 75 71 isNull(monitor.deletedAt), 76 72 ), 77 73 ) 78 74 .all(); 79 75 80 - if (_monitors.length !== monitorIds.length) { 81 - throw new HTTPException(400, { message: "Monitor not found" }); 76 + if (_monitors.length !== input.monitorIds.length) { 77 + throw new OpenStatusApiError({ 78 + code: "BAD_REQUEST", 79 + message: `Some of the monitors ${input.monitorIds.join(", ")} not found`, 80 + }); 82 81 } 83 82 } 84 83 85 - if (rest.pageId) { 86 - const _pages = await db 87 - .select() 88 - .from(page) 89 - .where( 90 - and( 91 - eq(page.workspaceId, Number(workspaceId)), 92 - eq(page.id, rest.pageId), 93 - ), 94 - ) 95 - .all(); 84 + const _pages = await db 85 + .select() 86 + .from(page) 87 + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, input.pageId))) 88 + .all(); 96 89 97 - if (_pages.length !== 1) { 98 - throw new HTTPException(400, { message: "Page not found" }); 99 - } 90 + if (_pages.length !== 1) { 91 + throw new OpenStatusApiError({ 92 + code: "BAD_REQUEST", 93 + message: `Page ${input.pageId} not found`, 94 + }); 100 95 } 101 96 102 97 const _newStatusReport = await db 103 98 .insert(statusReport) 104 99 .values({ 105 - ...rest, 106 - workspaceId: Number(workspaceId), 100 + status: input.status, 101 + title: input.title, 102 + pageId: input.pageId, 103 + workspaceId: workspaceId, 107 104 }) 108 105 .returning() 109 106 .get(); ··· 111 108 const _newStatusReportUpdate = await db 112 109 .insert(statusReportUpdate) 113 110 .values({ 114 - ...input, 115 - date: date ? new Date(date) : new Date(), 111 + status: input.status, 112 + message: input.message, 113 + date: input.date, 116 114 statusReportId: _newStatusReport.id, 117 115 }) 118 116 .returning() 119 117 .get(); 120 118 121 - if (monitorIds?.length) { 119 + if (input.monitorIds?.length) { 122 120 await db 123 121 .insert(monitorsToStatusReport) 124 122 .values( 125 - monitorIds.map((id) => { 123 + input.monitorIds.map((id) => { 126 124 return { 127 125 monitorId: id, 128 126 statusReportId: _newStatusReport.id, ··· 132 130 .returning(); 133 131 } 134 132 135 - if (getLimit(limits, "status-subscribers") && _newStatusReport.pageId) { 133 + if (limits["status-subscribers"] && _newStatusReport.pageId) { 136 134 const subscribers = await db 137 135 .select() 138 136 .from(pageSubscriber) ··· 143 141 ), 144 142 ) 145 143 .all(); 144 + 146 145 const pageInfo = await db 147 146 .select() 148 147 .from(page) 149 148 .where(eq(page.id, _newStatusReport.pageId)) 150 149 .get(); 150 + 151 151 if (pageInfo) { 152 152 const emails = subscribers.map((subscriber) => { 153 153 return { ··· 158 158 from: "Notification OpenStatus <notification@notifications.openstatus.dev>", 159 159 }; 160 160 }); 161 + 161 162 await sendBatchEmailHtml(emails); 162 163 } 163 164 } 164 165 165 166 const data = StatusReportSchema.parse({ 166 167 ..._newStatusReport, 167 - monitorIds, 168 + monitorIds: input.monitorIds, 168 169 statusReportUpdateIds: [_newStatusReportUpdate.id], 169 170 }); 170 171
-58
apps/server/src/v1/statusReports/schema.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - 3 - import { statusReportStatusSchema } from "@openstatus/db/src/schema"; 4 - 5 - export const ParamsSchema = z.object({ 6 - id: z 7 - .string() 8 - .min(1) 9 - .openapi({ 10 - param: { 11 - name: "id", 12 - in: "path", 13 - }, 14 - description: "The id of the status report", 15 - example: "1", 16 - }), 17 - }); 18 - 19 - export const StatusReportSchema = z.object({ 20 - id: z.number().openapi({ description: "The id of the status report" }), 21 - title: z.string().openapi({ 22 - example: "Documenso", 23 - description: "The title of the status report", 24 - }), 25 - status: statusReportStatusSchema.openapi({ 26 - description: "The current status of the report", 27 - }), 28 - // REMINDER: extended only on POST requests 29 - // date: isoDate.openapi({ 30 - // description: "The date of the report in ISO8601 format", 31 - // }), 32 - // message: z.string().openapi({ 33 - // description: "The message of the current status of incident", 34 - // }), 35 - statusReportUpdateIds: z 36 - .array(z.number()) 37 - .optional() 38 - .nullable() 39 - .default([]) 40 - .openapi({ 41 - description: "The ids of the status report updates", 42 - }), 43 - monitorIds: z 44 - .array(z.number()) 45 - .optional() 46 - .nullable() 47 - .default([]) 48 - .openapi({ 49 - description: "id of monitors this report needs to refer", 50 - }) 51 - .nullable(), 52 - 53 - pageId: z.number().optional().nullable().openapi({ 54 - description: "The id of the page this status report belongs to", 55 - }), 56 - }); 57 - 58 - export type StatusReportSchema = z.infer<typeof StatusReportSchema>;
-156
apps/server/src/v1/statusReports/statusReports.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { api } from "../index"; 4 - 5 - test("GET one status report", async () => { 6 - const res = await api.request("/status_report/1", { 7 - headers: { 8 - "x-openstatus-key": "1", 9 - }, 10 - }); 11 - expect(res.status).toBe(200); 12 - expect(await res.json()).toMatchObject({ 13 - id: 1, 14 - title: "Test Status Report", 15 - status: "monitoring", 16 - statusReportUpdateIds: expect.arrayContaining([1, 3]), // depending on the order of the updates 17 - monitorIds: null, 18 - pageId: 1, 19 - }); 20 - }); 21 - 22 - test("Get all status report", async () => { 23 - const res = await api.request("/status_report", { 24 - headers: { 25 - "x-openstatus-key": "1", 26 - }, 27 - }); 28 - expect(res.status).toBe(200); 29 - expect({ data: await res.json() }).toMatchObject({ 30 - data: [ 31 - { 32 - id: 1, 33 - title: "Test Status Report", 34 - status: "monitoring", 35 - statusReportUpdateIds: expect.arrayContaining([1, 3]), // depending on the order of the updates 36 - monitorIds: [], 37 - pageId: 1, 38 - }, 39 - { 40 - id: 2, 41 - title: "Test Status Report", 42 - status: "investigating", 43 - statusReportUpdateIds: expect.arrayContaining([2]), // depending on the order of the updates 44 - monitorIds: [1, 2], 45 - pageId: 1, 46 - }, 47 - ], 48 - }); 49 - }); 50 - 51 - test("Create one status report including passing optional fields", async () => { 52 - const res = await api.request("/status_report", { 53 - method: "POST", 54 - headers: { 55 - "x-openstatus-key": "1", 56 - "content-type": "application/json", 57 - }, 58 - body: JSON.stringify({ 59 - status: "investigating", 60 - title: "New Status Report", 61 - message: "Message", 62 - monitorIds: [1], 63 - pageId: 1, 64 - }), 65 - }); 66 - const json = await res.json(); 67 - 68 - expect(res.status).toBe(200); 69 - 70 - expect(json).toMatchObject({ 71 - id: expect.any(Number), 72 - title: "New Status Report", 73 - status: "investigating", 74 - statusReportUpdateIds: [expect.any(Number)], 75 - monitorIds: [1], 76 - pageId: 1, 77 - }); 78 - }); 79 - 80 - test("Create one status report without auth key should return 401", async () => { 81 - const res = await api.request("/status_report", { 82 - method: "POST", 83 - headers: { 84 - "content-type": "application/json", 85 - }, 86 - body: JSON.stringify({ 87 - status: "investigating", 88 - title: "Test Status Report", 89 - }), 90 - }); 91 - expect(res.status).toBe(401); //unauthenticated 92 - }); 93 - 94 - test("Create one status report with invalid data should return 403", async () => { 95 - const res = await api.request("/status_report", { 96 - method: "POST", 97 - headers: { 98 - "x-openstatus-key": "1", 99 - "content-type": "application/json", 100 - }, 101 - body: JSON.stringify({ 102 - //passing incompelete body 103 - title: "Test Status Report", 104 - }), 105 - }); 106 - expect(res.status).toBe(400); 107 - }); 108 - 109 - test("Create status report with non existing monitor ids should return 400", async () => { 110 - const res = await api.request("/status_report", { 111 - method: "POST", 112 - headers: { 113 - "x-openstatus-key": "1", 114 - "content-type": "application/json", 115 - }, 116 - body: JSON.stringify({ 117 - status: "investigating", 118 - title: "New Status Report", 119 - message: "Message", 120 - monitorIds: [100], 121 - pageId: 1, 122 - }), 123 - }); 124 - 125 - expect(res.status).toBe(400); 126 - }); 127 - 128 - test("Create status report with non existing page ids should return 400", async () => { 129 - const res = await api.request("/status_report", { 130 - method: "POST", 131 - headers: { 132 - "x-openstatus-key": "1", 133 - "content-type": "application/json", 134 - }, 135 - body: JSON.stringify({ 136 - status: "investigating", 137 - title: "New Status Report", 138 - message: "Message", 139 - monitorIds: [1], 140 - pageId: 100, 141 - }), 142 - }); 143 - 144 - expect(res.status).toBe(400); 145 - }); 146 - 147 - test("Delete a status report", async () => { 148 - const res = await api.request("/status_report/3", { 149 - method: "DELETE", 150 - headers: { 151 - "x-openstatus-key": "1", 152 - }, 153 - }); 154 - expect(res.status).toBe(200); 155 - expect(await res.json()).toMatchObject({}); 156 - });
+15 -11
apps/server/src/v1/statusReports/update/post.ts apps/server/src/routes/v1/statusReports/update/post.ts
··· 1 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 1 2 import { createRoute } from "@hono/zod-openapi"; 2 3 import { and, db, eq, isNotNull } from "@openstatus/db"; 3 4 import { ··· 6 7 statusReport, 7 8 statusReportUpdate, 8 9 } from "@openstatus/db/src/schema"; 9 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 10 10 import { sendBatchEmailHtml } from "@openstatus/emails/src/send"; 11 - import { HTTPException } from "hono/http-exception"; 12 - import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; 13 11 import { StatusReportUpdateSchema } from "../../statusReportUpdates/schema"; 14 12 import type { statusReportsApi } from "../index"; 15 13 import { ParamsSchema, StatusReportSchema } from "../schema"; ··· 18 16 method: "post", 19 17 tags: ["status_report"], 20 18 path: "/:id/update", 19 + summary: "Create a status report update", 20 + deprecated: true, 21 21 description: 22 - "Create an status report update. Deprecated, please use /status-report-updates instead.", 22 + "Preferably use [`/status-report-updates`](#tag/status_report_update/POST/status_report_update) instead.", 23 23 request: { 24 24 params: ParamsSchema, 25 25 body: { ··· 48 48 return api.openapi(postRouteUpdate, async (c) => { 49 49 const input = c.req.valid("json"); 50 50 const { id } = c.req.valid("param"); 51 - const workspaceId = c.get("workspaceId"); 52 - const limits = c.get("limits"); 51 + const workspaceId = c.get("workspace").id; 52 + const limits = c.get("workspace").limits; 53 53 54 54 const _statusReport = await db 55 55 .update(statusReport) ··· 57 57 .where( 58 58 and( 59 59 eq(statusReport.id, Number(id)), 60 - eq(statusReport.workspaceId, Number(workspaceId)), 60 + eq(statusReport.workspaceId, workspaceId), 61 61 ), 62 62 ) 63 63 .returning() 64 64 .get(); 65 65 66 66 if (!_statusReport) { 67 - throw new HTTPException(404, { message: "Not Found" }); 67 + throw new OpenStatusApiError({ 68 + code: "NOT_FOUND", 69 + message: `Status Report ${id} not found`, 70 + }); 68 71 } 69 72 70 73 const _statusReportUpdate = await db 71 74 .insert(statusReportUpdate) 72 75 .values({ 73 - ...input, 74 - date: new Date(input.date), 76 + status: input.status, 77 + message: input.message, 78 + date: input.date, 75 79 statusReportId: Number(id), 76 80 }) 77 81 .returning() 78 82 .get(); 79 83 80 - if (getLimit(limits, "notifications") && _statusReport.pageId) { 84 + if (limits.notifications && _statusReport.pageId) { 81 85 const subscribers = await db 82 86 .select() 83 87 .from(pageSubscriber)
-2
apps/server/src/v1/test-utils.ts
··· 1 - export const iso8601Regex: RegExp = 2 - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
-17
apps/server/src/v1/utils.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - 3 - export const isoDate = z.preprocess((val) => { 4 - if (val) { 5 - return new Date(String(val)).toISOString(); 6 - } 7 - return new Date().toISOString(); 8 - }, z.string()); 9 - 10 - export function isNumberArray<T>( 11 - monitors: number[] | T[], 12 - ): monitors is number[] { 13 - return ( 14 - Array.isArray(monitors) && 15 - monitors.every((item) => typeof item === "number") 16 - ); 17 - }
-45
apps/server/src/v1/whoami/get.ts
··· 1 - import { createRoute, z } from "@hono/zod-openapi"; 2 - import { eq } from "@openstatus/db"; 3 - import { db } from "@openstatus/db/src/db"; 4 - import { workspace } from "@openstatus/db/src/schema/workspaces"; 5 - import { HTTPException } from "hono/http-exception"; 6 - import type { whoamiApi } from "."; 7 - import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 8 - import { schema } from "./schema"; 9 - 10 - const getRoute = createRoute({ 11 - method: "get", 12 - tags: ["whoami"], 13 - path: "/", 14 - description: "Get the current workspace information", 15 - responses: { 16 - 200: { 17 - content: { 18 - "application/json": { 19 - schema: schema, 20 - }, 21 - }, 22 - description: "The current workspace information with the limits", 23 - }, 24 - ...openApiErrorResponses, 25 - }, 26 - }); // Error: createRoute is not defined 27 - 28 - export function registerGetWhoami(api: typeof whoamiApi) { 29 - return api.openapi(getRoute, async (c) => { 30 - const workspaceId = c.get("workspaceId"); 31 - 32 - const workspaceData = await db 33 - .select() 34 - .from(workspace) 35 - .where(eq(workspace.id, Number(workspaceId))) 36 - .get(); 37 - 38 - if (!workspaceData) { 39 - throw new HTTPException(404, { message: "Not Found" }); 40 - } 41 - 42 - const data = schema.parse(workspaceData); 43 - return c.json(data, 200); 44 - }); 45 - }
+1 -1
apps/server/src/v1/whoami/index.ts apps/server/src/routes/v1/whoami/index.ts
··· 1 + import { handleZodError } from "@/libs/errors"; 1 2 import { OpenAPIHono } from "@hono/zod-openapi"; 2 3 import type { Variables } from ".."; 3 - import { handleZodError } from "../../libs/errors"; 4 4 import { registerGetWhoami } from "./get"; 5 5 6 6 export const whoamiApi = new OpenAPIHono<{ Variables: Variables }>({
-19
apps/server/src/v1/whoami/schema.ts
··· 1 - import { z } from "@hono/zod-openapi"; 2 - 3 - import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; 4 - 5 - export const schema = z.object({ 6 - name: z 7 - .string() 8 - .openapi({ description: "The current workspace name" }) 9 - .optional(), 10 - slug: z.string().openapi({ description: "The current workspace slug" }), 11 - plan: z 12 - .enum(workspacePlans) 13 - .nullable() 14 - .default("free") 15 - .transform((val) => val ?? "free") 16 - .openapi({ 17 - description: "The current workspace plan", 18 - }), 19 - });
+8 -1
apps/server/tsconfig.json
··· 2 2 "extends": "@openstatus/tsconfig/base.json", 3 3 "include": ["src", "*.ts", "**/*.ts"], 4 4 "compilerOptions": { 5 - "types": ["bun-types"] 5 + "jsx": "react-jsx", 6 + "jsxImportSource": "react", 7 + "allowJs": true, 8 + "types": ["bun-types"], 9 + "baseUrl": ".", 10 + "paths": { 11 + "@/*": ["src/*"] 12 + } 6 13 } 7 14 }
+7 -5
apps/web/src/app/(content)/blog/feed.xml/route.ts
··· 14 14 author: { 15 15 name: "OpenStatus Team", 16 16 email: "ping@openstatus.dev", 17 - link: "https://openstatus.dev" 17 + link: "https://openstatus.dev", 18 18 }, 19 19 copyright: `Copyright ${new Date().getFullYear().toString()}, OpenStatus`, 20 20 language: "en-US", ··· 33 33 title: post.title, 34 34 description: post.description, 35 35 link: `https://www.openstatus.dev/blog/${post.slug}`, 36 - author: [{ 37 - name: post.author.name, 38 - link: post.author.url, 39 - }], 36 + author: [ 37 + { 38 + name: post.author.name, 39 + link: post.author.url, 40 + }, 41 + ], 40 42 date: post.publishedAt, 41 43 }); 42 44 });
+8 -6
apps/web/src/app/(content)/changelog/feed.xml/route.ts
··· 14 14 author: { 15 15 name: "OpenStatus Team", 16 16 email: "ping@openstatus.dev", 17 - link: "https://openstatus.dev" 17 + link: "https://openstatus.dev", 18 18 }, 19 19 copyright: `Copyright ${new Date().getFullYear().toString()}, OpenStatus`, 20 20 language: "en-US", ··· 33 33 title: post.title, 34 34 description: post.description, 35 35 link: `https://www.openstatus.dev/changelog/${post.slug}`, 36 - author: [{ 37 - name: "OpenStatus Team", 38 - email: "ping@openstatus.dev", 39 - link: "https://openstatus.dev" 40 - }], 36 + author: [ 37 + { 38 + name: "OpenStatus Team", 39 + email: "ping@openstatus.dev", 40 + link: "https://openstatus.dev", 41 + }, 42 + ], 41 43 date: post.publishedAt, 42 44 }); 43 45 });
+3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx
··· 19 19 <div className="grid max-w-lg gap-3"> 20 20 {Object.entries(currentNumbers).map(([key, value]) => { 21 21 const limit = workspace.limits[key as keyof typeof currentNumbers]; 22 + // TODO: find a better way to determine if the limit is monthly 23 + const isMonthly = ["synthetic-checks"].includes(key); 22 24 return ( 23 25 <div key={key}> 24 26 <div className="mb-1 flex items-center justify-between text-muted-foreground"> 25 27 <p className="text-sm capitalize">{key.replace("-", " ")}</p> 26 28 <p className="text-xs"> 29 + {isMonthly ? "monthly" : null}{" "} 27 30 <span className="text-foreground">{value}</span> / {limit} 28 31 </p> 29 32 </div>
+10 -10
apps/web/src/app/status-page/[domain]/_components/actions.ts
··· 66 66 67 67 const token = crypto.randomUUID(); 68 68 69 + await db 70 + .insert(pageSubscriber) 71 + .values({ 72 + email: validatedFields.data.email, 73 + token, 74 + pageId: pageData.id, 75 + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 76 + }) 77 + .execute(); 78 + 69 79 await sendEmail({ 70 80 react: SubscribeEmail({ 71 81 domain: pageData.slug, ··· 76 86 to: [validatedFields.data.email], 77 87 subject: `Verify your subscription to ${pageData.title}`, 78 88 }); 79 - 80 - await db 81 - .insert(pageSubscriber) 82 - .values({ 83 - email: validatedFields.data.email, 84 - token, 85 - pageId: pageData.id, 86 - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 87 - }) 88 - .execute(); 89 89 90 90 const analytics = await setupAnalytics({}); 91 91 analytics.track({ ...Events.SubscribePage, slug: pageData.slug });
+13 -11
apps/web/src/app/status-page/[domain]/subscribe/route.ts
··· 38 38 return new Response("Not found", { status: 401 }); 39 39 } 40 40 41 - const token = (Math.random() + 1).toString(36).substring(10); 41 + const token = crypto.randomUUID(); 42 42 43 - await sendEmail({ 44 - react: SubscribeEmail({ 45 - domain: params.domain, 46 - token: token, 47 - page: pageData.title, 48 - }), 49 - from: "OpenStatus <notification@notifications.openstatus.dev>", 50 - to: [result.email], 51 - subject: `Verify your subscription to ${pageData.title}`, 52 - }); 53 43 await db 54 44 .insert(pageSubscriber) 55 45 .values({ ··· 59 49 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 60 50 }) 61 51 .execute(); 52 + 53 + await sendEmail({ 54 + react: SubscribeEmail({ 55 + domain: params.domain, 56 + token, 57 + page: pageData.title, 58 + }), 59 + from: "OpenStatus <notification@notifications.openstatus.dev>", 60 + to: [result.email], 61 + subject: `Verify your subscription to ${pageData.title}`, 62 + }); 63 + 62 64 return Response.json({ message: "Hello world" }); 63 65 }
+1 -1
packages/analytics/src/server.ts
··· 67 67 opts: EventProps & PostEventPayload["properties"], 68 68 ): Promise<unknown> => { 69 69 return new Promise((resolve) => { 70 - console.log(`>>> Track Event: ${opts.name}`); 70 + console.log(`>>> Track Noop Event: ${opts.name}`); 71 71 resolve(null); 72 72 }); 73 73 },
+6 -6
packages/api/src/router/page.test.ts
··· 17 17 const result = await caller.page.getPageBySlug({ slug: "status" }); 18 18 expect(result).toMatchObject({ 19 19 createdAt: expect.any(Date), 20 - customDomain: "", 21 - description: "hello", 22 - icon: "https://www.openstatus.dev/favicon.ico", 20 + customDomain: expect.any(String), 21 + description: expect.any(String), 22 + icon: expect.any(String), 23 23 statusReports: expect.any(Array), 24 24 monitors: expect.any(Array), 25 25 incidents: expect.any(Array), 26 - published: true, 27 - slug: "status", 28 - title: "Test Page", 26 + published: expect.any(Boolean), 27 + slug: expect.any(String), 28 + title: expect.any(String), 29 29 updatedAt: expect.any(Date), 30 30 }); 31 31 });
+2 -13
packages/api/src/router/page.ts
··· 12 12 selectPageSchemaWithMonitorsRelation, 13 13 selectPublicPageSchemaWithRelation, 14 14 statusReport, 15 + subdomainSafeList, 15 16 workspace, 16 17 } from "@openstatus/db/src/schema"; 17 18 ··· 345 346 .input(z.object({ slug: z.string().toLowerCase() })) 346 347 .query(async (opts) => { 347 348 // had filter on some words we want to keep for us 348 - if ( 349 - [ 350 - "api", 351 - "app", 352 - "www", 353 - "docs", 354 - "checker", 355 - "time", 356 - "help", 357 - "data-table", 358 - "light", 359 - ].includes(opts.input.slug) 360 - ) { 349 + if (subdomainSafeList.includes(opts.input.slug)) { 361 350 return false; 362 351 } 363 352 const result = await opts.ctx.db.query.page.findMany({
+17 -2
packages/api/src/router/workspace.ts
··· 2 2 import * as randomWordSlugs from "random-word-slugs"; 3 3 import { z } from "zod"; 4 4 5 - import { and, eq, isNull, sql } from "@openstatus/db"; 5 + import { and, eq, gte, isNull, sql } from "@openstatus/db"; 6 6 import { 7 7 application, 8 8 monitor, 9 + monitorRun, 9 10 notification, 10 11 page, 11 12 selectApplicationSchema, ··· 197 198 }), 198 199 199 200 getCurrentWorkspaceNumbers: protectedProcedure.query(async (opts) => { 200 - console.log(opts.ctx.workspace.id); 201 + const lastMonth = new Date().setMonth(new Date().getMonth() - 1); 202 + 201 203 const currentNumbers = await opts.ctx.db.transaction(async (tx) => { 202 204 const notifications = await tx 203 205 .select({ count: sql<number>`count(*)` }) ··· 216 218 .select({ count: sql<number>`count(*)` }) 217 219 .from(page) 218 220 .where(eq(page.workspaceId, opts.ctx.workspace.id)); 221 + 222 + const runs = await tx 223 + .select({ count: sql<number>`count(*)` }) 224 + .from(monitorRun) 225 + .where( 226 + and( 227 + eq(monitorRun.workspaceId, opts.ctx.workspace.id), 228 + gte(monitorRun.createdAt, new Date(lastMonth)), 229 + ), 230 + ) 231 + .all(); 232 + 219 233 return { 220 234 "notification-channels": notifications?.[0].count || 0, 221 235 monitors: monitors?.[0].count || 0, 222 236 "status-pages": pages?.[0].count || 0, 237 + "synthetic-checks": runs?.[0].count || 0, 223 238 } satisfies Partial<Limits>; 224 239 }); 225 240
+12
packages/db/src/schema/pages/constants.ts
··· 1 + export const subdomainSafeList = [ 2 + "api", 3 + "app", 4 + "www", 5 + "docs", 6 + "checker", 7 + "time", 8 + "help", 9 + "data-table", 10 + "light", 11 + "workflows", 12 + ];
+1
packages/db/src/schema/pages/index.ts
··· 1 1 export * from "./page"; 2 2 export * from "./validation"; 3 3 export type * from "./validation"; 4 + export * from "./constants";
+1 -1
packages/db/src/schema/plan/schema.ts
··· 10 10 * Monitor limits 11 11 */ 12 12 monitors: z.number().default(1), 13 - "synthetic-checks": z.number().default(30), 13 + "synthetic-checks": z.number().default(30), // monthly limits 14 14 periodicity: monitorPeriodicitySchema.array().default(["10m", "30m", "1h"]), 15 15 "multi-region": z.boolean().default(true), 16 16 "max-regions": z.number().default(6),
+4
packages/emails/src/send.ts
··· 19 19 from: string; 20 20 }; 21 21 export const sendEmail = async (email: Emails) => { 22 + if (process.env.NODE_ENV !== "production") return; 22 23 await resend.emails.send(email); 23 24 }; 24 25 25 26 export const sendBatchEmailHtml = async (emails: EmailHtml[]) => { 27 + if (process.env.NODE_ENV !== "production") return; 26 28 await resend.batch.send(emails); 27 29 }; 28 30 29 31 // TODO: delete in favor of sendBatchEmailHtml 30 32 export const sendEmailHtml = async (emails: EmailHtml[]) => { 33 + if (process.env.NODE_ENV !== "production") return; 34 + 31 35 await fetch("https://api.resend.com/emails/batch", { 32 36 method: "POST", 33 37 headers: {
+1 -1
packages/emails/src/utils.ts
··· 2 2 const response = await fetch( 3 3 `https://open.kickbox.com/v1/disposable/${mailHost}`, 4 4 ); 5 - const status = await response.json(); 5 + const status = (await response.json()) as Record<string, unknown>; 6 6 7 7 return status.disposable; 8 8 };
+5 -5
packages/error/src/error-code.ts
··· 1 1 import { z } from "zod"; 2 2 3 - export const ErrorCodeEnum = z.enum([ 3 + export const ErrorCodes = [ 4 4 "BAD_REQUEST", 5 5 "FORBIDDEN", 6 6 "INTERNAL_SERVER_ERROR", 7 - "USAGE_EXCEEDED", 8 - "DISABLED", 7 + "PAYMENT_REQUIRED", 9 8 "CONFLICT", 10 9 "NOT_FOUND", 11 - "NOT_UNIQUE", 12 10 "UNAUTHORIZED", 13 11 "METHOD_NOT_ALLOWED", 14 12 "UNPROCESSABLE_ENTITY", 15 - ]); 13 + ] as const; 14 + 15 + export const ErrorCodeEnum = z.enum(ErrorCodes); 16 16 17 17 export type ErrorCode = z.infer<typeof ErrorCodeEnum>;
+6 -2
packages/error/src/utils.ts
··· 8 8 return "BAD_REQUEST"; 9 9 case 401: 10 10 return "UNAUTHORIZED"; 11 + case 402: 12 + return "PAYMENT_REQUIRED"; 11 13 case 403: 12 14 return "FORBIDDEN"; 13 15 case 404: ··· 15 17 case 405: 16 18 return "METHOD_NOT_ALLOWED"; 17 19 case 409: 18 - return "METHOD_NOT_ALLOWED"; 20 + return "CONFLICT"; 19 21 case 422: 20 22 return "UNPROCESSABLE_ENTITY"; 21 23 case 500: ··· 25 27 } 26 28 } 27 29 28 - export function codeToStatus(code: ErrorCode): number { 30 + export function codeToStatus(code: ErrorCode) { 29 31 switch (code) { 30 32 case "BAD_REQUEST": 31 33 return 400; 32 34 case "UNAUTHORIZED": 33 35 return 401; 36 + case "PAYMENT_REQUIRED": 37 + return 402; 34 38 case "FORBIDDEN": 35 39 return 403; 36 40 case "NOT_FOUND":
+9 -7
packages/tinybird/src/client.ts
··· 9 9 private readonly tb: Client; 10 10 11 11 constructor(token: string) { 12 - if (process.env.NODE_ENV === "development") { 13 - this.tb = new NoopTinybird(); 14 - } else { 15 - this.tb = new Client({ token }); 16 - } 12 + // if (process.env.NODE_ENV === "development") { 13 + // this.tb = new NoopTinybird(); 14 + // } else { 15 + this.tb = new Client({ token }); 16 + // } 17 17 } 18 18 19 19 public get homeStats() { ··· 346 346 timestamp: z.number(), 347 347 workspaceId: z.string(), 348 348 }), 349 - opts: { cache: "no-store" }, 349 + // REMINDER: cache the result for accessing the data for a check as it won't change 350 + opts: { cache: "force-cache" }, 350 351 }); 351 352 } 352 353 ··· 685 686 timestamp: z.number(), 686 687 workspaceId: z.string(), 687 688 }), 688 - opts: { cache: "no-store" }, 689 + // REMINDER: cache the result for accessing the data for a check as it won't change 690 + opts: { cache: "force-cache" }, 689 691 }); 690 692 } 691 693 }
+2
packages/utils/index.ts
··· 461 461 degradedAfter: z.number().nullable(), 462 462 trigger: z.enum(["cron", "api"]).optional().nullable().default("cron"), 463 463 }); 464 + 465 + export type TcpPayload = z.infer<typeof tpcPayloadSchema>;
+4 -1
pnpm-lock.yaml
··· 122 122 specifier: 0.2.2 123 123 version: 0.2.2(hono@4.5.3)(zod@3.23.8) 124 124 '@openstatus/analytics': 125 - specifier: workspace:^ 125 + specifier: workspace:* 126 126 version: link:../../packages/analytics 127 + '@openstatus/assertions': 128 + specifier: workspace:* 129 + version: link:../../packages/assertions 127 130 '@openstatus/db': 128 131 specifier: workspace:* 129 132 version: link:../../packages/db