diff --git a/apps/server/package.json b/apps/server/package.json index 263df598..e54c489b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,13 +7,15 @@ "scripts": { "dev": "bun run --hot src/index.ts", "start": "NODE_ENV=production bun run src/index.ts", - "test": "bun test" + "test": "bun test", + "tsc": "tsc --noEmit" }, "dependencies": { "@hono/sentry": "1.2.0", "@hono/zod-openapi": "0.15.1", "@hono/zod-validator": "0.2.2", - "@openstatus/analytics": "workspace:^", + "@openstatus/analytics": "workspace:*", + "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 675d53d2..dd54daad 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -14,6 +14,7 @@ export const env = createEnv({ SCREENSHOT_SERVICE_URL: z.string(), QSTASH_TOKEN: z.string(), NODE_ENV: z.string().default("development"), + SUPER_ADMIN_TOKEN: z.string(), }, /** diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ba10634c..5e01b808 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,14 +3,24 @@ import { Hono } from "hono"; import { showRoutes } from "hono/dev"; import { logger } from "hono/logger"; -import { checkerRoute } from "./checker"; +import { prettyJSON } from "hono/pretty-json"; +import { requestId } from "hono/request-id"; import { env } from "./env"; import { handleError } from "./libs/errors"; -import { publicRoute } from "./public"; -import { api } from "./v1"; +import { checkerRoute } from "./routes/checker"; +import { publicRoute } from "./routes/public"; +import { api } from "./routes/v1"; -const app = new Hono({ strict: false }); +export const app = new Hono({ strict: false }); + +/** + * Middleware + */ app.use("*", sentry({ dsn: process.env.SENTRY_DSN })); +app.use("*", requestId()); +app.use("*", logger()); +app.use("*", prettyJSON()); + app.onError(handleError); /** @@ -21,14 +31,23 @@ app.route("/public", publicRoute); /** * Ping Pong */ -app.use("/ping", logger()); -app.get("/ping", (c) => c.json({ ping: "pong", region: env.FLY_REGION }, 200)); +app.get("/ping", (c) => { + return c.json( + { ping: "pong", region: env.FLY_REGION, requestId: c.get("requestId") }, + 200, + ); +}); /** * API Routes v1 */ app.route("/v1", api); +/** + * TODO: move to `workflows` app + * This route is used by our checker to update the status of the monitors, + * create incidents, and send notifications. + */ app.route("/", checkerRoute); const isDev = process.env.NODE_ENV === "development"; diff --git a/apps/server/src/libs/checker/index.ts b/apps/server/src/libs/checker/index.ts new file mode 100644 index 00000000..178cd64f --- /dev/null +++ b/apps/server/src/libs/checker/index.ts @@ -0,0 +1 @@ +export * from "./utils"; diff --git a/apps/server/src/libs/checker/utils.ts b/apps/server/src/libs/checker/utils.ts new file mode 100644 index 00000000..b9bf1795 --- /dev/null +++ b/apps/server/src/libs/checker/utils.ts @@ -0,0 +1,67 @@ +import { OpenStatusApiError } from "@/libs/errors"; +import type { z } from "@hono/zod-openapi"; +import type { selectMonitorSchema } from "@openstatus/db/src/schema"; +import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; + +export function getCheckerPayload( + monitor: z.infer, + status: z.infer["status"], +): z.infer | z.infer { + const timestamp = new Date().getTime(); + switch (monitor.jobType) { + case "http": + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + url: monitor.url, + method: monitor.method || "GET", + cronTimestamp: timestamp, + body: monitor.body, + headers: monitor.headers, + status: status, + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + }; + case "tcp": + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + status: status, + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + }; + default: + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: + "Invalid jobType, currently only 'http' and 'tcp' are supported", + }); + } +} + +export function getCheckerUrl( + monitor: z.infer, + opts: { trigger?: "api" | "cron"; data?: boolean } = { + trigger: "api", + data: false, + }, +): string { + switch (monitor.jobType) { + case "http": + return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; + case "tcp": + return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; + default: + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: + "Invalid jobType, currently only 'http' and 'tcp' are supported", + }); + } +} diff --git a/apps/server/src/libs/errors/index.ts b/apps/server/src/libs/errors/index.ts index 178cd64f..eac1b82f 100644 --- a/apps/server/src/libs/errors/index.ts +++ b/apps/server/src/libs/errors/index.ts @@ -1 +1,2 @@ export * from "./utils"; +export * from "./openapi-error-responses"; diff --git a/apps/server/src/libs/errors/openapi-error-responses.ts b/apps/server/src/libs/errors/openapi-error-responses.ts index 9b1863a7..c19bc57f 100644 --- a/apps/server/src/libs/errors/openapi-error-responses.ts +++ b/apps/server/src/libs/errors/openapi-error-responses.ts @@ -1,3 +1,4 @@ +import type { RouteConfig } from "@hono/zod-openapi"; import { createErrorSchema } from "./utils"; export const openApiErrorResponses = { @@ -19,6 +20,15 @@ export const openApiErrorResponses = { }, }, }, + 402: { + description: "A higher pricing plan is required to access the resource.", + content: { + "application/json": { + schema: + createErrorSchema("PAYMENT_REQUIRED").openapi("ErrPaymentRequired"), + }, + }, + }, 403: { description: "The client does not have the necessary permissions to access the resource.", @@ -56,4 +66,4 @@ export const openApiErrorResponses = { }, }, }, -}; +} satisfies RouteConfig["responses"]; diff --git a/apps/server/src/libs/errors/utils.ts b/apps/server/src/libs/errors/utils.ts index 0490fedf..34114a49 100644 --- a/apps/server/src/libs/errors/utils.ts +++ b/apps/server/src/libs/errors/utils.ts @@ -1,27 +1,74 @@ +// Props to Unkey: https://github.com/unkeyed/unkey/blob/main/apps/api/src/pkg/errors/http.ts import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; +import type { ErrorCode } from "@openstatus/error"; import { - type ErrorCode, - ErrorCodeEnum, + ErrorCodes, SchemaError, + codeToStatus, statusToCode, } from "@openstatus/error"; -import { ZodError, z } from "zod"; +import { z } from "@hono/zod-openapi"; +import { ZodError } from "zod"; + +export class OpenStatusApiError extends HTTPException { + public readonly code: ErrorCode; + + constructor({ + code, + message, + }: { + code: ErrorCode; + message: HTTPException["message"]; + }) { + const status = codeToStatus(code); + super(status, { message }); + this.code = code; + } +} export function handleError(err: Error, c: Context): Response { if (err instanceof ZodError) { const error = SchemaError.fromZod(err, c); + + // If the error is a client error, we disable Sentry + c.get("sentry").setEnabled(false); + return c.json( { code: "BAD_REQUEST", message: error.message, docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", + requestId: c.get("requestId"), }, { status: 400 }, ); } + + /** + * This is a custom error that we throw in our code so we can handle it + */ + if (err instanceof OpenStatusApiError) { + const code = statusToCode(err.status); + + // If the error is a client error, we disable Sentry + if (err.status < 499) { + c.get("sentry").setEnabled(false); + } + + return c.json( + { + code: code, + message: err.message, + docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, + requestId: c.get("requestId"), + }, + { status: err.status }, + ); + } + if (err instanceof HTTPException) { const code = statusToCode(err.status); return c.json( @@ -29,15 +76,18 @@ export function handleError(err: Error, c: Context): Response { code: code, message: err.message, docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, + requestId: c.get("requestId"), }, { status: err.status }, ); } + return c.json( { code: "INTERNAL_SERVER_ERROR", message: err.message ?? "Something went wrong", docs: "https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR", + requestId: c.get("requestId"), }, { status: 500 }, @@ -63,26 +113,33 @@ export function handleZodError( code: "BAD_REQUEST", docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", message: error.message, + requestId: c.get("requestId"), }, { status: 400 }, ); } } -export type ErrorSchema = z.infer>; export function createErrorSchema(code: ErrorCode) { return z.object({ - code: ErrorCodeEnum.openapi({ + code: z.enum(ErrorCodes).openapi({ example: code, description: "The error code related to the status code.", }), message: z.string().openapi({ description: "A human readable message describing the issue.", - example: "Missing required field 'name'.", + example: "", }), docs: z.string().openapi({ description: "A link to the documentation for the error.", example: `https://docs.openstatus.dev/api-references/errors/code/${code}`, }), + requestId: z.string().openapi({ + description: + "The request id to be used for debugging and error reporting.", + example: "", + }), }); } + +export type ErrorSchema = z.infer>; diff --git a/apps/server/src/libs/middlewares/auth.ts b/apps/server/src/libs/middlewares/auth.ts new file mode 100644 index 00000000..17e49cbf --- /dev/null +++ b/apps/server/src/libs/middlewares/auth.ts @@ -0,0 +1,94 @@ +import { verifyKey } from "@unkey/api"; +import type { Context, Next } from "hono"; + +import { env } from "@/env"; +import { OpenStatusApiError } from "@/libs/errors"; +import type { Variables } from "@/types"; +import { db, eq } from "@openstatus/db"; +import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; + +export async function authMiddleware( + c: Context<{ Variables: Variables }, "/*">, + next: Next, +) { + const key = c.req.header("x-openstatus-key"); + if (!key) + throw new OpenStatusApiError({ + code: "UNAUTHORIZED", + message: "Missing 'x-openstatus-key' header", + }); + + const { error, result } = await validateKey(key); + + if (error) { + throw new OpenStatusApiError({ + code: "INTERNAL_SERVER_ERROR", + message: error.message, + }); + } + if (!result?.valid || !result?.ownerId) { + throw new OpenStatusApiError({ + code: "UNAUTHORIZED", + message: "Invalid API Key", + }); + } + + const _workspace = await db + .select() + .from(workspace) + .where(eq(workspace.id, Number.parseInt(result.ownerId))) + .get(); + + if (!_workspace) { + console.error("Workspace not found"); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: "Workspace not found, please contact support", + }); + } + + const validation = selectWorkspaceSchema.safeParse(_workspace); + + if (!validation.success) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: "Workspace data is invalid", + }); + } + + c.set("workspace", validation.data); + + await next(); +} + +async function validateKey(key: string): Promise<{ + result: { valid: boolean; ownerId?: string }; + error?: { message: string }; +}> { + if (env.NODE_ENV === "production") { + /** + * The Unkey api key starts with `os_` - that's how we can differentiate if we + * want to roll out our own key verification in the future. + * > We cannot use `os_` as a prefix for our own keys. + */ + if (key.startsWith("os_")) { + const { result, error } = await verifyKey(key); + return { + result: { valid: result?.valid ?? false, ownerId: result?.ownerId }, + error: error ? { message: error.message } : undefined, + }; + } + // Special bypass for our workspace + if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) { + return { result: { valid: true, ownerId: "1" } }; + } + // In production, we only accept Unkey keys + throw new OpenStatusApiError({ + code: "UNAUTHORIZED", + message: "Invalid API Key", + }); + } + + // In dev / test mode we can use the key as the ownerId + return { result: { valid: true, ownerId: key } }; +} diff --git a/apps/server/src/libs/middlewares/index.ts b/apps/server/src/libs/middlewares/index.ts new file mode 100644 index 00000000..c11d8d36 --- /dev/null +++ b/apps/server/src/libs/middlewares/index.ts @@ -0,0 +1,3 @@ +export * from "./auth"; +export * from "./track"; +export * from "./plan"; diff --git a/apps/server/src/libs/middlewares/plan.ts b/apps/server/src/libs/middlewares/plan.ts new file mode 100644 index 00000000..f70abf40 --- /dev/null +++ b/apps/server/src/libs/middlewares/plan.ts @@ -0,0 +1,25 @@ +import type { Variables } from "@/types"; +import { + type Workspace, + workspacePlanHierarchy, +} from "@openstatus/db/src/schema"; +import type { Context, Next } from "hono"; +import { OpenStatusApiError } from "../errors"; + +/** + * Checks if the workspace has a minimum required plan to access the endpoint + */ +export function minPlanMiddleware({ plan }: { plan: Workspace["plan"] }) { + return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { + const workspace = c.get("workspace"); + + if (workspacePlanHierarchy[workspace.plan] < workspacePlanHierarchy[plan]) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "You need to upgrade your plan to access this feature", + }); + } + + await next(); + }; +} diff --git a/apps/server/src/libs/middlewares/track.ts b/apps/server/src/libs/middlewares/track.ts new file mode 100644 index 00000000..613015b0 --- /dev/null +++ b/apps/server/src/libs/middlewares/track.ts @@ -0,0 +1,40 @@ +import type { Variables } from "@/types"; +import { + type EventProps, + parseInputToProps, + setupAnalytics, +} from "@openstatus/analytics"; +import type { Context, Next } from "hono"; + +export function trackMiddleware(event: EventProps, eventProps?: string[]) { + return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { + await next(); + + // REMINDER: only track the event if the request was successful + const isValid = c.res.status.toString().startsWith("2") && !c.error; + + if (isValid) { + // We have checked the request to be valid already + let json: unknown; + if (c.req.raw.bodyUsed) { + try { + json = await c.req.json(); + } catch { + json = {}; + } + } + const additionalProps = parseInputToProps(json, eventProps); + const workspace = c.get("workspace"); + + // REMINDER: use setTimeout to avoid blocking the response + setTimeout(async () => { + const analytics = await setupAnalytics({ + userId: `api_${workspace.id}`, + workspaceId: `${workspace.id}`, + plan: workspace.plan, + }); + await analytics.track({ ...event, additionalProps }); + }, 0); + } + }; +} diff --git a/apps/server/src/libs/test/preload.ts b/apps/server/src/libs/test/preload.ts new file mode 100644 index 00000000..f4efc7a0 --- /dev/null +++ b/apps/server/src/libs/test/preload.ts @@ -0,0 +1,23 @@ +import { mock } from "bun:test"; + +mock.module("@openstatus/upstash", () => ({ + Redis: { + fromEnv() { + return { + get: () => Promise.resolve(undefined), + set: () => Promise.resolve([]), + }; + }, + }, +})); + +mock.module("@openstatus/tinybird", () => ({ + OSTinybird: class { + httpStatus45d() { + return Promise.resolve({ data: [] }); + } + tcpStatus45d() { + return Promise.resolve({ data: [] }); + } + }, +})); diff --git a/apps/server/src/checker/alerting.test.ts b/apps/server/src/routes/checker/alerting.test.ts similarity index 100% rename from apps/server/src/checker/alerting.test.ts rename to apps/server/src/routes/checker/alerting.test.ts diff --git a/apps/server/src/checker/alerting.ts b/apps/server/src/routes/checker/alerting.ts similarity index 98% rename from apps/server/src/checker/alerting.ts rename to apps/server/src/routes/checker/alerting.ts index 0eb724a9..758cab68 100644 --- a/apps/server/src/checker/alerting.ts +++ b/apps/server/src/routes/checker/alerting.ts @@ -5,9 +5,9 @@ import { selectNotificationSchema, } from "@openstatus/db/src/schema"; +import { checkerAudit } from "@/utils/audit-log"; import type { MonitorFlyRegion } from "@openstatus/db/src/schema/constants"; import { Redis } from "@openstatus/upstash"; -import { checkerAudit } from "../utils/audit-log"; import { providerToFunction } from "./utils"; const redis = Redis.fromEnv(); diff --git a/apps/server/src/checker/index.ts b/apps/server/src/routes/checker/index.ts similarity index 99% rename from apps/server/src/checker/index.ts rename to apps/server/src/routes/checker/index.ts index e2d18fbb..bc19ac87 100644 --- a/apps/server/src/checker/index.ts +++ b/apps/server/src/routes/checker/index.ts @@ -10,9 +10,9 @@ import { } from "@openstatus/db/src/schema/monitors/validation"; import { Redis } from "@openstatus/upstash"; +import { env } from "@/env"; +import { checkerAudit } from "@/utils/audit-log"; import { flyRegions } from "@openstatus/db/src/schema/constants"; -import { env } from "../env"; -import { checkerAudit } from "../utils/audit-log"; import { triggerNotifications, upsertMonitorStatus } from "./alerting"; export const checkerRoute = new Hono(); diff --git a/apps/server/src/checker/utils.ts b/apps/server/src/routes/checker/utils.ts similarity index 100% rename from apps/server/src/checker/utils.ts rename to apps/server/src/routes/checker/utils.ts diff --git a/apps/server/src/public/index.ts b/apps/server/src/routes/public/index.ts similarity index 79% rename from apps/server/src/public/index.ts rename to apps/server/src/routes/public/index.ts index 5324b187..36d4f2c2 100644 --- a/apps/server/src/public/index.ts +++ b/apps/server/src/routes/public/index.ts @@ -1,13 +1,11 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; -import { logger } from "hono/logger"; import { timing } from "hono/timing"; import { status } from "./status"; export const publicRoute = new Hono(); publicRoute.use("*", cors()); -publicRoute.use("*", logger()); publicRoute.use("*", timing()); publicRoute.route("/status", status); diff --git a/apps/server/src/public/status.ts b/apps/server/src/routes/public/status.ts similarity index 69% rename from apps/server/src/public/status.ts rename to apps/server/src/routes/public/status.ts index 4896f9cb..44748b43 100644 --- a/apps/server/src/public/status.ts +++ b/apps/server/src/routes/public/status.ts @@ -14,7 +14,7 @@ import { import { Status, Tracker } from "@openstatus/tracker"; import { Redis } from "@openstatus/upstash"; -import { notEmpty } from "../utils/not-empty"; +import { notEmpty } from "@/utils/not-empty"; // TODO: include ratelimiting @@ -23,51 +23,56 @@ const redis = Redis.fromEnv(); export const status = new Hono(); status.get("/:slug", async (c) => { - const { slug } = c.req.param(); - - const cache = await redis.get(slug); - - if (cache) { - setMetric(c, "OpenStatus-Cache", "HIT"); - return c.json({ status: cache }); - } - - startTime(c, "database"); - - const currentPage = await db - .select() - .from(page) - .where(eq(page.slug, slug)) - .get(); - - if (!currentPage) { + try { + const { slug } = c.req.param(); + + const cache = await redis.get(slug); + + if (cache) { + setMetric(c, "OpenStatus-Cache", "HIT"); + return c.json({ status: cache }); + } + + startTime(c, "database"); + + const currentPage = await db + .select() + .from(page) + .where(eq(page.slug, slug)) + .get(); + + if (!currentPage) { + return c.json({ status: Status.Unknown }); + } + + const { + pageStatusReportData, + monitorStatusReportData, + ongoingIncidents, + maintenanceData, + } = await getStatusPageData(currentPage.id); + endTime(c, "database"); + + const statusReports = [...monitorStatusReportData].map((item) => { + return item.status_report; + }); + + statusReports.push(...pageStatusReportData); + + const tracker = new Tracker({ + incidents: ongoingIncidents, + statusReports, + maintenances: maintenanceData, + }); + + const status = tracker.currentStatus; + await redis.set(slug, status, { ex: 60 }); // 1m cache + + return c.json({ status }); + } catch (e) { + console.error(`Error in public status page: ${e}`); return c.json({ status: Status.Unknown }); } - - const { - pageStatusReportData, - monitorStatusReportData, - ongoingIncidents, - maintenanceData, - } = await getStatusPageData(currentPage.id); - endTime(c, "database"); - - const statusReports = [...monitorStatusReportData].map((item) => { - return item.status_report; - }); - - statusReports.push(...pageStatusReportData); - - const tracker = new Tracker({ - incidents: ongoingIncidents, - statusReports, - maintenances: maintenanceData, - }); - - const status = tracker.currentStatus; - await redis.set(slug, status, { ex: 60 }); // 1m cache - - return c.json({ status }); }); async function getStatusPageData(pageId: number) { diff --git a/apps/server/src/v1/check/http/post.test.ts b/apps/server/src/routes/v1/check/http/post.test.ts similarity index 95% rename from apps/server/src/v1/check/http/post.test.ts rename to apps/server/src/routes/v1/check/http/post.test.ts index 36641b5e..7f148baf 100644 --- a/apps/server/src/v1/check/http/post.test.ts +++ b/apps/server/src/routes/v1/check/http/post.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { api } from "../../index"; - import { afterEach, mock } from "bun:test"; +import { app } from "@/index"; +// @ts-expect-error - FIXME: requires a function... const mockFetch = mock(); global.fetch = mockFetch; @@ -30,7 +30,7 @@ test("Create a single check ", async () => { ), ); - const res = await api.request("/check/http", { + const res = await app.request("/v1/check/http", { method: "POST", headers: { "x-openstatus-key": "1", @@ -99,7 +99,7 @@ test.todo("Create a multiple check ", async () => { ), ); - const res = await api.request("/check", { + const res = await app.request("/v1/check", { method: "POST", headers: { "x-openstatus-key": "1", diff --git a/apps/server/src/v1/check/http/post.ts b/apps/server/src/routes/v1/check/http/post.ts similarity index 93% rename from apps/server/src/v1/check/http/post.ts rename to apps/server/src/routes/v1/check/http/post.ts index 854c04ba..a38b366e 100644 --- a/apps/server/src/v1/check/http/post.ts +++ b/apps/server/src/routes/v1/check/http/post.ts @@ -1,11 +1,11 @@ import { createRoute, type z } from "@hono/zod-openapi"; +import { env } from "@/env"; +import { openApiErrorResponses } from "@/libs/errors"; import { db } from "@openstatus/db"; import { check } from "@openstatus/db/src/schema/check"; import percentile from "percentile"; -import { env } from "../../../env"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; -import type { checkAPI } from "../index"; +import type { checkApi } from "../index"; import { AggregatedResponseSchema, AggregatedResult, @@ -16,8 +16,8 @@ import { const postRoute = createRoute({ method: "post", - tags: ["page"], - description: "Run a single check", + tags: ["check"], + summary: "Run a single check", path: "/http", request: { body: { @@ -42,10 +42,10 @@ const postRoute = createRoute({ }, }); -export function registerHTTPPostCheck(api: typeof checkAPI) { +export function registerHTTPPostCheck(api: typeof checkApi) { return api.openapi(postRoute, async (c) => { const data = c.req.valid("json"); - const workspaceId = Number(c.get("workspaceId")); + const workspaceId = c.get("workspace").id; const input = c.req.valid("json"); const { headers, regions, runCount, aggregated, ...rest } = data; @@ -53,7 +53,7 @@ export function registerHTTPPostCheck(api: typeof checkAPI) { const newCheck = await db .insert(check) .values({ - workspaceId: Number(workspaceId), + workspaceId: workspaceId, regions: regions.join(","), countRequests: runCount, ...rest, diff --git a/apps/server/src/v1/check/http/schema.ts b/apps/server/src/routes/v1/check/http/schema.ts similarity index 100% rename from apps/server/src/v1/check/http/schema.ts rename to apps/server/src/routes/v1/check/http/schema.ts diff --git a/apps/server/src/v1/check/index.ts b/apps/server/src/routes/v1/check/index.ts similarity index 52% rename from apps/server/src/v1/check/index.ts rename to apps/server/src/routes/v1/check/index.ts index d384cd88..3f1e1db0 100644 --- a/apps/server/src/v1/check/index.ts +++ b/apps/server/src/routes/v1/check/index.ts @@ -2,13 +2,13 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { Variables } from "../index"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import { registerHTTPPostCheck } from "./http/post"; -const checkAPI = new OpenAPIHono<{ Variables: Variables }>({ +const checkApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); -registerHTTPPostCheck(checkAPI); +registerHTTPPostCheck(checkApi); -export { checkAPI }; +export { checkApi }; diff --git a/apps/server/src/routes/v1/incidents/get.test.ts b/apps/server/src/routes/v1/incidents/get.test.ts new file mode 100644 index 00000000..ddd2ead6 --- /dev/null +++ b/apps/server/src/routes/v1/incidents/get.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { IncidentSchema } from "./schema"; + +test("return the incident", async () => { + const res = await app.request("/v1/incident/2", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = IncidentSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/incident/2"); + + expect(res.status).toBe(401); +}); + +test("invalid incident id should return 404", async () => { + const res = await app.request("/v1/incident/2", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/incidents/get.ts b/apps/server/src/routes/v1/incidents/get.ts similarity index 75% rename from apps/server/src/v1/incidents/get.ts rename to apps/server/src/routes/v1/incidents/get.ts index cf58b386..871c0489 100644 --- a/apps/server/src/v1/incidents/get.ts +++ b/apps/server/src/routes/v1/incidents/get.ts @@ -3,15 +3,14 @@ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { incidentsApi } from "./index"; import { IncidentSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["incident"], - description: "Get an incident", + summary: "Get an incident", path: "/:id", request: { params: ParamsSchema, @@ -31,7 +30,7 @@ const getRoute = createRoute({ export function registerGetIncident(app: typeof incidentsApi) { return app.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _incident = await db @@ -39,14 +38,17 @@ export function registerGetIncident(app: typeof incidentsApi) { .from(incidentTable) .where( and( - eq(incidentTable.workspaceId, Number(workspaceId)), + eq(incidentTable.workspaceId, workspaceId), eq(incidentTable.id, Number(id)), ), ) .get(); if (!_incident) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Incident ${id} not found`, + }); } const data = IncidentSchema.parse(_incident); diff --git a/apps/server/src/routes/v1/incidents/get_all.test.ts b/apps/server/src/routes/v1/incidents/get_all.test.ts new file mode 100644 index 00000000..77de0cd4 --- /dev/null +++ b/apps/server/src/routes/v1/incidents/get_all.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { IncidentSchema } from "./schema"; + +test("return all incidents", async () => { + const res = await app.request("/v1/incident", { + method: "GET", + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = IncidentSchema.array().safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.length).toBeGreaterThan(0); +}); + +test("return empty incidents", async () => { + const res = await app.request("/v1/incident", { + method: "GET", + headers: { + "x-openstatus-key": "2", + }, + }); + + const result = IncidentSchema.array().safeParse(await res.json()); + + expect(result.success).toBe(true); + expect(res.status).toBe(200); + expect(result.data?.length).toBe(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/incident", { + method: "GET", + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/incidents/get_all.ts b/apps/server/src/routes/v1/incidents/get_all.ts similarity index 52% rename from apps/server/src/v1/incidents/get_all.ts rename to apps/server/src/routes/v1/incidents/get_all.ts index d7efe2f1..5f8e16bf 100644 --- a/apps/server/src/v1/incidents/get_all.ts +++ b/apps/server/src/routes/v1/incidents/get_all.ts @@ -1,24 +1,23 @@ -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; import { db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { openApiErrorResponses } from "@/libs/errors"; import type { incidentsApi } from "./index"; import { IncidentSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["incident"], - description: "Get all Incidents", + summary: "List all incidents", path: "/", request: {}, responses: { 200: { content: { "application/json": { - schema: z.array(IncidentSchema), + schema: IncidentSchema.array(), }, }, description: "Get all incidents", @@ -29,19 +28,16 @@ const getAllRoute = createRoute({ export function registerGetAllIncidents(app: typeof incidentsApi) { app.openapi(getAllRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const _incidents = await db .select() .from(incidentTable) - .where(eq(incidentTable.workspaceId, Number(workspaceId))) + .where(eq(incidentTable.workspaceId, workspaceId)) .all(); - if (!_incidents) { - throw new HTTPException(404, { message: "Not Found" }); - } + const data = IncidentSchema.array().parse(_incidents); - const returnValues = z.array(IncidentSchema).parse(_incidents); // TODO: think of using safeParse with SchemaError.fromZod - return c.json(returnValues, 200); + return c.json(data, 200); }); } diff --git a/apps/server/src/v1/incidents/index.ts b/apps/server/src/routes/v1/incidents/index.ts similarity index 90% rename from apps/server/src/v1/incidents/index.ts rename to apps/server/src/routes/v1/incidents/index.ts index 8385c12b..59f27633 100644 --- a/apps/server/src/v1/incidents/index.ts +++ b/apps/server/src/routes/v1/incidents/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetIncident } from "./get"; import { registerGetAllIncidents } from "./get_all"; diff --git a/apps/server/src/routes/v1/incidents/put.test.ts b/apps/server/src/routes/v1/incidents/put.test.ts new file mode 100644 index 00000000..676be424 --- /dev/null +++ b/apps/server/src/routes/v1/incidents/put.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { IncidentSchema } from "./schema"; + +test("acknlowledge the incident", async () => { + const date = new Date(); + date.setMilliseconds(0); + + const res = await app.request("/v1/incident/2", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: date.toISOString(), + }), + }); + + const result = IncidentSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.acknowledgedAt?.toISOString()).toBe(date.toISOString()); +}); + +test("resolve the incident", async () => { + const date = new Date(); + date.setMilliseconds(0); + + const res = await app.request("/v1/incident/2", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + resolvedAt: date.toISOString(), + }), + }); + + const result = IncidentSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.resolvedAt?.toISOString()).toBe(date.toISOString()); +}); + +test("invalid payload should return 400", async () => { + const res = await app.request("/v1/incident/2", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: "helloworld", + }), + }); + + const result = (await res.json()) as Record; + expect(result.message).toBe("invalid_date in 'acknowledgedAt': Invalid date"); + expect(res.status).toBe(400); +}); + +test("invalid incident id should return 404", async () => { + const res = await app.request("/v1/incident/404", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: new Date().toISOString(), + }), + }); + + expect(res.status).toBe(404); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/incident/2", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: new Date().toISOString(), + }), + }); + expect(res.status).toBe(401); +}); + +test("update the incident with invalid data should return 400", async () => { + const res = await app.request("/v1/incident/2", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: "2023-11-0", + }), + }); + expect(res.status).toBe(400); +}); diff --git a/apps/server/src/v1/incidents/put.ts b/apps/server/src/routes/v1/incidents/put.ts similarity index 64% rename from apps/server/src/v1/incidents/put.ts rename to apps/server/src/routes/v1/incidents/put.ts index d0a43a6c..3ef57530 100644 --- a/apps/server/src/v1/incidents/put.ts +++ b/apps/server/src/routes/v1/incidents/put.ts @@ -3,17 +3,17 @@ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; import type { incidentsApi } from "./index"; import { IncidentSchema, ParamsSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["incident"], - description: "Update an incident", + summary: "Update an incident", + description: "Acknowledge or resolve an incident", path: "/:id", middleware: [trackMiddleware(Events.UpdateIncident)], request: { @@ -25,14 +25,7 @@ const putRoute = createRoute({ schema: IncidentSchema.pick({ acknowledgedAt: true, resolvedAt: true, - }) - .extend({ - acknowledgedAt: z.coerce.date().optional(), - resolvedAt: z.coerce.date().optional(), - }) - .openapi({ - description: "The incident to update", - }), + }).partial(), }, }, }, @@ -52,8 +45,8 @@ const putRoute = createRoute({ export function registerPutIncident(app: typeof incidentsApi) { return app.openapi(putRoute, async (c) => { - const inputValues = c.req.valid("json"); - const workspaceId = c.get("workspaceId"); + const input = c.req.valid("json"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _incident = await db @@ -62,22 +55,22 @@ export function registerPutIncident(app: typeof incidentsApi) { .where( and( eq(incidentTable.id, Number(id)), - eq(incidentTable.workspaceId, Number(workspaceId)), + eq(incidentTable.workspaceId, workspaceId), ), ) .get(); if (!_incident) { - throw new HTTPException(404, { message: "Not Found" }); - } - - if (Number(workspaceId) !== _incident.workspaceId) { - throw new HTTPException(401, { message: "Unauthorized" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Incident ${id} not found`, + }); } const _newIncident = await db .update(incidentTable) - .set({ ...inputValues }) + // TODO: we should set the acknowledgedBy and resolvedBy fields + .set({ ...input, updatedAt: new Date() }) .where(eq(incidentTable.id, Number(id))) .returning() .get(); diff --git a/apps/server/src/v1/incidents/schema.ts b/apps/server/src/routes/v1/incidents/schema.ts similarity index 51% rename from apps/server/src/v1/incidents/schema.ts rename to apps/server/src/routes/v1/incidents/schema.ts index 7ceaa0f8..d8a2a477 100644 --- a/apps/server/src/v1/incidents/schema.ts +++ b/apps/server/src/routes/v1/incidents/schema.ts @@ -1,7 +1,5 @@ import { z } from "@hono/zod-openapi"; -import { isoDate } from "../utils"; - export const ParamsSchema = z.object({ id: z .string() @@ -16,45 +14,32 @@ export const ParamsSchema = z.object({ }), }); -export const IncidentSchema = z.object({ - id: z.number().openapi({ - description: "The id of the incident", - example: 1, - }), - startedAt: isoDate.openapi({ - description: "The date the incident started", - }), - monitorId: z - .number() - .openapi({ +export const IncidentSchema = z + .object({ + id: z.number().openapi({ + description: "The id of the incident", + example: 1, + }), + startedAt: z.coerce.date().openapi({ + description: "The date the incident started", + }), + monitorId: z.number().nullable().openapi({ description: "The id of the monitor associated with the incident", example: 1, - }) - .nullable(), - acknowledgedAt: isoDate - .openapi({ + }), + acknowledgedAt: z.coerce.date().optional().nullable().openapi({ description: "The date the incident was acknowledged", - }) - .optional() - .nullable(), - acknowledgedBy: z - .number() - .openapi({ + }), + acknowledgedBy: z.number().nullable().openapi({ description: "The user who acknowledged the incident", - }) - .nullable(), - resolvedAt: isoDate - .openapi({ + }), + resolvedAt: z.coerce.date().optional().nullable().openapi({ description: "The date the incident was resolved", - }) - .optional() - .nullable(), - resolvedBy: z - .number() - .openapi({ + }), + resolvedBy: z.number().nullable().openapi({ description: "The user who resolved the incident", - }) - .nullable(), -}); + }), + }) + .openapi("Incident"); export type IncidentSchema = z.infer; diff --git a/apps/server/src/v1/index.ts b/apps/server/src/routes/v1/index.ts similarity index 51% rename from apps/server/src/v1/index.ts rename to apps/server/src/routes/v1/index.ts index a66ce25e..632fd29d 100644 --- a/apps/server/src/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -1,14 +1,13 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { apiReference } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; -import { logger } from "hono/logger"; +import type { RequestIdVariables } from "hono/request-id"; -import type { WorkspacePlan } from "@openstatus/db/src/schema"; -import type { Limits } from "@openstatus/db/src/schema/plan/schema"; -import { handleError, handleZodError } from "../libs/errors"; -import { checkAPI } from "./check"; +import { handleZodError } from "@/libs/errors"; +import { authMiddleware } from "@/libs/middlewares"; +import type { Workspace } from "@openstatus/db/src/schema"; +import { checkApi } from "./check"; import { incidentsApi } from "./incidents"; -import { secureMiddleware } from "./middleware"; import { monitorsApi } from "./monitors"; import { notificationsApi } from "./notifications"; import { pageSubscribersApi } from "./pageSubscribers"; @@ -17,23 +16,14 @@ import { statusReportUpdatesApi } from "./statusReportUpdates"; import { statusReportsApi } from "./statusReports"; import { whoamiApi } from "./whoami"; -export type Variables = { - workspaceId: string; - workspacePlan: { - title: "Hobby" | "Starter" | "Growth" | "Pro"; - id: WorkspacePlan; - description: string; - price: number; - }; - limits: Limits; +export type Variables = RequestIdVariables & { + workspace: Workspace; }; export const api = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); -api.onError(handleError); - api.use("/openapi", cors()); api.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", { @@ -55,6 +45,53 @@ api.doc("/openapi", { description: "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.", }, + tags: [ + { + name: "monitor", + description: "Monitor related endpoints", + "x-displayName": "Monitor", + }, + { + name: "page", + description: "Page related endpoints", + "x-displayName": "Page", + }, + { + name: "status_report", + description: "Status report related endpoints", + "x-displayName": "Status Report", + }, + { + name: "status_report_update", + description: "Status report update related endpoints", + "x-displayName": "Status Report Update", + }, + { + name: "incident", + description: "Incident related endpoints", + "x-displayName": "Incident", + }, + { + name: "notification", + description: "Notification related endpoints", + "x-displayName": "Notification", + }, + { + name: "page_subscriber", + description: "Page subscriber related endpoints", + "x-displayName": "Page Subscriber", + }, + { + name: "check", + description: "Check related endpoints", + "x-displayName": "Check", + }, + { + name: "whoami", + description: "WhoAmI related endpoints", + "x-displayName": "WhoAmI", + }, + ], security: [ { ApiKeyAuth: [], @@ -69,24 +106,31 @@ api.get( url: "/v1/openapi", }, baseServerURL: "https://api.openstatus.dev/v1", + metaData: { + title: "OpenStatus API", + description: "API Reference", + ogDescription: "API Reference", + ogTitle: "OpenStatus API", + ogImage: + "https://openstatus.dev/api/og?title=OpenStatus%20API&description=API%20Reference", + twitterCard: "summary_large_image", + }, }), ); /** - * Authentification Middleware + * Middlewares */ -api.use("/*", secureMiddleware); -api.use("/*", logger()); +api.use("/*", authMiddleware); /** * Routes */ -api.route("/incident", incidentsApi); api.route("/monitor", monitorsApi); -api.route("/notification", notificationsApi); api.route("/page", pagesApi); -api.route("/page_subscriber", pageSubscribersApi); api.route("/status_report", statusReportsApi); api.route("/status_report_update", statusReportUpdatesApi); -api.route("/check", checkAPI); - +api.route("/incident", incidentsApi); +api.route("/notification", notificationsApi); +api.route("/page_subscriber", pageSubscribersApi); +api.route("/check", checkApi); api.route("/whoami", whoamiApi); diff --git a/apps/server/src/routes/v1/monitors/delete.test.ts b/apps/server/src/routes/v1/monitors/delete.test.ts new file mode 100644 index 00000000..de9a8b6d --- /dev/null +++ b/apps/server/src/routes/v1/monitors/delete.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { app } from "@/index"; + +test("delete the monitor", async () => { + const res = await app.request("/v1/monitor/3", { + method: "DELETE", + headers: { + "x-openstatus-key": "1", + }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({}); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/2", { method: "DELETE" }); + + expect(res.status).toBe(401); +}); + +test("invalid monitor id should return 404", async () => { + const res = await app.request("/v1/monitor/404", { + method: "DELETE", + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/monitors/delete.ts b/apps/server/src/routes/v1/monitors/delete.ts similarity index 65% rename from apps/server/src/v1/monitors/delete.ts rename to apps/server/src/routes/v1/monitors/delete.ts index bbf6e8c5..e31e6dc4 100644 --- a/apps/server/src/v1/monitors/delete.ts +++ b/apps/server/src/routes/v1/monitors/delete.ts @@ -1,19 +1,18 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { db, eq } from "@openstatus/db"; +import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; import type { monitorsApi } from "./index"; import { ParamsSchema } from "./schema"; const deleteRoute = createRoute({ method: "delete", tags: ["monitor"], - description: "Delete a monitor", + summary: "Delete a monitor", path: "/:id", request: { params: ParamsSchema, @@ -26,7 +25,7 @@ const deleteRoute = createRoute({ schema: z.object({}), }, }, - description: "Delete the monitor", + description: "The monitor was successfully deleted", }, ...openApiErrorResponses, }, @@ -34,21 +33,26 @@ const deleteRoute = createRoute({ export function registerDeleteMonitor(app: typeof monitorsApi) { return app.openapi(deleteRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db .select() .from(monitor) - .where(eq(monitor.id, Number(id))) + .where( + and( + eq(monitor.id, Number(id)), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) .get(); if (!_monitor) { - throw new HTTPException(404, { message: "Not Found" }); - } - - if (Number(workspaceId) !== _monitor.workspaceId) { - throw new HTTPException(401, { message: "Unauthorized" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); } await db diff --git a/apps/server/src/routes/v1/monitors/get.test.ts b/apps/server/src/routes/v1/monitors/get.test.ts new file mode 100644 index 00000000..24d13a66 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/get.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("return the monitor", async () => { + const res = await app.request("/v1/monitor/1", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/2"); + + expect(res.status).toBe(401); +}); + +test("invalid monitor id should return 404", async () => { + const res = await app.request("/v1/monitor/2", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/monitors/get.ts b/apps/server/src/routes/v1/monitors/get.ts similarity index 72% rename from apps/server/src/v1/monitors/get.ts rename to apps/server/src/routes/v1/monitors/get.ts index 52b926e3..d4af8ef4 100644 --- a/apps/server/src/v1/monitors/get.ts +++ b/apps/server/src/routes/v1/monitors/get.ts @@ -1,17 +1,16 @@ -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "./index"; import { MonitorSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["monitor"], - description: "Get a monitor", + summary: "Get a monitor", path: "/:id", request: { params: ParamsSchema, @@ -31,7 +30,7 @@ const getRoute = createRoute({ export function registerGetMonitor(api: typeof monitorsApi) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db @@ -40,14 +39,17 @@ export function registerGetMonitor(api: typeof monitorsApi) { .where( and( eq(monitor.id, Number(id)), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); } const data = MonitorSchema.parse(_monitor); diff --git a/apps/server/src/routes/v1/monitors/get_all.test.ts b/apps/server/src/routes/v1/monitors/get_all.test.ts new file mode 100644 index 00000000..9a01c725 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/get_all.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("return all monitors", async () => { + const res = await app.request("/v1/monitor", { + method: "GET", + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = MonitorSchema.array().safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.length).toBeGreaterThan(0); +}); + +test("return empty monitors", async () => { + const res = await app.request("/v1/monitor", { + method: "GET", + headers: { + "x-openstatus-key": "2", + }, + }); + + const result = MonitorSchema.array().safeParse(await res.json()); + + expect(result.success).toBe(true); + expect(res.status).toBe(200); + expect(result.data?.length).toBe(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor", { + method: "GET", + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/monitors/get_all.ts b/apps/server/src/routes/v1/monitors/get_all.ts similarity index 67% rename from apps/server/src/v1/monitors/get_all.ts rename to apps/server/src/routes/v1/monitors/get_all.ts index 1b70e0d3..e253aa28 100644 --- a/apps/server/src/v1/monitors/get_all.ts +++ b/apps/server/src/routes/v1/monitors/get_all.ts @@ -3,15 +3,14 @@ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "./index"; import { MonitorSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["monitor"], - description: "Get all monitors", + summary: "List all monitors", path: "/", request: {}, responses: { @@ -29,23 +28,16 @@ const getAllRoute = createRoute({ export function registerGetAllMonitors(app: typeof monitorsApi) { return app.openapi(getAllRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const _monitors = await db .select() .from(monitor) .where( - and( - eq(monitor.workspaceId, Number(workspaceId)), - isNull(monitor.deletedAt), - ), + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all(); - if (!_monitors) { - throw new HTTPException(404, { message: "Not Found" }); - } - const data = z.array(MonitorSchema).parse(_monitors); return c.json(data, 200); diff --git a/apps/server/src/v1/monitors/index.ts b/apps/server/src/routes/v1/monitors/index.ts similarity index 95% rename from apps/server/src/v1/monitors/index.ts rename to apps/server/src/routes/v1/monitors/index.ts index 9e61a5f5..4cb2ff22 100644 --- a/apps/server/src/v1/monitors/index.ts +++ b/apps/server/src/routes/v1/monitors/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerDeleteMonitor } from "./delete"; import { registerGetMonitor } from "./get"; @@ -20,8 +20,9 @@ registerGetAllMonitors(monitorsApi); registerGetMonitor(monitorsApi); registerPutMonitor(monitorsApi); registerDeleteMonitor(monitorsApi); -registerGetMonitorSummary(monitorsApi); registerPostMonitor(monitorsApi); +// +registerGetMonitorSummary(monitorsApi); registerTriggerMonitor(monitorsApi); registerGetMonitorResult(monitorsApi); registerRunMonitor(monitorsApi); diff --git a/apps/server/src/routes/v1/monitors/post.test.ts b/apps/server/src/routes/v1/monitors/post.test.ts new file mode 100644 index 00000000..3bf77cb5 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("create a valid monitor", async () => { + const res = await app.request("/v1/monitor", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + periodicity: "10m", + url: "https://www.openstatus.dev", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + method: "POST", + body: '{"hello":"world"}', + headers: [{ key: "key", value: "value" }], + active: true, + public: true, + assertions: [ + { + type: "status", + compare: "eq", + target: 200, + }, + { type: "header", compare: "not_eq", key: "key", target: "value" }, + ], + }), + }); + + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a status report with invalid payload should return 400", async () => { + const res = await app.request("/v1/monitor", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + periodicity: 32, //not valid value + url: "https://www.openstatus.dev", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + method: "POST", + body: '{"hello":"world"}', + headers: [{ key: "key", value: "value" }], + active: true, + public: false, + }), + }); + + expect(res.status).toBe(400); +}); + +test("create a monitor with invalid page id should return 400", async () => { + const res = await app.request("/v1/monitor", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitorIds: [1], + pageId: 404, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/monitors/post.ts b/apps/server/src/routes/v1/monitors/post.ts similarity index 63% rename from apps/server/src/v1/monitors/post.ts rename to apps/server/src/routes/v1/monitors/post.ts index 7f405a62..c0b827b1 100644 --- a/apps/server/src/v1/monitors/post.ts +++ b/apps/server/src/routes/v1/monitors/post.ts @@ -4,12 +4,10 @@ import { Events } from "@openstatus/analytics"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { serialize } from "../../../../../packages/assertions/src"; +import { serialize } from "@openstatus/assertions"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import type { monitorsApi } from "./index"; import { MonitorSchema } from "./schema"; import { getAssertions } from "./utils"; @@ -17,7 +15,7 @@ import { getAssertions } from "./utils"; const postRoute = createRoute({ method: "post", tags: ["monitor"], - description: "Create a monitor", + summary: "Create a monitor", path: "/", middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], request: { @@ -45,38 +43,50 @@ const postRoute = createRoute({ export function registerPostMonitor(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const input = c.req.valid("json"); const count = ( await db .select({ count: sql`count(*)` }) .from(monitor) .where( - and( - eq(monitor.workspaceId, Number(workspaceId)), - isNull(monitor.deletedAt), - ), + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all() )[0].count; - if (count >= getLimit(limits, "monitors")) { - throw new HTTPException(403, { + if (count >= limits.monitors) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", message: "Upgrade for more monitors", }); } - if (!getLimit(limits, "periodicity").includes(input.periodicity)) { - throw new HTTPException(403, { message: "Forbidden" }); + if (!limits.periodicity.includes(input.periodicity)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); } for (const region of input.regions) { - if (!getLimit(limits, "regions").includes(region)) { - throw new HTTPException(403, { message: "Upgrade for more region" }); + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); } } + if (input.jobType && !["http", "tcp"].includes(input.jobType)) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: + "Invalid jobType, currently only 'http' and 'tcp' are supported", + }); + } + const { headers, regions, assertions, ...rest } = input; const assert = assertions ? getAssertions(assertions) : []; @@ -85,7 +95,7 @@ export function registerPostMonitor(api: typeof monitorsApi) { .insert(monitor) .values({ ...rest, - workspaceId: Number(workspaceId), + workspaceId: workspaceId, regions: regions ? regions.join(",") : undefined, headers: input.headers ? JSON.stringify(input.headers) : undefined, assertions: assert.length > 0 ? serialize(assert) : undefined, diff --git a/apps/server/src/routes/v1/monitors/put.test.ts b/apps/server/src/routes/v1/monitors/put.test.ts new file mode 100644 index 00000000..c0816dc5 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/put.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("update the monitor", async () => { + const res = await app.request("/v1/monitor/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "New Name", + }), + }); + + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.name).toBe("New Name"); +}); + +test("update the monitor with a different jobType should return 400", async () => { + const res = await app.request("/v1/monitor/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jobType: "tcp", + }), + }); + + expect(res.status).toBe(400); +}); + +test("invalid monitor id should return 404", async () => { + const res = await app.request("/v1/page/404", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + /* */ + }), + }); + + expect(res.status).toBe(404); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page/2", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + /* */ + }), + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/monitors/put.ts b/apps/server/src/routes/v1/monitors/put.ts similarity index 55% rename from apps/server/src/v1/monitors/put.ts rename to apps/server/src/routes/v1/monitors/put.ts index d880cf6f..5ba8e6cb 100644 --- a/apps/server/src/v1/monitors/put.ts +++ b/apps/server/src/routes/v1/monitors/put.ts @@ -1,13 +1,12 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { and, db, eq } from "@openstatus/db"; +import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; -import { HTTPException } from "hono/http-exception"; -import { serialize } from "../../../../../packages/assertions/src/serializing"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; +import { serialize } from "@openstatus/assertions"; import type { monitorsApi } from "./index"; import { MonitorSchema, ParamsSchema } from "./schema"; import { getAssertions } from "./utils"; @@ -15,7 +14,7 @@ import { getAssertions } from "./utils"; const putRoute = createRoute({ method: "put", tags: ["monitor"], - description: "Update a monitor", + summary: "Update a monitor", path: "/:id", middleware: [trackMiddleware(Events.UpdateMonitor)], request: { @@ -24,7 +23,7 @@ const putRoute = createRoute({ description: "The monitor to update", content: { "application/json": { - schema: MonitorSchema.omit({ id: true }), + schema: MonitorSchema.omit({ id: true }).partial(), }, }, }, @@ -44,32 +43,54 @@ const putRoute = createRoute({ export function registerPutMonitor(api: typeof monitorsApi) { return api.openapi(putRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); - if (!limits.periodicity.includes(input.periodicity)) { - throw new HTTPException(403, { message: "Forbidden" }); + if (input.periodicity && !limits.periodicity.includes(input.periodicity)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); } - for (const region of input.regions) { - if (!limits.regions.includes(region)) { - throw new HTTPException(403, { message: "Upgrade for more region" }); + if (input.regions) { + for (const region of input.regions) { + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } } } + const _monitor = await db .select() .from(monitor) - .where(eq(monitor.id, Number(id))) + .where( + and( + eq(monitor.id, Number(id)), + isNull(monitor.deletedAt), + eq(monitor.workspaceId, workspaceId), + ), + ) .get(); if (!_monitor) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); } - if (Number(workspaceId) !== _monitor.workspaceId) { - throw new HTTPException(401, { message: "Unauthorized" }); + if (input.jobType && input.jobType !== _monitor.jobType) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: + "Cannot change jobType. Please delete and create a new monitor instead.", + }); } const { headers, regions, assertions, ...rest } = input; @@ -84,6 +105,7 @@ export function registerPutMonitor(api: typeof monitorsApi) { headers: input.headers ? JSON.stringify(input.headers) : undefined, assertions: assert.length > 0 ? serialize(assert) : undefined, timeout: input.timeout || 45000, + updatedAt: new Date(), }) .where(eq(monitor.id, Number(_monitor.id))) .returning() diff --git a/apps/server/src/v1/monitors/results/get.ts b/apps/server/src/routes/v1/monitors/results/get.ts similarity index 62% rename from apps/server/src/v1/monitors/results/get.ts rename to apps/server/src/routes/v1/monitors/results/get.ts index 099f3d7a..d442c6da 100644 --- a/apps/server/src/v1/monitors/results/get.ts +++ b/apps/server/src/routes/v1/monitors/results/get.ts @@ -1,22 +1,23 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { and, db, eq, isNull } from "@openstatus/db"; +import { and, db, eq } from "@openstatus/db"; import { monitor, monitorRun } from "@openstatus/db/src/schema"; import { OSTinybird } from "@openstatus/tinybird"; -import { flyRegions } from "@openstatus/db/src/schema/constants"; -import { HTTPException } from "hono/http-exception"; -import { env } from "../../../env"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; +import { env } from "@/env"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "../index"; import { ParamsSchema, ResultRun } from "../schema"; const tb = new OSTinybird(env.TINY_BIRD_API_KEY); -const getMonitorStats = createRoute({ +const getRoute = createRoute({ method: "get", tags: ["monitor"], - description: "Get a monitor result", + summary: "Get a monitor result", + // FIXME: Should work for all types of monitors + description: + "**WARNING:** This works only for HTTP monitors. We will add support for other types of monitors soon.", path: "/:id/result/:resultId", request: { params: ParamsSchema.extend({ @@ -29,18 +30,18 @@ const getMonitorStats = createRoute({ 200: { content: { "application/json": { - schema: z.array(ResultRun), + schema: ResultRun.array(), }, }, - description: "All the metrics for the monitor", + description: "All the metrics for the result id from the monitor", }, ...openApiErrorResponses, }, }); export function registerGetMonitorResult(api: typeof monitorsApi) { - return api.openapi(getMonitorStats, async (c) => { - const workspaceId = c.get("workspaceId"); + return api.openapi(getRoute, async (c) => { + const workspaceId = c.get("workspace").id; const { id, resultId } = c.req.valid("param"); const _monitorRun = await db @@ -50,13 +51,16 @@ export function registerGetMonitorResult(api: typeof monitorsApi) { and( eq(monitorRun.id, Number(resultId)), eq(monitorRun.monitorId, Number(id)), - eq(monitorRun.workspaceId, Number(workspaceId)), + eq(monitorRun.workspaceId, workspaceId), ), ) .get(); if (!_monitorRun || !_monitorRun?.runnedAt) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor run ${resultId} not found`, + }); } const _monitor = await db @@ -66,18 +70,19 @@ export function registerGetMonitorResult(api: typeof monitorsApi) { .get(); if (!_monitor) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); } + // Fetch result from tb pipe const data = await tb.getResultForOnDemandCheckHttp({ monitorId: _monitor.id, timestamp: _monitorRun.runnedAt?.getTime(), url: _monitor.url, }); - // return array of results - if (!data || data.data.length === 0) { - throw new HTTPException(404, { message: "Not Found" }); - } + return c.json(data.data, 200); }); } diff --git a/apps/server/src/v1/monitors/run/post.ts b/apps/server/src/routes/v1/monitors/run/post.ts similarity index 57% rename from apps/server/src/v1/monitors/run/post.ts rename to apps/server/src/routes/v1/monitors/run/post.ts index 91926b35..5f0ec03d 100644 --- a/apps/server/src/v1/monitors/run/post.ts +++ b/apps/server/src/routes/v1/monitors/run/post.ts @@ -1,3 +1,6 @@ +import { env } from "@/env"; +import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; +import { openApiErrorResponses } from "@/libs/errors"; import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, gte, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; @@ -6,43 +9,27 @@ import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/mon import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; -import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; import { HTTPException } from "hono/http-exception"; import type { monitorsApi } from ".."; -import { env } from "../../../env"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; -import { - HTTPTriggerResult, - ParamsSchema, - TCPTriggerResult, - TriggerResult, -} from "../schema"; - -const triggerMonitor = createRoute({ +import { ParamsSchema, TriggerResult } from "../schema"; +import { QuerySchema } from "./schema"; + +const postMonitor = createRoute({ method: "post", tags: ["monitor"], - description: "Run a monitor check", + summary: "Create a monitor run", + description: + "Run a synthetic check for a specific monitor. It will take all configs into account.", path: "/:id/run", request: { params: ParamsSchema, - query: z - .object({ - "no-wait": z.coerce - .boolean() - .optional() - .openapi({ - description: "Don't wait for the result", - }) - .default(false), - }) - .openapi({}), + query: QuerySchema, }, responses: { 200: { content: { "application/json": { - schema: z.array(TriggerResult), + schema: TriggerResult.array(), }, }, description: "All the historical metrics", @@ -52,10 +39,10 @@ const triggerMonitor = createRoute({ }); export function registerRunMonitor(api: typeof monitorsApi) { - return api.openapi(triggerMonitor, async (c) => { - const workspaceId = c.get("workspaceId"); + return api.openapi(postMonitor, async (c) => { + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); - const limits = c.get("limits"); + const limits = c.get("workspace").limits; const { "no-wait": noWait } = c.req.valid("query"); const lastMonth = new Date().setMonth(new Date().getMonth() - 1); @@ -65,14 +52,14 @@ export function registerRunMonitor(api: typeof monitorsApi) { .from(monitorRun) .where( and( - eq(monitorRun.workspaceId, Number(workspaceId)), + eq(monitorRun.workspaceId, workspaceId), gte(monitorRun.createdAt, new Date(lastMonth)), ), ) .all() )[0].count; - if (count >= getLimit(limits, "synthetic-checks")) { + if (count >= limits["synthetic-checks"]) { throw new HTTPException(403, { message: "Upgrade for more checks", }); @@ -84,7 +71,7 @@ export function registerRunMonitor(api: typeof monitorsApi) { .where( and( eq(monitor.id, Number(id)), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) @@ -110,9 +97,10 @@ export function registerRunMonitor(api: typeof monitorsApi) { .where(eq(monitorStatusTable.monitorId, monitorData.id)) .all(); - const monitorStatus = z - .array(selectMonitorStatusSchema) + const monitorStatus = selectMonitorStatusSchema + .array() .safeParse(monitorStatusData); + if (!monitorStatus.success) { console.log(monitorStatus.error); throw new HTTPException(400, { message: "Something went wrong" }); @@ -137,47 +125,9 @@ export function registerRunMonitor(api: typeof monitorsApi) { for (const region of parseMonitor.data.regions) { const status = monitorStatus.data.find((m) => region === m.region)?.status || "active"; - // Trigger the monitor - - let payload: - | z.infer - | z.infer - | null = null; - // - if (row.jobType === "http") { - payload = { - workspaceId: String(row.workspaceId), - monitorId: String(row.id), - url: row.url, - method: row.method || "GET", - cronTimestamp: timestamp, - body: row.body, - headers: row.headers, - status: status, - assertions: row.assertions ? JSON.parse(row.assertions) : null, - degradedAfter: row.degradedAfter, - timeout: row.timeout, - trigger: "api", - }; - } - if (row.jobType === "tcp") { - payload = { - workspaceId: String(row.workspaceId), - monitorId: String(row.id), - uri: row.url, - status: status, - assertions: row.assertions ? JSON.parse(row.assertions) : null, - cronTimestamp: timestamp, - degradedAfter: row.degradedAfter, - timeout: row.timeout, - trigger: "api", - }; - } - - if (!payload) { - throw new Error("Invalid jobType"); - } - const url = generateUrl({ row }); + const payload = getCheckerPayload(row, status); + const url = getCheckerUrl(row, { data: true }); + const result = fetch(url, { headers: { "Content-Type": "application/json", @@ -195,12 +145,9 @@ export function registerRunMonitor(api: typeof monitorsApi) { } const result = await Promise.all(allResult); - // console.log(result); - const bodies = await Promise.all(result.map((r) => r.json())); - // let data = null; - const data = z.array(TriggerResult).safeParse(bodies); + const data = TriggerResult.array().safeParse(bodies); if (!data) { throw new HTTPException(400, { message: "Something went wrong" }); @@ -214,14 +161,3 @@ export function registerRunMonitor(api: typeof monitorsApi) { return c.json(data.data, 200); }); } - -function generateUrl({ row }: { row: z.infer }) { - switch (row.jobType) { - case "http": - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}&trigger=api&data=true`; - case "tcp": - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}&trigger=api&data=true`; - default: - throw new Error("Invalid jobType"); - } -} diff --git a/apps/server/src/routes/v1/monitors/run/schema.ts b/apps/server/src/routes/v1/monitors/run/schema.ts new file mode 100644 index 00000000..06cbc489 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/run/schema.ts @@ -0,0 +1,11 @@ +import { z } from "@hono/zod-openapi"; + +export const QuerySchema = z + .object({ + "no-wait": z.coerce.boolean().optional().default(false).openapi({ + description: "Don't wait for the result", + }), + }) + .openapi({ + description: "Query parameters", + }); diff --git a/apps/server/src/v1/monitors/schema.ts b/apps/server/src/routes/v1/monitors/schema.ts similarity index 91% rename from apps/server/src/v1/monitors/schema.ts rename to apps/server/src/routes/v1/monitors/schema.ts index ec581b29..3e40a51f 100644 --- a/apps/server/src/v1/monitors/schema.ts +++ b/apps/server/src/routes/v1/monitors/schema.ts @@ -1,15 +1,12 @@ import { z } from "@hono/zod-openapi"; +import { numberCompare, stringCompare } from "@openstatus/assertions"; import { monitorJobTypes, monitorMethods } from "@openstatus/db/src/schema"; import { flyRegions, monitorPeriodicitySchema, } from "@openstatus/db/src/schema/constants"; import { ZodError } from "zod"; -import { - numberCompare, - stringCompare, -} from "../../../../../packages/assertions/src/v1"; const statusAssertion = z .object({ @@ -124,13 +121,10 @@ export const MonitorSchema = z example: "Documenso", description: "The name of the monitor", }), - description: z - .string() - .openapi({ - example: "Documenso website", - description: "The description of your monitor", - }) - .optional(), + description: z.string().optional().openapi({ + example: "Documenso website", + description: "The description of your monitor", + }), method: z.enum(monitorMethods).default("GET").openapi({ example: "GET" }), body: z .preprocess((val) => { @@ -205,21 +199,15 @@ export const MonitorSchema = z timeout: z.number().nullish().default(45000).openapi({ description: "The timeout of the request", }), - jobType: z - .enum(monitorJobTypes) - .openapi({ - description: "The type of the monitor", - }) - .default("http") - .optional(), + jobType: z.enum(monitorJobTypes).optional().default("http").openapi({ + description: "The type of the monitor", + }), }) - .openapi({ - description: "The monitor", - required: ["periodicity", "url", "regions", "method"], - }); + .openapi("Monitor"); export type MonitorSchema = z.infer; +// TODO: Move to @/libs/checker/schema const timingSchema = z.object({ dnsStart: z.number(), dnsDone: z.number(), @@ -233,6 +221,8 @@ const timingSchema = z.object({ transferDone: z.number(), }); +// Use a baseSchema with 'latency', 'region', 'timestamp' + export const HTTPTriggerResult = z.object({ jobType: z.literal("http"), status: z.number(), @@ -255,6 +245,7 @@ export const TCPTriggerResult = z.object({ region: z.enum(flyRegions), timestamp: z.number(), timing: tcptimingSchema, + // check if it should be z.coerce.boolean()? error: z.number().optional().nullable(), errorMessage: z.string().optional().nullable(), }); @@ -269,10 +260,7 @@ export const ResultRun = z.object({ statusCode: z.number().int().nullable().default(null), monitorId: z.string().default(""), url: z.string().optional(), - error: z - .number() - .default(0) - .transform((val) => val !== 0), + error: z.coerce.boolean().default(false), region: z.enum(flyRegions), timestamp: z.number().int().optional(), message: z.string().nullable().optional(), diff --git a/apps/server/src/routes/v1/monitors/summary/get.test.ts b/apps/server/src/routes/v1/monitors/summary/get.test.ts new file mode 100644 index 00000000..0b3d8dba --- /dev/null +++ b/apps/server/src/routes/v1/monitors/summary/get.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { z } from "@hono/zod-openapi"; + +import { app } from "@/index"; +import { SummarySchema } from "./schema"; + +test("return the summary of the monitor", async () => { + const res = await app.request("/v1/monitor/1/summary", { + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = z + .object({ data: SummarySchema.array() }) + .safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/1/summary"); + + expect(res.status).toBe(401); +}); + +test("invalid monitor id should return 404", async () => { + const res = await app.request("/v1/monitor/404/summary", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/monitors/summary/get.ts b/apps/server/src/routes/v1/monitors/summary/get.ts similarity index 53% rename from apps/server/src/v1/monitors/summary/get.ts rename to apps/server/src/routes/v1/monitors/summary/get.ts index 134c5341..b2f82093 100644 --- a/apps/server/src/v1/monitors/summary/get.ts +++ b/apps/server/src/routes/v1/monitors/summary/get.ts @@ -5,35 +5,25 @@ import { monitor } from "@openstatus/db/src/schema"; import { OSTinybird } from "@openstatus/tinybird"; import { Redis } from "@openstatus/upstash"; -import { HTTPException } from "hono/http-exception"; -import { env } from "../../../env"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; -import { isoDate } from "../../utils"; +import { env } from "@/env"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "../index"; -import { ParamsSchema } from "../schema"; +import { ParamsSchema, SummarySchema } from "./schema"; + +// TODO: is there another better way to mock Redis/Tinybird? +if (process.env.NODE_ENV === "test") { + require("@/libs/test/preload"); +} const tb = new OSTinybird(env.TINY_BIRD_API_KEY); const redis = Redis.fromEnv(); -const dailyStatsSchema = z.object({ - ok: z.number().int().openapi({ - description: "The number of ok responses", - }), - count: z - .number() - .int() - .openapi({ description: "The total number of request" }), - day: isoDate, -}); - -const dailyStatsSchemaArray = z - .array(dailyStatsSchema) - .openapi({ description: "The daily stats" }); - const getMonitorStats = createRoute({ method: "get", tags: ["monitor"], - description: "Get a monitor daily summary", + summary: "Get a monitor summary", + description: + "Get a monitor summary of the last 45 days of data to be used within a status page", path: "/:id/summary", request: { params: ParamsSchema, @@ -43,7 +33,7 @@ const getMonitorStats = createRoute({ content: { "application/json": { schema: z.object({ - data: dailyStatsSchemaArray, + data: SummarySchema.array(), }), }, }, @@ -55,7 +45,7 @@ const getMonitorStats = createRoute({ export function registerGetMonitorSummary(api: typeof monitorsApi) { return api.openapi(getMonitorStats, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db @@ -64,31 +54,33 @@ export function registerGetMonitorSummary(api: typeof monitorsApi) { .where( and( eq(monitor.id, Number(id)), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); } - const cache = await redis.get>( - `${id}-daily-stats`, - ); + const cache = await redis.get(`${id}-daily-stats`); + if (cache) { console.log("fetching from cache"); return c.json({ data: cache }, 200); } console.log("fetching from tinybird"); - const res = await tb.httpStatus45d({ monitorId: id }); + const res = + _monitor.jobType === "http" + ? await tb.httpStatus45d({ monitorId: id }) + : await tb.tcpStatus45d({ monitorId: id }); - if (!res || res.data.length === 0) { - throw new HTTPException(404, { message: "Not Found" }); - } - await redis.set(`${id}-daily-stats`, res, { ex: 600 }); + await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); return c.json({ data: res.data }, 200); }); diff --git a/apps/server/src/routes/v1/monitors/summary/schema.ts b/apps/server/src/routes/v1/monitors/summary/schema.ts new file mode 100644 index 00000000..129fd025 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/summary/schema.ts @@ -0,0 +1,20 @@ +import { z } from "@hono/zod-openapi"; +import { ParamsSchema } from "../schema"; + +export { ParamsSchema }; + +export const SummarySchema = z.object({ + ok: z.number().int().openapi({ + description: + "The number of ok responses (defined by the assertions - or by default status code 200)", + }), + count: z + .number() + .int() + .openapi({ description: "The total number of request" }), + day: z.coerce + .date() + .openapi({ description: "The date of the daily stat in ISO8601 format" }), +}); + +export type SummarySchema = z.infer; diff --git a/apps/server/src/routes/v1/monitors/trigger/post.ts b/apps/server/src/routes/v1/monitors/trigger/post.ts new file mode 100644 index 00000000..0e9a9cd5 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/trigger/post.ts @@ -0,0 +1,156 @@ +import { env } from "@/env"; +import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { createRoute, z } from "@hono/zod-openapi"; +import { and, eq, gte, isNull, sql } from "@openstatus/db"; +import { db } from "@openstatus/db/src/db"; +import { monitorRun } from "@openstatus/db/src/schema"; +import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; +import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; +import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; +import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; +import { HTTPException } from "hono/http-exception"; +import type { monitorsApi } from ".."; +import { ParamsSchema, TriggerSchema } from "./schema"; + +const postRoute = createRoute({ + method: "post", + tags: ["monitor"], + summary: "Create a monitor trigger", + description: "Trigger a monitor check without waiting the result", + path: "/:id/trigger", + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: TriggerSchema, + }, + }, + description: + "Returns a result id that can be used to get the result of your trigger", + }, + ...openApiErrorResponses, + }, +}); + +export function registerTriggerMonitor(api: typeof monitorsApi) { + return api.openapi(postRoute, async (c) => { + const workspaceId = c.get("workspace").id; + const { id } = c.req.valid("param"); + const limits = c.get("workspace").limits; + + const lastMonth = new Date().setMonth(new Date().getMonth() - 1); + + const count = ( + await db + .select({ count: sql`count(*)` }) + .from(monitorRun) + .where( + and( + eq(monitorRun.workspaceId, workspaceId), + gte(monitorRun.createdAt, new Date(lastMonth)), + ), + ) + .all() + )[0].count; + + if (count >= limits["synthetic-checks"]) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more checks", + }); + } + + const _monitor = await db + .select() + .from(monitor) + .where( + and( + eq(monitor.id, Number(id)), + eq(monitor.workspaceId, workspaceId), + isNull(monitor.deletedAt), + ), + ) + .get(); + + if (!_monitor) { + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Monitor ${id} not found`, + }); + } + + const validateMonitor = selectMonitorSchema.safeParse(_monitor); + + if (!validateMonitor.success) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: "Invalid monitor, please contact support", + }); + } + + const row = validateMonitor.data; + + // Maybe later overwrite the region + + const _monitorStatus = await db + .select() + .from(monitorStatusTable) + .where(eq(monitorStatusTable.monitorId, _monitor.id)) + .all(); + + const monitorStatus = z + .array(selectMonitorStatusSchema) + .safeParse(_monitorStatus); + + if (!monitorStatus.success) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: "Invalid monitor status, please contact support", + }); + } + + const timestamp = Date.now(); + + const newRun = await db + .insert(monitorRun) + .values({ + monitorId: row.id, + workspaceId: row.workspaceId, + runnedAt: new Date(timestamp), + }) + .returning(); + + if (!newRun[0]) { + throw new HTTPException(400, { message: "Something went wrong" }); + } + + const allResult = []; + + for (const region of validateMonitor.data.regions) { + const status = + monitorStatus.data.find((m) => region === m.region)?.status || "active"; + const payload = getCheckerPayload(row, status); + const url = getCheckerUrl(row); + + const result = fetch(url, { + headers: { + "Content-Type": "application/json", + "fly-prefer-region": region, // Specify the region you want the request to be sent to + Authorization: `Basic ${env.CRON_SECRET}`, + }, + method: "POST", + body: JSON.stringify(payload), + }); + + allResult.push(result); + } + + await Promise.all(allResult); + + return c.json({ resultId: newRun[0].id }, 200); + }); +} diff --git a/apps/server/src/routes/v1/monitors/trigger/schema.ts b/apps/server/src/routes/v1/monitors/trigger/schema.ts new file mode 100644 index 00000000..4d111ee8 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/trigger/schema.ts @@ -0,0 +1,10 @@ +import { z } from "@hono/zod-openapi"; +import { ParamsSchema } from "../schema"; + +export { ParamsSchema }; + +export const TriggerSchema = z.object({ + resultId: z.number().openapi({ description: "the id of your check result" }), +}); + +export type TriggerSchema = z.infer; diff --git a/apps/server/src/v1/monitors/utils.ts b/apps/server/src/routes/v1/monitors/utils.ts similarity index 83% rename from apps/server/src/v1/monitors/utils.ts rename to apps/server/src/routes/v1/monitors/utils.ts index c49bba86..847e9523 100644 --- a/apps/server/src/v1/monitors/utils.ts +++ b/apps/server/src/routes/v1/monitors/utils.ts @@ -1,10 +1,10 @@ -import type { z } from "zod"; -import type { Assertion } from "../../../../../packages/assertions/src"; +import type { Assertion } from "@openstatus/assertions"; import { HeaderAssertion, StatusAssertion, TextBodyAssertion, -} from "../../../../../packages/assertions/src/v1"; +} from "@openstatus/assertions"; +import type { z } from "zod"; import type { assertion } from "./schema"; export const getAssertions = ( diff --git a/apps/server/src/routes/v1/notifications/get.test.ts b/apps/server/src/routes/v1/notifications/get.test.ts new file mode 100644 index 00000000..5517e5b9 --- /dev/null +++ b/apps/server/src/routes/v1/notifications/get.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { NotificationSchema } from "./schema"; + +test("return the notification", async () => { + const res = await app.request("/v1/notification/1", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = NotificationSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/notification/1"); + + expect(res.status).toBe(401); +}); + +test("invalid notification id should return 404", async () => { + const res = await app.request("/v1/notification/404", { + headers: { + "x-openstatus-key": "1", + }, + }); + + expect(res.status).toBe(404); +}); + +test("invalid auth key should return 404", async () => { + const res = await app.request("/v1/notification/1", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/notifications/get.ts b/apps/server/src/routes/v1/notifications/get.ts similarity index 74% rename from apps/server/src/v1/notifications/get.ts rename to apps/server/src/routes/v1/notifications/get.ts index aa3ab7cd..6918af1b 100644 --- a/apps/server/src/v1/notifications/get.ts +++ b/apps/server/src/routes/v1/notifications/get.ts @@ -1,20 +1,18 @@ import { createRoute } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { and, db, eq } from "@openstatus/db"; import { notification, notificationsToMonitors, - page, } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; import type { notificationsApi } from "./index"; import { NotificationSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["notification"], - description: "Get a notification", + summary: "Get a notification", path: "/:id", request: { params: ParamsSchema, @@ -34,7 +32,7 @@ const getRoute = createRoute({ export function registerGetNotification(api: typeof notificationsApi) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _notification = await db @@ -42,28 +40,29 @@ export function registerGetNotification(api: typeof notificationsApi) { .from(notification) .where( and( - eq(page.workspaceId, Number(workspaceId)), + eq(notification.workspaceId, workspaceId), eq(notification.id, Number(id)), ), ) .get(); if (!_notification) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Notification ${id} not found`, + }); } - const linkedMonitors = await db + const _monitors = await db .select() .from(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, Number(id))) .all(); - const monitors = linkedMonitors.map((m) => m.monitorId); - const data = NotificationSchema.parse({ ..._notification, payload: JSON.parse(_notification.data || "{}"), - monitors, + monitors: _monitors.map((m) => m.monitorId), }); return c.json(data, 200); diff --git a/apps/server/src/routes/v1/notifications/get_all.test.ts b/apps/server/src/routes/v1/notifications/get_all.test.ts new file mode 100644 index 00000000..fd584c87 --- /dev/null +++ b/apps/server/src/routes/v1/notifications/get_all.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { NotificationSchema } from "./schema"; + +test("return all notifications", async () => { + const res = await app.request("/v1/notification", { + method: "GET", + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = NotificationSchema.array().safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.length).toBeGreaterThan(0); +}); + +test("return empty notifications", async () => { + const res = await app.request("/v1/notification", { + method: "GET", + headers: { + "x-openstatus-key": "2", + }, + }); + + const result = NotificationSchema.array().safeParse(await res.json()); + + expect(result.success).toBe(true); + expect(res.status).toBe(200); + expect(result.data?.length).toBe(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/notification", { + method: "GET", + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/routes/v1/notifications/get_all.ts b/apps/server/src/routes/v1/notifications/get_all.ts new file mode 100644 index 00000000..36972f09 --- /dev/null +++ b/apps/server/src/routes/v1/notifications/get_all.ts @@ -0,0 +1,64 @@ +import { createRoute } from "@hono/zod-openapi"; + +import { openApiErrorResponses } from "@/libs/errors"; +import { db, eq, inArray } from "@openstatus/db"; +import { + notification, + notificationsToMonitors, +} from "@openstatus/db/src/schema"; +import type { notificationsApi } from "./index"; +import { NotificationSchema } from "./schema"; + +const getAllRoute = createRoute({ + method: "get", + tags: ["notification"], + summary: "List all notifications", + path: "/", + + responses: { + 200: { + content: { + "application/json": { + schema: NotificationSchema.array(), + }, + }, + description: "Get all your workspace notification", + }, + ...openApiErrorResponses, + }, +}); + +export function registerGetAllNotifications(app: typeof notificationsApi) { + return app.openapi(getAllRoute, async (c) => { + const workspaceId = c.get("workspace").id; + + const _notifications = await db + .select() + .from(notification) + .where(eq(notification.workspaceId, workspaceId)) + .all(); + + const _monitors = await db + .select() + .from(notificationsToMonitors) + .where( + inArray( + notificationsToMonitors.notificationId, + _notifications.map((n) => n.id), + ), + ) + .all(); + + const data = NotificationSchema.array().parse( + _notifications.map((n) => ({ + ...n, + payload: JSON.parse(n.data || "{}"), + monitors: _monitors + .filter((m) => m.notificationId === n.id) + .map((m) => m.monitorId), + })), + ); + + return c.json(data, 200); + }); +} diff --git a/apps/server/src/v1/notifications/index.ts b/apps/server/src/routes/v1/notifications/index.ts similarity index 90% rename from apps/server/src/v1/notifications/index.ts rename to apps/server/src/routes/v1/notifications/index.ts index 9666cba8..a2ced08b 100644 --- a/apps/server/src/v1/notifications/index.ts +++ b/apps/server/src/routes/v1/notifications/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetNotification } from "./get"; import { registerGetAllNotifications } from "./get_all"; diff --git a/apps/server/src/routes/v1/notifications/post.test.ts b/apps/server/src/routes/v1/notifications/post.test.ts new file mode 100644 index 00000000..15988494 --- /dev/null +++ b/apps/server/src/routes/v1/notifications/post.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { NotificationSchema } from "./schema"; + +test("create a notification", async () => { + const res = await app.request("/v1/notification", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "OpenStatus", + provider: "email", + payload: { email: "ping@openstatus.dev" }, + monitors: [1], + }), + }); + + const result = NotificationSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a notification with invalid monitor ids should return a 400", async () => { + const res = await app.request("/v1/notification", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "OpenStatus", + provider: "email", + payload: { email: "ping@openstatus.dev" }, + monitors: [404], + }), + }); + + expect(res.status).toBe(400); +}); + +test("create a email notification with invalid payload should return a 400", async () => { + const res = await app.request("/v1/notification", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "OpenStatus", + provider: "email", + payload: { hello: "world" }, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/notification", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/notifications/post.ts b/apps/server/src/routes/v1/notifications/post.ts similarity index 73% rename from apps/server/src/v1/notifications/post.ts rename to apps/server/src/routes/v1/notifications/post.ts index 2bcd5d9e..c778e0f4 100644 --- a/apps/server/src/v1/notifications/post.ts +++ b/apps/server/src/routes/v1/notifications/post.ts @@ -1,5 +1,7 @@ import { createRoute } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { and, db, eq, inArray, isNull, sql } from "@openstatus/db"; import { @@ -9,17 +11,13 @@ import { notificationsToMonitors, selectNotificationSchema, } from "@openstatus/db/src/schema"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; import type { notificationsApi } from "./index"; import { NotificationSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["notification"], - description: "Create a notification", + summary: "Create a notification", path: "/", middleware: [trackMiddleware(Events.CreateNotification)], request: { @@ -47,25 +45,29 @@ const postRoute = createRoute({ export function registerPostNotification(api: typeof notificationsApi) { return api.openapi(postRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - const workspacePlan = c.get("workspacePlan"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const workspacePlan = c.get("workspace").plan; + const limits = c.get("workspace").limits; const input = c.req.valid("json"); - if (input.provider === "sms" && workspacePlan.title === "Hobby") { - throw new HTTPException(403, { message: "Upgrade for SMS" }); + if (input.provider === "sms" && workspacePlan === "free") { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for SMS", + }); } const count = ( await db .select({ count: sql`count(*)` }) .from(notification) - .where(eq(notification.workspaceId, Number(workspaceId))) + .where(eq(notification.workspaceId, workspaceId)) .all() )[0].count; - if (count >= getLimit(limits, "notification-channels")) { - throw new HTTPException(403, { + if (count >= limits["notification-channels"]) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", message: "Upgrade for more notification channels", }); } @@ -79,14 +81,17 @@ export function registerPostNotification(api: typeof notificationsApi) { .where( and( inArray(monitor.id, monitors), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitors.length) { - throw new HTTPException(400, { message: "Monitor not found" }); + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Some of the monitors ${monitors.join(", ")} not found`, + }); } } @@ -94,7 +99,7 @@ export function registerPostNotification(api: typeof notificationsApi) { .insert(notification) .values({ ...rest, - workspaceId: Number(workspaceId), + workspaceId: workspaceId, data: JSON.stringify(payload), }) .returning() diff --git a/apps/server/src/routes/v1/notifications/schema.ts b/apps/server/src/routes/v1/notifications/schema.ts new file mode 100644 index 00000000..0f217d40 --- /dev/null +++ b/apps/server/src/routes/v1/notifications/schema.ts @@ -0,0 +1,47 @@ +import { z } from "@hono/zod-openapi"; +import { + NotificationDataSchema, + notificationProviderSchema, +} from "@openstatus/db/src/schema"; + +export const ParamsSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "The id of the notification", + example: "1", + }), +}); + +export const NotificationSchema = z + .object({ + id: z + .number() + .openapi({ description: "The id of the notification", example: 1 }), + name: z.string().openapi({ + description: "The name of the notification", + example: "OpenStatus Discord", + }), + provider: notificationProviderSchema.openapi({ + description: "The provider of the notification", + example: "discord", + }), + payload: NotificationDataSchema.openapi({ + description: "The data of the notification", + }), + monitors: z + .array(z.number()) + .nullish() + .openapi({ + description: "The monitors that the notification is linked to", + example: [1, 2], + }), + }) + .openapi("Notification"); + +export type NotificationSchema = z.infer; diff --git a/apps/server/src/v1/pageSubscribers/index.ts b/apps/server/src/routes/v1/pageSubscribers/index.ts similarity index 85% rename from apps/server/src/v1/pageSubscribers/index.ts rename to apps/server/src/routes/v1/pageSubscribers/index.ts index 1bf82be6..ce15ce2e 100644 --- a/apps/server/src/v1/pageSubscribers/index.ts +++ b/apps/server/src/routes/v1/pageSubscribers/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerPostPageSubscriber } from "./post"; diff --git a/apps/server/src/routes/v1/pageSubscribers/post.test.ts b/apps/server/src/routes/v1/pageSubscribers/post.test.ts new file mode 100644 index 00000000..4c1cf77a --- /dev/null +++ b/apps/server/src/routes/v1/pageSubscribers/post.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { PageSubscriberSchema } from "./schema"; + +test("create a page subscription", async () => { + const res = await app.request("/v1/page_subscriber/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ email: "ping@openstatus.dev" }), + }); + + const result = PageSubscriberSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a scubscriber with invalid email should return a 400", async () => { + const res = await app.request("/v1/page_subscriber/1/update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ email: "ping" }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page_subscriber/1/update", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/pageSubscribers/post.ts b/apps/server/src/routes/v1/pageSubscribers/post.ts similarity index 66% rename from apps/server/src/v1/pageSubscribers/post.ts rename to apps/server/src/routes/v1/pageSubscribers/post.ts index 1c355a8a..e68801e1 100644 --- a/apps/server/src/v1/pageSubscribers/post.ts +++ b/apps/server/src/routes/v1/pageSubscribers/post.ts @@ -1,27 +1,30 @@ import { createRoute } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; +import { Events } from "@openstatus/analytics"; import { and, eq } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { page, pageSubscriber } from "@openstatus/db/src/schema"; import { SubscribeEmail } from "@openstatus/emails"; import { sendEmail } from "@openstatus/emails/src/send"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; import type { pageSubscribersApi } from "./index"; import { PageSubscriberSchema, ParamsSchema } from "./schema"; const postRouteSubscriber = createRoute({ method: "post", - tags: ["page"], + tags: ["page_subscriber"], + summary: "Subscribe to a status page", path: "/:id/update", - description: "Add a subscriber to a status page", + middleware: [trackMiddleware(Events.SubscribePage)], + description: "Add a subscriber to a status page", // TODO: how to define legacy routes request: { params: ParamsSchema, body: { - description: "the subscriber payload", + description: "The subscriber payload", content: { "application/json": { - schema: PageSubscriberSchema, + schema: PageSubscriberSchema.pick({ email: true }), }, }, }, @@ -33,7 +36,7 @@ const postRouteSubscriber = createRoute({ schema: PageSubscriberSchema, }, }, - description: "The user", + description: "The user has been subscribed", }, ...openApiErrorResponses, }, @@ -41,27 +44,29 @@ const postRouteSubscriber = createRoute({ export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { return api.openapi(postRouteSubscriber, async (c) => { - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const input = c.req.valid("json"); const { id } = c.req.valid("param"); if (!limits["status-subscribers"]) { - throw new HTTPException(403, { - message: "Upgrade for status page subscribers", + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for status subscribers", }); } const _page = await db .select() .from(page) - .where( - and(eq(page.id, Number(id)), eq(page.workspaceId, Number(workspaceId))), - ) + .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { - throw new HTTPException(401, { message: "Unauthorized" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Page ${id} not found`, + }); } const alreadySubscribed = await db @@ -76,25 +81,15 @@ export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { .get(); if (alreadySubscribed) { - throw new HTTPException(400, { - message: "Bad request - Already subscribed", + throw new OpenStatusApiError({ + code: "CONFLICT", + message: `Email ${input.email} already subscribed`, }); } - const token = (Math.random() + 1).toString(36).substring(10); + const token = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); - await sendEmail({ - react: SubscribeEmail({ - domain: _page.slug, - token, - page: _page.title, - }), - from: "OpenStatus ", - to: [input.email], - subject: "Verify your subscription", - }); - const _statusReportSubscriberUpdate = await db .insert(pageSubscriber) .values({ @@ -106,6 +101,17 @@ export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { .returning() .get(); + await sendEmail({ + react: SubscribeEmail({ + domain: _page.slug, + token, + page: _page.title, + }), + from: "OpenStatus ", + to: [input.email], + subject: "Verify your subscription", + }); + const data = PageSubscriberSchema.parse(_statusReportSubscriberUpdate); return c.json(data, 200); diff --git a/apps/server/src/routes/v1/pageSubscribers/schema.ts b/apps/server/src/routes/v1/pageSubscribers/schema.ts new file mode 100644 index 00000000..741094a5 --- /dev/null +++ b/apps/server/src/routes/v1/pageSubscribers/schema.ts @@ -0,0 +1,33 @@ +import { z } from "@hono/zod-openapi"; + +export const ParamsSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "The id of the page", + example: "1", + }), +}); + +export const PageSubscriberSchema = z + .object({ + id: z.number().openapi({ + description: "The id of the subscriber", + example: 1, + }), + email: z.string().email().openapi({ + description: "The email of the subscriber", + }), + pageId: z.number().openapi({ + description: "The id of the page to subscribe to", + example: 1, + }), + }) + .openapi("PageSubscriber"); + +export type PageSubscriberSchema = z.infer; diff --git a/apps/server/src/routes/v1/pages/get.test.ts b/apps/server/src/routes/v1/pages/get.test.ts new file mode 100644 index 00000000..6aac697a --- /dev/null +++ b/apps/server/src/routes/v1/pages/get.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { PageSchema } from "./schema"; + +test("return the page", async () => { + const res = await app.request("/v1/page/1", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = PageSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page/2"); + + expect(res.status).toBe(401); +}); + +test("invalid page id should return 404", async () => { + const res = await app.request("/v1/page/2", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/pages/get.ts b/apps/server/src/routes/v1/pages/get.ts similarity index 71% rename from apps/server/src/v1/pages/get.ts rename to apps/server/src/routes/v1/pages/get.ts index 09b9eae9..cc3d5ff0 100644 --- a/apps/server/src/v1/pages/get.ts +++ b/apps/server/src/routes/v1/pages/get.ts @@ -1,17 +1,16 @@ import { createRoute, z } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { and, eq } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { page } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; import type { pagesApi } from "./index"; import { PageSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["page"], - description: "Get a status page", + summary: "Get a status page", path: "/:id", request: { params: ParamsSchema, @@ -31,19 +30,20 @@ const getRoute = createRoute({ export function registerGetPage(api: typeof pagesApi) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _page = await db .select() .from(page) - .where( - and(eq(page.workspaceId, Number(workspaceId)), eq(page.id, Number(id))), - ) + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id)))) .get(); if (!_page) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Page ${id} not found`, + }); } const data = PageSchema.parse(_page); diff --git a/apps/server/src/routes/v1/pages/get_all.test.ts b/apps/server/src/routes/v1/pages/get_all.test.ts new file mode 100644 index 00000000..836e0bbd --- /dev/null +++ b/apps/server/src/routes/v1/pages/get_all.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { PageSchema } from "./schema"; + +test("return all pages", async () => { + const res = await app.request("/v1/page", { + method: "GET", + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = PageSchema.array().safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.length).toBeGreaterThan(0); +}); + +test("return empty pages", async () => { + const res = await app.request("/v1/page", { + method: "GET", + headers: { + "x-openstatus-key": "2", + }, + }); + + const result = PageSchema.array().safeParse(await res.json()); + + expect(result.success).toBe(true); + expect(res.status).toBe(200); + expect(result.data?.length).toBe(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page", { + method: "GET", + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/pages/get_all.ts b/apps/server/src/routes/v1/pages/get_all.ts similarity index 52% rename from apps/server/src/v1/pages/get_all.ts rename to apps/server/src/routes/v1/pages/get_all.ts index 3fccfe16..4d4f4212 100644 --- a/apps/server/src/v1/pages/get_all.ts +++ b/apps/server/src/routes/v1/pages/get_all.ts @@ -1,25 +1,24 @@ -import { createRoute, z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { openApiErrorResponses } from "@/libs/errors"; import { db, eq } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; import type { pagesApi } from "./index"; import { PageSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["page"], - description: "Get all your status page", + summary: "List all status pages", path: "/", responses: { 200: { content: { "application/json": { - schema: z.array(PageSchema), + schema: PageSchema.array(), }, }, - description: "Get an Status page", + description: "A list of your status pages", }, ...openApiErrorResponses, }, @@ -27,18 +26,14 @@ const getAllRoute = createRoute({ export function registerGetAllPages(api: typeof pagesApi) { return api.openapi(getAllRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const _pages = await db .select() .from(page) - .where(eq(page.workspaceId, Number(workspaceId))); + .where(eq(page.workspaceId, workspaceId)); - if (!_pages) { - throw new HTTPException(404, { message: "Not Found" }); - } - - const data = z.array(PageSchema).parse(_pages); + const data = PageSchema.array().parse(_pages); return c.json(data, 200); }); diff --git a/apps/server/src/v1/pages/index.ts b/apps/server/src/routes/v1/pages/index.ts similarity index 90% rename from apps/server/src/v1/pages/index.ts rename to apps/server/src/routes/v1/pages/index.ts index 9cc8ebbc..bfacfcb7 100644 --- a/apps/server/src/v1/pages/index.ts +++ b/apps/server/src/routes/v1/pages/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetPage } from "./get"; import { registerGetAllPages } from "./get_all"; diff --git a/apps/server/src/routes/v1/pages/post.test.ts b/apps/server/src/routes/v1/pages/post.test.ts new file mode 100644 index 00000000..afdcd5ca --- /dev/null +++ b/apps/server/src/routes/v1/pages/post.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { PageSchema } from "./schema"; + +test("create a valid page", async () => { + const res = await app.request("/v1/page", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "OpenStatus", + description: "OpenStatus website", + slug: "openstatus", + monitors: [1], + }), + }); + + const result = PageSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a page with invalid monitor ids should return a 400", async () => { + const res = await app.request("/v1/page", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "OpenStatus", + description: "OpenStatus website", + slug: "another-openstatus", + monitors: [404], + }), + }); + + expect(res.status).toBe(400); +}); + +test("create a page with password on free plan should return a 402", async () => { + const res = await app.request("/v1/page", { + method: "POST", + headers: { + "x-openstatus-key": "2", + "content-type": "application/json", + }, + body: JSON.stringify({ + title: "OpenStatus", + description: "OpenStatus website", + slug: "password-openstatus", + passwordProtected: true, + }), + }); + + expect(res.status).toBe(402); +}); + +test("create a email page with invalid payload should return a 400", async () => { + const res = await app.request("/v1/page", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "OpenStatus", + provider: "email", + payload: { hello: "world" }, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/pages/post.ts b/apps/server/src/routes/v1/pages/post.ts similarity index 69% rename from apps/server/src/v1/pages/post.ts rename to apps/server/src/routes/v1/pages/post.ts index ee044d3b..d773a778 100644 --- a/apps/server/src/v1/pages/post.ts +++ b/apps/server/src/routes/v1/pages/post.ts @@ -2,13 +2,16 @@ import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; -import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; - +import { + monitor, + monitorsToPages, + page, + subdomainSafeList, +} from "@openstatus/db/src/schema"; + +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; import { isNumberArray } from "../utils"; import type { pagesApi } from "./index"; import { PageSchema } from "./schema"; @@ -16,7 +19,7 @@ import { PageSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["page"], - description: "Create a status page", + summary: "Create a status page", path: "/", middleware: [trackMiddleware(Events.CreatePage, ["slug"])], request: { @@ -44,18 +47,20 @@ const postRoute = createRoute({ export function registerPostPage(api: typeof pagesApi) { return api.openapi(postRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const input = c.req.valid("json"); if (input.customDomain && !limits["custom-domain"]) { - throw new HTTPException(403, { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", message: "Upgrade for custom domains", }); } if (input.customDomain?.toLowerCase().includes("openstatus")) { - throw new HTTPException(400, { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", message: "Domain cannot contain 'openstatus'", }); } @@ -64,25 +69,34 @@ export function registerPostPage(api: typeof pagesApi) { await db .select({ count: sql`count(*)` }) .from(page) - .where(eq(page.workspaceId, Number(workspaceId))) + .where(eq(page.workspaceId, workspaceId)) .all() )[0].count; - if (count >= getLimit(limits, "status-pages")) { - throw new HTTPException(403, { + if (count >= limits["status-pages"]) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", message: "Upgrade for more status pages", }); } if ( - getLimit(limits, "password-protection") === false && - input?.passwordProtected === true + !limits["password-protection"] && + (input?.passwordProtected || input?.password) ) { - throw new HTTPException(403, { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", message: "Upgrade for password protection", }); } + if (subdomainSafeList.includes(input.slug)) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: "Slug is reserved", + }); + } + const countSlug = ( await db .select({ count: sql`count(*)` }) @@ -92,7 +106,8 @@ export function registerPostPage(api: typeof pagesApi) { )[0].count; if (countSlug > 0) { - throw new HTTPException(409, { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", message: "Slug has to be unique and has already been taken", }); } @@ -110,14 +125,17 @@ export function registerPostPage(api: typeof pagesApi) { .where( and( inArray(monitor.id, monitorIds), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitors.length) { - throw new HTTPException(400, { message: "Monitor not found" }); + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Some of the monitors ${monitorIds.join(", ")} not found`, + }); } } @@ -125,7 +143,7 @@ export function registerPostPage(api: typeof pagesApi) { .insert(page) .values({ ...rest, - workspaceId: Number(workspaceId), + workspaceId: workspaceId, customDomain: rest.customDomain ?? "", // TODO: make database migration to allow null }) .returning() diff --git a/apps/server/src/routes/v1/pages/put.test.ts b/apps/server/src/routes/v1/pages/put.test.ts new file mode 100644 index 00000000..d6f5e6b9 --- /dev/null +++ b/apps/server/src/routes/v1/pages/put.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { PageSchema } from "./schema"; + +test("update the page with monitor ids", async () => { + const res = await app.request("/v1/page/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: "New Title", + monitors: [1, 2], + }), + }); + + const result = PageSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.title).toBe("New Title"); + expect(result.data?.monitors).toEqual([1, 2]); +}); + +test("update the page with monitor objects", async () => { + const res = await app.request("/v1/page/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + monitors: [ + { monitorId: 1, order: 1 }, + { monitorId: 2, order: 2 }, + ], + }), + }); + + const result = PageSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.monitors).toEqual([ + { monitorId: 1, order: 1 }, + { monitorId: 2, order: 2 }, + ]); +}); + +test("update the page with invalid monitors should return 400", async () => { + const res = await app.request("/v1/page/1", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + monitors: [404], + }), + }); + expect(res.status).toBe(400); +}); + +test("invalid page id should return 404", async () => { + const res = await app.request("/v1/page/404", { + method: "PUT", + headers: { + "x-openstatus-key": "1", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: new Date().toISOString(), + }), + }); + + expect(res.status).toBe(404); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/page/2", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + acknowledgedAt: new Date().toISOString(), + }), + }); + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/pages/put.ts b/apps/server/src/routes/v1/pages/put.ts similarity index 68% rename from apps/server/src/v1/pages/put.ts rename to apps/server/src/routes/v1/pages/put.ts index a68a800a..73756db9 100644 --- a/apps/server/src/v1/pages/put.ts +++ b/apps/server/src/routes/v1/pages/put.ts @@ -1,12 +1,16 @@ import { createRoute } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; -import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { trackMiddleware } from "../middleware"; +import { + monitor, + monitorsToPages, + page, + subdomainSafeList, +} from "@openstatus/db/src/schema"; import { isNumberArray } from "../utils"; import type { pagesApi } from "./index"; import { PageSchema, ParamsSchema } from "./schema"; @@ -14,7 +18,7 @@ import { PageSchema, ParamsSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["page"], - description: "Update a status page", + summary: "Update a status page", path: "/:id", middleware: [trackMiddleware(Events.UpdatePage)], request: { @@ -23,7 +27,6 @@ const putRoute = createRoute({ description: "The monitor to update", content: { "application/json": { - // REMINDER: allow only partial updates schema: PageSchema.omit({ id: true }).partial(), }, }, @@ -44,19 +47,21 @@ const putRoute = createRoute({ export function registerPutPage(api: typeof pagesApi) { return api.openapi(putRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.customDomain && !limits["custom-domain"]) { - throw new HTTPException(403, { - message: "Upgrade for custom domains", + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for custom domain", }); } if (input.customDomain?.toLowerCase().includes("openstatus")) { - throw new HTTPException(400, { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", message: "Domain cannot contain 'openstatus'", }); } @@ -65,24 +70,33 @@ export function registerPutPage(api: typeof pagesApi) { limits["password-protection"] === false && input?.passwordProtected === true ) { - throw new HTTPException(403, { - message: "Forbidden - Upgrade for password protection", + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for password protection", }); } const _page = await db .select() .from(page) - .where( - and(eq(page.id, Number(id)), eq(page.workspaceId, Number(workspaceId))), - ) + .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Page ${id} not found`, + }); } if (input.slug && _page.slug !== input.slug) { + if (subdomainSafeList.includes(input.slug)) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: "Slug is reserved", + }); + } + const countSlug = ( await db .select({ count: sql`count(*)` }) @@ -92,11 +106,13 @@ export function registerPutPage(api: typeof pagesApi) { )[0].count; if (countSlug > 0) { - throw new HTTPException(400, { - message: "Forbidden - Slug already taken", + throw new OpenStatusApiError({ + code: "CONFLICT", + message: "Slug has to be unique and has already been taken", }); } } + const { monitors, ...rest } = input; const monitorIds = monitors @@ -112,22 +128,27 @@ export function registerPutPage(api: typeof pagesApi) { .where( and( inArray(monitor.id, monitorIds), - eq(monitor.workspaceId, Number(workspaceId)), + eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (monitorsData.length !== monitors.length) { - throw new HTTPException(400, { - message: "Not Found - Wrong monitor configuration", + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Some of the monitors ${monitorIds.join(", ")} not found`, }); } } const newPage = await db .update(page) - .set({ ...rest, customDomain: input.customDomain ?? "" }) + .set({ + ...rest, + customDomain: input.customDomain ?? "", + updatedAt: new Date(), + }) .where(eq(page.id, _page.id)) .returning() .get(); @@ -167,7 +188,10 @@ export function registerPutPage(api: typeof pagesApi) { } } - const data = PageSchema.parse(newPage); + const data = PageSchema.parse({ + ...newPage, + monitors: monitors || currentMonitorsToPages, + }); return c.json(data, 200); }); diff --git a/apps/server/src/routes/v1/pages/schema.ts b/apps/server/src/routes/v1/pages/schema.ts new file mode 100644 index 00000000..39f040f9 --- /dev/null +++ b/apps/server/src/routes/v1/pages/schema.ts @@ -0,0 +1,92 @@ +import { z } from "@hono/zod-openapi"; + +export const ParamsSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "The id of the page", + example: "1", + }), +}); + +export const PageSchema = z + .object({ + id: z.number().openapi({ + description: "The id of the page", + example: 1, + }), + title: z.string().openapi({ + description: "The title of the page", + example: "My Page", + }), + description: z.string().openapi({ + description: "The description of the page", + example: "My awesome status page", + }), + slug: z.string().openapi({ + description: "The slug of the page", + example: "my-page", + }), + // REMINDER: needs to be configured on Dashboard UI + customDomain: z + .string() + .transform((val) => (val ? val : undefined)) + .nullish() + .openapi({ + description: + "The custom domain of the page. To be configured within the dashboard.", + example: "status.acme.com", + }), + icon: z + .string() + .url() + .or(z.literal("")) + .transform((val) => (val ? val : undefined)) + .nullish() + .openapi({ + description: "The icon of the page", + example: "https://example.com/icon.png", + }), + passwordProtected: z.boolean().optional().default(false).openapi({ + description: + "Make the page password protected. Used with the 'passwordProtected' property.", + example: true, + }), + password: z.string().optional().nullish().openapi({ + description: "Your password to protect the page from the public", + example: "hidden-password", + }), + showMonitorValues: z.boolean().optional().nullish().default(true).openapi({ + description: + "Displays the total and failed request numbers for each monitor", + example: true, + }), + monitors: z + .array(z.number()) + .openapi({ + description: + "The monitors of the page as an array of ids. We recommend using the object format to include the order.", + deprecated: true, + example: [1, 2], + }) + .or( + z + .array(z.object({ monitorId: z.number(), order: z.number() })) + .openapi({ + description: "The monitor as object allowing to pass id and order", + example: [ + { monitorId: 1, order: 0 }, + { monitorId: 2, order: 1 }, + ], + }), + ) + .optional(), + }) + .openapi("Page"); + +export type PageSchema = z.infer; diff --git a/apps/server/src/routes/v1/statusReportUpdates/get.test.ts b/apps/server/src/routes/v1/statusReportUpdates/get.test.ts new file mode 100644 index 00000000..92674f2c --- /dev/null +++ b/apps/server/src/routes/v1/statusReportUpdates/get.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { StatusReportUpdateSchema } from "./schema"; + +test("return the status report update", async () => { + const res = await app.request("/v1/status_report_update/2", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = StatusReportUpdateSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report_update/2"); + + expect(res.status).toBe(401); +}); + +test("invalid status report id should return 404", async () => { + const res = await app.request("/v1/status_report_update/2", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/statusReportUpdates/get.ts b/apps/server/src/routes/v1/statusReportUpdates/get.ts similarity index 72% rename from apps/server/src/v1/statusReportUpdates/get.ts rename to apps/server/src/routes/v1/statusReportUpdates/get.ts index 4ef933db..16110115 100644 --- a/apps/server/src/v1/statusReportUpdates/get.ts +++ b/apps/server/src/routes/v1/statusReportUpdates/get.ts @@ -3,15 +3,14 @@ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportUpdatesApi } from "./index"; import { ParamsSchema, StatusReportUpdateSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["status_report_update"], - description: "Get a Status Reports Update", + summary: "Get a status report update", path: "/:id", request: { params: ParamsSchema, @@ -33,28 +32,31 @@ export function registerGetStatusReportUpdate( api: typeof statusReportUpdatesApi, ) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); - const _statusReportJoin = await db + const _statusReport = await db .select() .from(statusReportUpdate) .innerJoin( statusReport, and( eq(statusReport.id, statusReportUpdate.statusReportId), - eq(statusReport.workspaceId, Number(workspaceId)), + eq(statusReport.workspaceId, workspaceId), ), ) .where(eq(statusReportUpdate.id, Number(id))) .get(); - if (!_statusReportJoin) { - throw new HTTPException(404, { message: "Not Found" }); + if (!_statusReport) { + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Status Report Update ${id} not found`, + }); } const data = StatusReportUpdateSchema.parse( - _statusReportJoin.status_report_update, + _statusReport.status_report_update, ); return c.json(data, 200); diff --git a/apps/server/src/v1/statusReportUpdates/index.ts b/apps/server/src/routes/v1/statusReportUpdates/index.ts similarity index 89% rename from apps/server/src/v1/statusReportUpdates/index.ts rename to apps/server/src/routes/v1/statusReportUpdates/index.ts index 07efd7d5..daa8de71 100644 --- a/apps/server/src/v1/statusReportUpdates/index.ts +++ b/apps/server/src/routes/v1/statusReportUpdates/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetStatusReportUpdate } from "./get"; import { registerPostStatusReportUpdate } from "./post"; diff --git a/apps/server/src/routes/v1/statusReportUpdates/post.test.ts b/apps/server/src/routes/v1/statusReportUpdates/post.test.ts new file mode 100644 index 00000000..693f2b91 --- /dev/null +++ b/apps/server/src/routes/v1/statusReportUpdates/post.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { StatusReportUpdateSchema } from "./schema"; + +test("create a valid status report update", async () => { + const res = await app.request("/v1/status_report_update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + date: new Date().toISOString(), + message: "Message", + statusReportId: 1, + }), + }); + + const result = StatusReportUpdateSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a status report update without valid payload should return 400", async () => { + const res = await app.request("/v1/status_report_update", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + date: "test", + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report_update", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/statusReportUpdates/post.ts b/apps/server/src/routes/v1/statusReportUpdates/post.ts similarity index 82% rename from apps/server/src/v1/statusReportUpdates/post.ts rename to apps/server/src/routes/v1/statusReportUpdates/post.ts index d33d075d..004bc205 100644 --- a/apps/server/src/v1/statusReportUpdates/post.ts +++ b/apps/server/src/routes/v1/statusReportUpdates/post.ts @@ -9,19 +9,18 @@ import { } from "@openstatus/db/src/schema"; import { sendEmailHtml } from "@openstatus/emails"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportUpdatesApi } from "./index"; import { StatusReportUpdateSchema } from "./schema"; const createStatusUpdate = createRoute({ method: "post", tags: ["status_report_update"], - description: "Create a Status Report Update", + summary: "Create a status report update", path: "/", request: { body: { - description: "the status report update", + description: "The status report update to create", content: { "application/json": { schema: StatusReportUpdateSchema.omit({ id: true }), @@ -36,7 +35,7 @@ const createStatusUpdate = createRoute({ schema: StatusReportUpdateSchema, }, }, - description: "Get all status report updates", + description: "The created status report update", }, ...openApiErrorResponses, }, @@ -46,9 +45,9 @@ export function registerPostStatusReportUpdate( api: typeof statusReportUpdatesApi, ) { return api.openapi(createStatusUpdate, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const input = c.req.valid("json"); - const limits = c.get("limits"); + const limits = c.get("workspace").limits; const _statusReport = await db .select() @@ -56,15 +55,15 @@ export function registerPostStatusReportUpdate( .where( and( eq(statusReport.id, input.statusReportId), - eq(statusReport.workspaceId, Number(workspaceId)), + eq(statusReport.workspaceId, workspaceId), ), ) .get(); if (!_statusReport) { - throw new HTTPException(404, { - message: - "Not Found - Status report id does not exist within your workspace", + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Status Report ${input.statusReportId} not found`, }); } @@ -78,8 +77,6 @@ export function registerPostStatusReportUpdate( .returning() .get(); - // send email - if (limits["status-subscribers"] && _statusReport.pageId) { const subscribers = await db .select() diff --git a/apps/server/src/routes/v1/statusReportUpdates/schema.ts b/apps/server/src/routes/v1/statusReportUpdates/schema.ts new file mode 100644 index 00000000..3f0f0b8e --- /dev/null +++ b/apps/server/src/routes/v1/statusReportUpdates/schema.ts @@ -0,0 +1,37 @@ +import { z } from "@hono/zod-openapi"; + +import { statusReportStatus } from "@openstatus/db/src/schema"; + +export const ParamsSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "The id of the update", + example: "1", + }), +}); + +export const StatusReportUpdateSchema = z + .object({ + id: z.coerce.string().openapi({ description: "The id of the update" }), + status: z.enum(statusReportStatus).openapi({ + description: "The status of the update", + }), + date: z.coerce.date().default(new Date()).openapi({ + description: "The date of the update in ISO8601 format", + }), + message: z.string().openapi({ + description: "The message of the update", + }), + statusReportId: z.number().openapi({ + description: "The id of the status report", + }), + }) + .openapi("StatusReportUpdate"); + +export type StatusReportUpdateSchema = z.infer; diff --git a/apps/server/src/routes/v1/statusReports/delete.test.ts b/apps/server/src/routes/v1/statusReports/delete.test.ts new file mode 100644 index 00000000..d9933dc0 --- /dev/null +++ b/apps/server/src/routes/v1/statusReports/delete.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; + +test("delete the status report", async () => { + const res = await app.request("/v1/status_report/3", { + method: "DELETE", + headers: { + "x-openstatus-key": "1", + }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({}); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report/2", { method: "DELETE" }); + + expect(res.status).toBe(401); +}); + +test("invalid status report id should return 404", async () => { + const res = await app.request("/v1/status_report/2", { + method: "DELETE", + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/statusReports/delete.ts b/apps/server/src/routes/v1/statusReports/delete.ts similarity index 76% rename from apps/server/src/v1/statusReports/delete.ts rename to apps/server/src/routes/v1/statusReports/delete.ts index 7b2bc931..b9b0356e 100644 --- a/apps/server/src/v1/statusReports/delete.ts +++ b/apps/server/src/routes/v1/statusReports/delete.ts @@ -3,15 +3,14 @@ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportsApi } from "./index"; import { ParamsSchema } from "./schema"; const deleteRoute = createRoute({ method: "delete", tags: ["status_report"], - description: "Delete a Status Report", + summary: "Delete a status report", path: "/:id", request: { params: ParamsSchema, @@ -31,7 +30,7 @@ const deleteRoute = createRoute({ export function registerDeleteStatusReport(api: typeof statusReportsApi) { return api.openapi(deleteRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _statusReport = await db @@ -40,13 +39,16 @@ export function registerDeleteStatusReport(api: typeof statusReportsApi) { .where( and( eq(statusReport.id, Number(id)), - eq(statusReport.workspaceId, Number(workspaceId)), + eq(statusReport.workspaceId, workspaceId), ), ) .get(); if (!_statusReport) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Status Report ${id} not found`, + }); } await db diff --git a/apps/server/src/routes/v1/statusReports/get.test.ts b/apps/server/src/routes/v1/statusReports/get.test.ts new file mode 100644 index 00000000..4ed511bd --- /dev/null +++ b/apps/server/src/routes/v1/statusReports/get.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { StatusReportSchema } from "./schema"; + +test("return the status report", async () => { + const res = await app.request("/v1/status_report/2", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = StatusReportSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); + expect(result.data?.monitorIds?.length).toBeGreaterThan(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report/2"); + + expect(res.status).toBe(401); +}); + +test("invalid status report id should return 404", async () => { + const res = await app.request("/v1/status_report/2", { + headers: { + "x-openstatus-key": "2", + }, + }); + + expect(res.status).toBe(404); +}); diff --git a/apps/server/src/v1/statusReports/get.ts b/apps/server/src/routes/v1/statusReports/get.ts similarity index 82% rename from apps/server/src/v1/statusReports/get.ts rename to apps/server/src/routes/v1/statusReports/get.ts index 83f11ae5..f3fca46d 100644 --- a/apps/server/src/v1/statusReports/get.ts +++ b/apps/server/src/routes/v1/statusReports/get.ts @@ -3,15 +3,14 @@ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportsApi } from "./index"; import { ParamsSchema, StatusReportSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["status_report"], - description: "Get a Status Report", + summary: "Get a status report", path: "/:id", request: { params: ParamsSchema, @@ -31,7 +30,7 @@ const getRoute = createRoute({ export function regsiterGetStatusReport(api: typeof statusReportsApi) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _statusUpdate = await db.query.statusReport.findFirst({ @@ -40,13 +39,16 @@ export function regsiterGetStatusReport(api: typeof statusReportsApi) { monitorsToStatusReports: true, }, where: and( - eq(statusReport.workspaceId, Number(workspaceId)), + eq(statusReport.workspaceId, workspaceId), eq(statusReport.id, Number(id)), ), }); if (!_statusUpdate) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Status Report ${id} not found`, + }); } const { statusReportUpdates, monitorsToStatusReports } = _statusUpdate; diff --git a/apps/server/src/routes/v1/statusReports/get_all.test.ts b/apps/server/src/routes/v1/statusReports/get_all.test.ts new file mode 100644 index 00000000..e647df7e --- /dev/null +++ b/apps/server/src/routes/v1/statusReports/get_all.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { StatusReportSchema } from "./schema"; + +test("return all status reports", async () => { + const res = await app.request("/v1/status_report", { + method: "GET", + headers: { + "x-openstatus-key": "1", + }, + }); + + const result = StatusReportSchema.array().safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.length).toBeGreaterThan(0); +}); + +test("return empty status reports", async () => { + const res = await app.request("/v1/status_report", { + method: "GET", + headers: { + "x-openstatus-key": "2", + }, + }); + + const result = StatusReportSchema.array().safeParse(await res.json()); + + expect(result.success).toBe(true); + expect(res.status).toBe(200); + expect(result.data?.length).toBe(0); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report", { + method: "GET", + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/statusReports/get_all.ts b/apps/server/src/routes/v1/statusReports/get_all.ts similarity index 75% rename from apps/server/src/v1/statusReports/get_all.ts rename to apps/server/src/routes/v1/statusReports/get_all.ts index 50922c2b..533ea9e0 100644 --- a/apps/server/src/v1/statusReports/get_all.ts +++ b/apps/server/src/routes/v1/statusReports/get_all.ts @@ -3,15 +3,14 @@ import { createRoute, z } from "@hono/zod-openapi"; import { db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; +import { openApiErrorResponses } from "@/libs/errors"; import type { statusReportsApi } from "./index"; import { StatusReportSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["status_report"], - description: "Get all Status Reports", + summary: "List all status reports", path: "/", request: {}, responses: { @@ -29,20 +28,16 @@ const getAllRoute = createRoute({ export function registerGetAllStatusReports(api: typeof statusReportsApi) { return api.openapi(getAllRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; const _statusReports = await db.query.statusReport.findMany({ with: { statusReportUpdates: true, monitorsToStatusReports: true, }, - where: eq(statusReport.workspaceId, Number(workspaceId)), + where: eq(statusReport.workspaceId, workspaceId), }); - if (!_statusReports) { - throw new HTTPException(404, { message: "Not Found" }); - } - const data = z.array(StatusReportSchema).parse( _statusReports.map((r) => ({ ...r, diff --git a/apps/server/src/v1/statusReports/index.ts b/apps/server/src/routes/v1/statusReports/index.ts similarity index 93% rename from apps/server/src/v1/statusReports/index.ts rename to apps/server/src/routes/v1/statusReports/index.ts index 88d52020..64e7d5a8 100644 --- a/apps/server/src/v1/statusReports/index.ts +++ b/apps/server/src/routes/v1/statusReports/index.ts @@ -1,6 +1,6 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { handleZodError } from "../../libs/errors"; +import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerDeleteStatusReport } from "./delete"; import { regsiterGetStatusReport } from "./get"; diff --git a/apps/server/src/routes/v1/statusReports/post.test.ts b/apps/server/src/routes/v1/statusReports/post.test.ts new file mode 100644 index 00000000..1edea396 --- /dev/null +++ b/apps/server/src/routes/v1/statusReports/post.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { StatusReportSchema } from "./schema"; + +test("create a valid status report", async () => { + const date = new Date(); + date.setMilliseconds(0); + + const res = await app.request("/v1/status_report", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitorIds: [1], + date: date.toISOString(), + pageId: 1, + }), + }); + + const result = StatusReportSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); + expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); + expect(result.data?.monitorIds?.length).toBeGreaterThan(0); +}); + +test("create a status report with invalid monitor should return 400", async () => { + const res = await app.request("/v1/status_report", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitorIds: [404], + pageId: 1, + }), + }); + + expect(res.status).toBe(400); +}); + +test("create a status report with invalid page id should return 400", async () => { + const res = await app.request("/v1/status_report", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + status: "investigating", + title: "New Status Report", + message: "Message", + monitorIds: [1], + pageId: 404, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/status_report", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/statusReports/post.ts b/apps/server/src/routes/v1/statusReports/post.ts similarity index 68% rename from apps/server/src/v1/statusReports/post.ts rename to apps/server/src/routes/v1/statusReports/post.ts index 1346ed13..58ece924 100644 --- a/apps/server/src/v1/statusReports/post.ts +++ b/apps/server/src/routes/v1/statusReports/post.ts @@ -10,18 +10,15 @@ import { statusReportUpdate, } from "@openstatus/db/src/schema"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { sendBatchEmailHtml } from "@openstatus/emails/src/send"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { isoDate } from "../utils"; import type { statusReportsApi } from "./index"; import { StatusReportSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["status_report"], - description: "Create a Status Report", + summary: "Create a status report", path: "/", request: { body: { @@ -32,8 +29,9 @@ const postRoute = createRoute({ id: true, statusReportUpdateIds: true, }).extend({ - date: isoDate.optional().openapi({ - description: "The date of the report in ISO8601 format", + date: z.coerce.date().optional().default(new Date()).openapi({ + description: + "The date of the report in ISO8601 format, defaults to now", }), message: z.string().openapi({ description: "The message of the current status of incident", @@ -50,7 +48,7 @@ const postRoute = createRoute({ schema: StatusReportSchema, }, }, - description: "Status report created", + description: "The created status report", }, ...openApiErrorResponses, }, @@ -59,51 +57,50 @@ const postRoute = createRoute({ export function registerPostStatusReport(api: typeof statusReportsApi) { return api.openapi(postRoute, async (c) => { const input = c.req.valid("json"); - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; - const { monitorIds, date, ...rest } = input; - - if (monitorIds?.length) { + if (input.monitorIds?.length) { const _monitors = await db .select() .from(monitor) .where( and( - eq(monitor.workspaceId, Number(workspaceId)), - inArray(monitor.id, monitorIds), + eq(monitor.workspaceId, workspaceId), + inArray(monitor.id, input.monitorIds), isNull(monitor.deletedAt), ), ) .all(); - if (_monitors.length !== monitorIds.length) { - throw new HTTPException(400, { message: "Monitor not found" }); + if (_monitors.length !== input.monitorIds.length) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Some of the monitors ${input.monitorIds.join(", ")} not found`, + }); } } - if (rest.pageId) { - const _pages = await db - .select() - .from(page) - .where( - and( - eq(page.workspaceId, Number(workspaceId)), - eq(page.id, rest.pageId), - ), - ) - .all(); + const _pages = await db + .select() + .from(page) + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, input.pageId))) + .all(); - if (_pages.length !== 1) { - throw new HTTPException(400, { message: "Page not found" }); - } + if (_pages.length !== 1) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Page ${input.pageId} not found`, + }); } const _newStatusReport = await db .insert(statusReport) .values({ - ...rest, - workspaceId: Number(workspaceId), + status: input.status, + title: input.title, + pageId: input.pageId, + workspaceId: workspaceId, }) .returning() .get(); @@ -111,18 +108,19 @@ export function registerPostStatusReport(api: typeof statusReportsApi) { const _newStatusReportUpdate = await db .insert(statusReportUpdate) .values({ - ...input, - date: date ? new Date(date) : new Date(), + status: input.status, + message: input.message, + date: input.date, statusReportId: _newStatusReport.id, }) .returning() .get(); - if (monitorIds?.length) { + if (input.monitorIds?.length) { await db .insert(monitorsToStatusReport) .values( - monitorIds.map((id) => { + input.monitorIds.map((id) => { return { monitorId: id, statusReportId: _newStatusReport.id, @@ -132,7 +130,7 @@ export function registerPostStatusReport(api: typeof statusReportsApi) { .returning(); } - if (getLimit(limits, "status-subscribers") && _newStatusReport.pageId) { + if (limits["status-subscribers"] && _newStatusReport.pageId) { const subscribers = await db .select() .from(pageSubscriber) @@ -143,11 +141,13 @@ export function registerPostStatusReport(api: typeof statusReportsApi) { ), ) .all(); + const pageInfo = await db .select() .from(page) .where(eq(page.id, _newStatusReport.pageId)) .get(); + if (pageInfo) { const emails = subscribers.map((subscriber) => { return { @@ -158,13 +158,14 @@ export function registerPostStatusReport(api: typeof statusReportsApi) { from: "Notification OpenStatus ", }; }); + await sendBatchEmailHtml(emails); } } const data = StatusReportSchema.parse({ ..._newStatusReport, - monitorIds, + monitorIds: input.monitorIds, statusReportUpdateIds: [_newStatusReportUpdate.id], }); diff --git a/apps/server/src/routes/v1/statusReports/schema.ts b/apps/server/src/routes/v1/statusReports/schema.ts new file mode 100644 index 00000000..aee59885 --- /dev/null +++ b/apps/server/src/routes/v1/statusReports/schema.ts @@ -0,0 +1,48 @@ +import { z } from "@hono/zod-openapi"; + +import { statusReportStatusSchema } from "@openstatus/db/src/schema"; + +export const ParamsSchema = z.object({ + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "The id of the status report", + example: "1", + }), +}); + +export const StatusReportSchema = z + .object({ + id: z.number().openapi({ description: "The id of the status report" }), + title: z.string().openapi({ + example: "Documenso", + description: "The title of the status report", + }), + status: statusReportStatusSchema.openapi({ + description: "The current status of the report", + }), + statusReportUpdateIds: z + .array(z.number()) + .optional() + .nullable() + .default([]) + .openapi({ + description: "The ids of the status report updates", + }), + monitorIds: z + .array(z.number()) + .optional() + .default([]) + .openapi({ description: "Ids of the monitors the status report." }), + pageId: z.number().openapi({ + description: "The id of the page this status report belongs to", + }), + }) + .openapi("StatusReport"); + +export type StatusReportSchema = z.infer; diff --git a/apps/server/src/v1/statusReports/update/post.ts b/apps/server/src/routes/v1/statusReports/update/post.ts similarity index 81% rename from apps/server/src/v1/statusReports/update/post.ts rename to apps/server/src/routes/v1/statusReports/update/post.ts index 7f4710b4..6c6e4763 100644 --- a/apps/server/src/v1/statusReports/update/post.ts +++ b/apps/server/src/routes/v1/statusReports/update/post.ts @@ -1,3 +1,4 @@ +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { createRoute } from "@hono/zod-openapi"; import { and, db, eq, isNotNull } from "@openstatus/db"; import { @@ -6,10 +7,7 @@ import { statusReport, statusReportUpdate, } from "@openstatus/db/src/schema"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; import { sendBatchEmailHtml } from "@openstatus/emails/src/send"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; import { StatusReportUpdateSchema } from "../../statusReportUpdates/schema"; import type { statusReportsApi } from "../index"; import { ParamsSchema, StatusReportSchema } from "../schema"; @@ -18,8 +16,10 @@ const postRouteUpdate = createRoute({ method: "post", tags: ["status_report"], path: "/:id/update", + summary: "Create a status report update", + deprecated: true, description: - "Create an status report update. Deprecated, please use /status-report-updates instead.", + "Preferably use [`/status-report-updates`](#tag/status_report_update/POST/status_report_update) instead.", request: { params: ParamsSchema, body: { @@ -48,8 +48,8 @@ export function registerStatusReportUpdateRoutes(api: typeof statusReportsApi) { return api.openapi(postRouteUpdate, async (c) => { const input = c.req.valid("json"); const { id } = c.req.valid("param"); - const workspaceId = c.get("workspaceId"); - const limits = c.get("limits"); + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; const _statusReport = await db .update(statusReport) @@ -57,27 +57,31 @@ export function registerStatusReportUpdateRoutes(api: typeof statusReportsApi) { .where( and( eq(statusReport.id, Number(id)), - eq(statusReport.workspaceId, Number(workspaceId)), + eq(statusReport.workspaceId, workspaceId), ), ) .returning() .get(); if (!_statusReport) { - throw new HTTPException(404, { message: "Not Found" }); + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Status Report ${id} not found`, + }); } const _statusReportUpdate = await db .insert(statusReportUpdate) .values({ - ...input, - date: new Date(input.date), + status: input.status, + message: input.message, + date: input.date, statusReportId: Number(id), }) .returning() .get(); - if (getLimit(limits, "notifications") && _statusReport.pageId) { + if (limits.notifications && _statusReport.pageId) { const subscribers = await db .select() .from(pageSubscriber) diff --git a/apps/server/src/v1/utils.ts b/apps/server/src/routes/v1/utils.ts similarity index 53% rename from apps/server/src/v1/utils.ts rename to apps/server/src/routes/v1/utils.ts index 9d3191a2..bf452fae 100644 --- a/apps/server/src/v1/utils.ts +++ b/apps/server/src/routes/v1/utils.ts @@ -1,10 +1,17 @@ import { z } from "@hono/zod-openapi"; +import { ZodError } from "zod"; export const isoDate = z.preprocess((val) => { - if (val) { - return new Date(String(val)).toISOString(); + try { + if (val) { + return new Date(String(val)).toISOString(); + } + return new Date().toISOString(); + } catch (e) { + throw new ZodError([ + { code: "invalid_date", message: "Invalid date", path: [] }, + ]); } - return new Date().toISOString(); }, z.string()); export function isNumberArray( diff --git a/apps/server/src/routes/v1/whoami/get.test.ts b/apps/server/src/routes/v1/whoami/get.test.ts new file mode 100644 index 00000000..05cd0b98 --- /dev/null +++ b/apps/server/src/routes/v1/whoami/get.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { WorkspaceSchema } from "./schema"; + +test("return the whoami", async () => { + const res = await app.request("/v1/whoami", { + headers: { + "x-openstatus-key": "1", + }, + }); + const result = WorkspaceSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/whoami"); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/v1/whoami/get.ts b/apps/server/src/routes/v1/whoami/get.ts similarity index 51% rename from apps/server/src/v1/whoami/get.ts rename to apps/server/src/routes/v1/whoami/get.ts index e6a37d85..3b605ed2 100644 --- a/apps/server/src/v1/whoami/get.ts +++ b/apps/server/src/routes/v1/whoami/get.ts @@ -1,45 +1,48 @@ -import { createRoute, z } from "@hono/zod-openapi"; +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { createRoute } from "@hono/zod-openapi"; import { eq } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { workspace } from "@openstatus/db/src/schema/workspaces"; -import { HTTPException } from "hono/http-exception"; import type { whoamiApi } from "."; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import { schema } from "./schema"; +import { WorkspaceSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["whoami"], path: "/", - description: "Get the current workspace information", + summary: "Get your informations", + description: "Get the current workspace information attached to the API key.", responses: { 200: { content: { "application/json": { - schema: schema, + schema: WorkspaceSchema, }, }, description: "The current workspace information with the limits", }, ...openApiErrorResponses, }, -}); // Error: createRoute is not defined +}); export function registerGetWhoami(api: typeof whoamiApi) { return api.openapi(getRoute, async (c) => { - const workspaceId = c.get("workspaceId"); + const workspaceId = c.get("workspace").id; - const workspaceData = await db + const _workspace = await db .select() .from(workspace) - .where(eq(workspace.id, Number(workspaceId))) + .where(eq(workspace.id, workspaceId)) .get(); - if (!workspaceData) { - throw new HTTPException(404, { message: "Not Found" }); + if (!_workspace) { + throw new OpenStatusApiError({ + code: "NOT_FOUND", + message: `Workspace ${workspaceId} not found`, + }); } - const data = schema.parse(workspaceData); + const data = WorkspaceSchema.parse(_workspace); return c.json(data, 200); }); } diff --git a/apps/server/src/v1/whoami/index.ts b/apps/server/src/routes/v1/whoami/index.ts similarity index 83% rename from apps/server/src/v1/whoami/index.ts rename to apps/server/src/routes/v1/whoami/index.ts index e209260f..329744be 100644 --- a/apps/server/src/v1/whoami/index.ts +++ b/apps/server/src/routes/v1/whoami/index.ts @@ -1,6 +1,6 @@ +import { handleZodError } from "@/libs/errors"; import { OpenAPIHono } from "@hono/zod-openapi"; import type { Variables } from ".."; -import { handleZodError } from "../../libs/errors"; import { registerGetWhoami } from "./get"; export const whoamiApi = new OpenAPIHono<{ Variables: Variables }>({ diff --git a/apps/server/src/routes/v1/whoami/schema.ts b/apps/server/src/routes/v1/whoami/schema.ts new file mode 100644 index 00000000..996b79fc --- /dev/null +++ b/apps/server/src/routes/v1/whoami/schema.ts @@ -0,0 +1,18 @@ +import { z } from "@hono/zod-openapi"; + +import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; + +export const WorkspaceSchema = z + .object({ + name: z + .string() + .optional() + .openapi({ description: "The current workspace name" }), + slug: z.string().openapi({ description: "The current workspace slug" }), + plan: z.enum(workspacePlans).nullable().default("free").openapi({ + description: "The current workspace plan", + }), + }) + .openapi("Workspace"); + +export type WorkspaceSchema = z.infer; diff --git a/apps/server/src/types/index.ts b/apps/server/src/types/index.ts new file mode 100644 index 00000000..0d0a78ac --- /dev/null +++ b/apps/server/src/types/index.ts @@ -0,0 +1,6 @@ +import type { Workspace } from "@openstatus/db/src/schema"; +import type { RequestIdVariables } from "hono/request-id"; + +export type Variables = RequestIdVariables & { + workspace: Workspace; +}; diff --git a/apps/server/src/v1/incidents/incidents.test.ts b/apps/server/src/v1/incidents/incidents.test.ts deleted file mode 100644 index a1b42ee1..00000000 --- a/apps/server/src/v1/incidents/incidents.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; -import { iso8601Regex } from "../test-utils"; - -import type { IncidentSchema } from "./schema"; - -test("GET one Incident", async () => { - const res = await api.request("/incident/2", { - headers: { - "x-openstatus-key": "1", - }, - }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ - id: 2, - startedAt: expect.stringMatching(iso8601Regex), - monitorId: 1, - acknowledgedAt: null, - resolvedAt: null, - resolvedBy: null, - acknowledgedBy: null, - }); -}); - -test("Update an incident", async () => { - const res = await api.request("/incident/2", { - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - acknowledgedAt: "2023-11-08T21:03:13.000Z", - }), - }); - const json = await res.json(); - expect(res.status).toBe(200); - expect(json).toMatchObject({ - acknowledgedAt: expect.stringMatching(iso8601Regex), - monitorId: 1, - id: 2, - startedAt: expect.stringMatching(iso8601Regex), - resolvedAt: null, - resolvedBy: null, - acknowledgedBy: null, - }); -}); - -test("Update an incident not in db should return 404", async () => { - const res = await api.request("/incident/404", { - //accessing invalid monitor - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - acknowledgedAt: "2023-11-08T21:03:13.000Z", - }), - }); - - expect(res.status).toBe(404); -}); - -test("Update an incident without auth key should return 401", async () => { - const res = await api.request("/incident/2", { - method: "PUT", - headers: { - //not passing correct key - "content-type": "application/json", - }, - body: JSON.stringify({ - acknowledgedAt: "2023-11-08T21:03:13.000Z", - }), - }); - expect(res.status).toBe(401); -}); - -test("Update an incident with invalid data should return 403", async () => { - const res = await api.request("/incident/2", { - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - //passing incorrect body - acknowledgedAt: "2023-11-0", - }), - }); - expect(res.status).toBe(400); -}); - -test("Get all Incidents", async () => { - const res = await api.request("/incident", { - method: "GET", - headers: { - "x-openstatus-key": "1", - }, - }); - - const body = (await res.json()) as IncidentSchema[]; - - expect(res.status).toBe(200); - expect(body[0]).toMatchObject({ - acknowledgedAt: null, - monitorId: 1, - id: 1, - startedAt: expect.stringMatching(iso8601Regex), - resolvedAt: null, - resolvedBy: null, - acknowledgedBy: null, - }); -}); diff --git a/apps/server/src/v1/middleware.test.ts b/apps/server/src/v1/middleware.test.ts deleted file mode 100644 index 6666abae..00000000 --- a/apps/server/src/v1/middleware.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "./index"; - -test("Middleware error should return json", async () => { - const res = await api.request("/status_report/1", {}); - - const json = await res.json(); - expect(res.status).toBe(401); - expect(json).toMatchObject({ - code: "UNAUTHORIZED", - message: "Unauthorized", - docs: "https://docs.openstatus.dev/api-references/errors/code/UNAUTHORIZED", - }); -}); diff --git a/apps/server/src/v1/middleware.ts b/apps/server/src/v1/middleware.ts deleted file mode 100644 index 129138f8..00000000 --- a/apps/server/src/v1/middleware.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { verifyKey } from "@unkey/api"; -import type { Context, Next } from "hono"; - -import { - type EventProps, - parseInputToProps, - setupAnalytics, -} from "@openstatus/analytics"; -import { db, eq } from "@openstatus/db"; -import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; -import { getPlanConfig } from "@openstatus/db/src/schema/plan/utils"; -import { HTTPException } from "hono/http-exception"; -import { env } from "../env"; -import type { Variables } from "./index"; - -export async function secureMiddleware( - c: Context<{ Variables: Variables }, "/*">, - next: Next, -) { - const key = c.req.header("x-openstatus-key"); - if (!key) throw new HTTPException(401, { message: "Unauthorized" }); - - const { error, result } = - env.NODE_ENV === "production" - ? await verifyKey(key) - : { result: { valid: true, ownerId: "1" }, error: null }; - - if (error) throw new HTTPException(500, { message: error.message }); - if (!result.valid) throw new HTTPException(401, { message: "Unauthorized" }); - if (!result.ownerId) - throw new HTTPException(401, { message: "Unauthorized" }); - - const _workspace = await db - .select() - .from(workspace) - .where(eq(workspace.id, Number.parseInt(result.ownerId))) - .get(); - - if (!_workspace) { - console.error("Workspace not found"); - throw new HTTPException(401, { message: "Unauthorized" }); - } - - const _work = selectWorkspaceSchema.parse(_workspace); - - c.set("workspacePlan", getPlanConfig(_workspace.plan)); - c.set("workspaceId", `${result.ownerId}`); - c.set("limits", _work.limits); - - await next(); -} - -export function trackMiddleware(event: EventProps, eventProps?: string[]) { - return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { - await next(); - - // REMINDER: only track the event if the request was successful - const isValid = c.res.status.toString().startsWith("2") && !c.error; - - if (isValid) { - // We have checked the request to be valid already - let json: unknown; - if (c.req.raw.bodyUsed) { - try { - json = await c.req.json(); - } catch { - json = {}; - } - } - const additionalProps = parseInputToProps(json, eventProps); - - // REMINDER: use setTimeout to avoid blocking the response - setTimeout(async () => { - const analytics = await setupAnalytics({ - userId: `api_${c.get("workspaceId")}`, - workspaceId: c.get("workspaceId"), - plan: c.get("workspacePlan").id, - }); - await analytics.track({ ...event, additionalProps }); - }, 0); - } - }; -} - -/** - * TODO: move the plan limit into the Unkey `{ meta }` to avoid an additional db call. - * When an API Key is created, we need to include the `{ meta: { plan: "free" } }` to the key. - * Then, we can just read the plan from the key and use it in the middleware. - * Don't forget to update the key whenever a user changes their plan. (via `stripeRoute` webhook) - */ diff --git a/apps/server/src/v1/monitors/monitors.test.ts b/apps/server/src/v1/monitors/monitors.test.ts deleted file mode 100644 index a4a12889..00000000 --- a/apps/server/src/v1/monitors/monitors.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; -import type { MonitorSchema } from "./schema"; - -test("GET one monitor", async () => { - const res = await api.request("/monitor/1", { - headers: { - "x-openstatus-key": "1", - }, - }); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json).toMatchObject({ - id: 1, - periodicity: "1m", - url: "https://www.openstatus.dev", - regions: ["ams"], - name: "OpenStatus", - description: "OpenStatus website", - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - assertions: null, - }); -}); - -test("GET all monitor", async () => { - const res = await api.request("/monitor", { - headers: { - "x-openstatus-key": "1", - }, - }); - const json = (await res.json()) as MonitorSchema[]; - - expect(res.status).toBe(200); - expect(json[0]).toMatchObject({ - id: 1, - periodicity: "1m", - url: "https://www.openstatus.dev", - regions: ["ams"], - name: "OpenStatus", - description: "OpenStatus website", - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }); -}); - -test("Create a monitor", async () => { - const data = { - periodicity: "10m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams", "gru"], - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: true, - assertions: [ - { - type: "status", - compare: "eq", - target: 200, - }, - { type: "header", compare: "not_eq", key: "key", target: "value" }, - ], - }; - - const res = await api.request("/monitor", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - expect(res.status).toBe(200); - - expect(await res.json()).toMatchObject({ - id: expect.any(Number), - ...data, - }); -}); - -test("Create a monitor with Assertion ", async () => { - const data = { - periodicity: "10m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams", "gru", "iad"], - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: true, - assertions: [ - { - type: "status", - compare: "eq", - target: 200, - }, - { type: "header", compare: "not_eq", key: "key", target: "value" }, - ], - }; - const res = await api.request("/monitor", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - expect(res.status).toBe(200); - - expect(await res.json()).toMatchObject({ - id: expect.any(Number), - ...data, - }); -}); - -test("Create a monitor without auth key should return 401", async () => { - const data = { - periodicity: "10m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams", "gru"], - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }; - const res = await api.request("/monitor", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(401); -}); - -test("Create a monitor with invalid data should return 403", async () => { - const data = { - periodicity: 32, //not valid value - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams", "gru"], - method: "POST", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }; - const res = await api.request("/monitor", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - expect(res.status).toBe(400); -}); - -test("Update a Monitor ", async () => { - const data = { - periodicity: "10m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams"], - method: "GET", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: true, - }; - - const res = await api.request("/monitor/1", { - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(200); - - expect(await res.json()).toMatchObject({ - id: 1, - periodicity: "10m", - url: "https://www.openstatus.dev", - regions: ["ams"], - name: "OpenStatus", - description: "OpenStatus website", - method: "GET", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: true, - }); -}); - -test("Update a monitor not in db should return 404", async () => { - const data = { - periodicity: "10m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams"], - method: "GET", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }; - - const res = await api.request("/monitor/404", { - //accessing wrong monitor, just returns 404 - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(404); -}); - -test("Update a monitor without auth key should return 401", async () => { - const data = { - periodicity: "5m", - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams"], - method: "GET", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }; - const res = await api.request("/monitor/2", { - method: "PUT", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(401); -}); -test("Update a monitor with invalid data should return 403", async () => { - const data = { - periodicity: 9, //not passing correct value returns 403 - url: "https://www.openstatus.dev", - name: "OpenStatus", - description: "OpenStatus website", - regions: ["ams"], - method: "GET", - body: '{"hello":"world"}', - headers: [{ key: "key", value: "value" }], - active: true, - public: false, - }; - const res = await api.request("/monitor/2", { - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - expect(res.status).toBe(400); -}); - -test("Delete one monitor", async () => { - const res = await api.request("/monitor/3", { - method: "DELETE", - headers: { - "x-openstatus-key": "1", - }, - }); - expect(res.status).toBe(200); - - expect(await res.json()).toMatchObject({}); -}); - -test.todo("Get monitor daily Summary"); -// test("Get monitor daily Summary", async () => { -// const res = await api.request("/monitor/1/summary", { -// headers: { -// "x-openstatus-key": "1", -// }, -// }); -// expect(res.status).toBe(200); -// expect(await res.json()).toMatchObject({ -// ok: 4, -// count: 13, -// avgLatency: 1, -// day: expect.stringMatching(iso8601Regex) -// }); -// }); diff --git a/apps/server/src/v1/monitors/trigger/post.ts b/apps/server/src/v1/monitors/trigger/post.ts deleted file mode 100644 index 32255fdf..00000000 --- a/apps/server/src/v1/monitors/trigger/post.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { createRoute, z } from "@hono/zod-openapi"; -import { and, eq, gte, isNull, sql } from "@openstatus/db"; -import { db } from "@openstatus/db/src/db"; -import { monitorRun } from "@openstatus/db/src/schema"; -import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; -import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; -import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; -import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; -import { getLimit } from "@openstatus/db/src/schema/plan/utils"; -import type { httpPayloadSchema, tpcPayloadSchema } from "@openstatus/utils"; -import { HTTPException } from "hono/http-exception"; -import type { monitorsApi } from ".."; -import { env } from "../../../env"; -import { openApiErrorResponses } from "../../../libs/errors/openapi-error-responses"; -import { ParamsSchema } from "../schema"; - -const triggerMonitor = createRoute({ - method: "post", - tags: ["monitor"], - description: "Trigger a monitor check", - path: "/:id/trigger", - request: { - params: ParamsSchema, - }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - resultId: z - .number() - .openapi({ description: "the id of your check result" }), - }), - }, - }, - description: "All the historical metrics", - }, - ...openApiErrorResponses, - }, -}); - -export function registerTriggerMonitor(api: typeof monitorsApi) { - return api.openapi(triggerMonitor, async (c) => { - const workspaceId = c.get("workspaceId"); - const { id } = c.req.valid("param"); - const limits = c.get("limits"); - - const lastMonth = new Date().setMonth(new Date().getMonth() - 1); - - const count = ( - await db - .select({ count: sql`count(*)` }) - .from(monitorRun) - .where( - and( - eq(monitorRun.workspaceId, Number(workspaceId)), - gte(monitorRun.createdAt, new Date(lastMonth)), - ), - ) - .all() - )[0].count; - - if (count >= getLimit(limits, "synthetic-checks")) { - throw new HTTPException(403, { - message: "Upgrade for more checks", - }); - } - - const monitorData = await db - .select() - .from(monitor) - .where( - and( - eq(monitor.id, Number(id)), - eq(monitor.workspaceId, Number(workspaceId)), - isNull(monitor.deletedAt), - ), - ) - .get(); - - if (!monitorData) { - throw new HTTPException(404, { message: "Not Found" }); - } - - const parseMonitor = selectMonitorSchema.safeParse(monitorData); - - if (!parseMonitor.success) { - throw new HTTPException(400, { message: "Something went wrong" }); - } - - const row = parseMonitor.data; - - // Maybe later overwrite the region - - const monitorStatusData = await db - .select() - .from(monitorStatusTable) - .where(eq(monitorStatusTable.monitorId, monitorData.id)) - .all(); - - const monitorStatus = z - .array(selectMonitorStatusSchema) - .safeParse(monitorStatusData); - if (!monitorStatus.success) { - throw new HTTPException(400, { message: "Something went wrong" }); - } - - const timestamp = Date.now(); - - const newRun = await db - .insert(monitorRun) - .values({ - monitorId: row.id, - workspaceId: row.workspaceId, - runnedAt: new Date(timestamp), - }) - .returning(); - - if (!newRun[0]) { - throw new HTTPException(400, { message: "Something went wrong" }); - } - - const allResult = []; - for (const region of parseMonitor.data.regions) { - const status = - monitorStatus.data.find((m) => region === m.region)?.status || "active"; - // Trigger the monitor - - let payload: - | z.infer - | z.infer - | null = null; - // - if (row.jobType === "http") { - payload = { - workspaceId: String(row.workspaceId), - monitorId: String(row.id), - url: row.url, - method: row.method || "GET", - cronTimestamp: timestamp, - body: row.body, - headers: row.headers, - status: status, - assertions: row.assertions ? JSON.parse(row.assertions) : null, - degradedAfter: row.degradedAfter, - timeout: row.timeout, - trigger: "api", - }; - } - if (row.jobType === "tcp") { - payload = { - workspaceId: String(row.workspaceId), - monitorId: String(row.id), - uri: row.url, - status: status, - assertions: row.assertions ? JSON.parse(row.assertions) : null, - cronTimestamp: timestamp, - degradedAfter: row.degradedAfter, - timeout: row.timeout, - trigger: "api", - }; - } - - if (!payload) { - throw new Error("Invalid jobType"); - } - const url = generateUrl({ row }); - const result = fetch(url, { - headers: { - "Content-Type": "application/json", - "fly-prefer-region": region, // Specify the region you want the request to be sent to - Authorization: `Basic ${env.CRON_SECRET}`, - }, - method: "POST", - body: JSON.stringify(payload), - }); - allResult.push(result); - } - - await Promise.all(allResult); - - return c.json({ resultId: newRun[0].id }, 200); - }); -} - -function generateUrl({ row }: { row: z.infer }) { - switch (row.jobType) { - case "http": - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}&trigger=api&data=false`; - case "tcp": - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}&trigger=api&data=false`; - default: - throw new Error("Invalid jobType"); - } -} diff --git a/apps/server/src/v1/notifications/get_all.ts b/apps/server/src/v1/notifications/get_all.ts deleted file mode 100644 index 3d7b4e25..00000000 --- a/apps/server/src/v1/notifications/get_all.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createRoute, z } from "@hono/zod-openapi"; - -import { and, db, eq } from "@openstatus/db"; -import { - notification, - notificationsToMonitors, - page, -} from "@openstatus/db/src/schema"; -import { HTTPException } from "hono/http-exception"; -import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; -import type { notificationsApi } from "./index"; -import { NotificationSchema } from "./schema"; - -const getAllRoute = createRoute({ - method: "get", - tags: ["notification"], - description: "Get a notification", - path: "/", - - responses: { - 200: { - content: { - "application/json": { - schema: z.array(NotificationSchema), - }, - }, - description: "Get all your workspace notification", - }, - ...openApiErrorResponses, - }, -}); - -export function registerGetAllNotifications(app: typeof notificationsApi) { - return app.openapi(getAllRoute, async (c) => { - const workspaceId = c.get("workspaceId"); - - const _incidents = await db - .select() - .from(notification) - .where(and(eq(page.workspaceId, Number(workspaceId)))) - .all(); - - if (!_incidents) { - throw new HTTPException(404, { message: "Not Found" }); - } - - const data = []; - - for (const _incident of _incidents) { - const linkedMonitors = await db - .select() - .from(notificationsToMonitors) - .where(eq(notificationsToMonitors.notificationId, _incident.id)) - .all(); - - const monitors = linkedMonitors.map((m) => m.monitorId); - - const p = NotificationSchema.parse({ - ..._incidents, - payload: JSON.parse(_incident.data || "{}"), - monitors, - }); - - data.push(p); - } - - return c.json(data, 200); - }); -} diff --git a/apps/server/src/v1/notifications/notifications.test.ts b/apps/server/src/v1/notifications/notifications.test.ts deleted file mode 100644 index 0d638774..00000000 --- a/apps/server/src/v1/notifications/notifications.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; - -test("Create a notification", async () => { - const data = { - name: "OpenStatus", - provider: "email", - payload: { email: "ping@openstatus.dev" }, - }; - const res = await api.request("/notification", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - const json = await res.json(); - - expect(res.status).toBe(200); - - expect(json).toMatchObject({ - id: expect.any(Number), - provider: "email", - payload: { email: "ping@openstatus.dev" }, - }); -}); diff --git a/apps/server/src/v1/notifications/schema.ts b/apps/server/src/v1/notifications/schema.ts deleted file mode 100644 index 64ba1e29..00000000 --- a/apps/server/src/v1/notifications/schema.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { - NotificationDataSchema, - notificationProviderSchema, -} from "@openstatus/db/src/schema"; - -export const ParamsSchema = z.object({ - id: z - .string() - .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - description: "The id of the notification", - example: "1", - }), -}); - -export const NotificationSchema = z.object({ - id: z - .number() - .openapi({ description: "The id of the notification", example: 1 }), - name: z.string().openapi({ - description: "The name of the notification", - example: "OpenStatus Discord", - }), - provider: notificationProviderSchema.openapi({ - description: "The provider of the notification", - example: "discord", - }), - payload: NotificationDataSchema.openapi({ - description: "The data of the notification", - }), - monitors: z - .array(z.number()) - .openapi({ - description: "The monitors that the notification is linked to", - example: [1, 2], - }) - .nullish(), -}); - -export type NotificationSchema = z.infer; diff --git a/apps/server/src/v1/pageSubscribers/schema.ts b/apps/server/src/v1/pageSubscribers/schema.ts deleted file mode 100644 index 4cb456b0..00000000 --- a/apps/server/src/v1/pageSubscribers/schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -export const ParamsSchema = z.object({ - id: z - .string() - .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - description: "The id of the page", - example: "1", - }), -}); -export const PageSubscriberSchema = z.object({ - email: z.string().email().openapi({ - description: "The email of the subscriber", - }), -}); - -export type PageSubscriberSchema = z.infer; diff --git a/apps/server/src/v1/pages/pages.test.ts b/apps/server/src/v1/pages/pages.test.ts deleted file mode 100644 index ada0b7c2..00000000 --- a/apps/server/src/v1/pages/pages.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; - -test("Create a page", async () => { - const data = { - title: "OpenStatus", - description: "OpenStatus website", - slug: "openstatus", - }; - const res = await api.request("/page", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(200); - - expect(await res.json()).toMatchObject({ - id: expect.any(Number), - title: "OpenStatus", - description: "OpenStatus website", - slug: "openstatus", - }); -}); - -test("Create a page with monitors", async () => { - const data = { - title: "OpenStatus", - description: "OpenStatus website", - slug: "new-openstatus", - monitors: [1, 2], - }; - const res = await api.request("/page", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - const json = await res.json(); - - expect(res.status).toBe(200); - - expect(json).toMatchObject({ - id: expect.any(Number), - title: "OpenStatus", - description: "OpenStatus website", - slug: "new-openstatus", - }); -}); - -test("Update a page with monitors as object including order", async () => { - const data = { - monitors: [ - { monitorId: 1, order: 0 }, - { monitorId: 2, order: 1 }, - ], - }; - const res = await api.request("/page/3", { - method: "PUT", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - - const json = await res.json(); - - expect(res.status).toBe(200); - - expect(json).toMatchObject({ - id: 3, - }); -}); - -test("Create a page without auth key should return 401", async () => { - const data = { - title: "OpenStatus", - description: "OpenStatus website", - slug: "openstatus", - }; - const res = await api.request("/page", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(401); -}); - -test("Create a page with invalid data should return 403", async () => { - const data = { - description: "OpenStatus website", - slug: "openstatus", - }; - const res = await api.request("/page", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify(data), - }); - expect(res.status).toBe(400); -}); diff --git a/apps/server/src/v1/pages/schema.ts b/apps/server/src/v1/pages/schema.ts deleted file mode 100644 index e2c0c5fa..00000000 --- a/apps/server/src/v1/pages/schema.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -export const ParamsSchema = z.object({ - id: z - .string() - .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - description: "The id of the page", - example: "1", - }), -}); - -export const PageSchema = z.object({ - id: z.number().openapi({ - description: "The id of the page", - example: 1, - }), - title: z.string().openapi({ - description: "The title of the page", - example: "My Page", - }), - description: z.string().openapi({ - description: "The description of the page", - example: "My awesome status page", - }), - slug: z.string().openapi({ - description: "The slug of the page", - example: "my-page", - }), - // REMINDER: needs to be configured on Dashboard UI - customDomain: z - .string() - .openapi({ - description: - "The custom domain of the page. To be configured within the dashboard.", - example: "status.acme.com", - }) - .transform((val) => (val ? val : undefined)) - .nullish(), - icon: z - .string() - .openapi({ - description: "The icon of the page", - example: "https://example.com/icon.png", - }) - .url() - .or(z.literal("")) - .transform((val) => (val ? val : undefined)) - .nullish(), - passwordProtected: z - .boolean() - .openapi({ - description: - "Make the page password protected. Used with the 'passwordProtected' property.", - example: true, - }) - .default(false) - .optional(), - password: z - .string() - .openapi({ - description: "Your password to protect the page from the publi", - example: "hidden-password", - }) - .optional() - .nullish(), - showMonitorValues: z - .boolean() - .openapi({ - description: - "Displays the total and failed request numbers for each monitor", - example: true, - }) - .optional() - .nullish(), - monitors: z - .array(z.number()) - .openapi({ - description: "The monitors of the page as an array of ids", - example: [1, 2], - }) - .or( - z.array(z.object({ monitorId: z.number(), order: z.number() })).openapi({ - description: "The monitor as object allowing to pass id and order", - example: [ - { monitorId: 1, order: 0 }, - { monitorId: 2, order: 1 }, - ], - }), - ) - .optional(), -}); - -export type PageSchema = z.infer; diff --git a/apps/server/src/v1/statusReportUpdates/schema.ts b/apps/server/src/v1/statusReportUpdates/schema.ts deleted file mode 100644 index eede4aa8..00000000 --- a/apps/server/src/v1/statusReportUpdates/schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -import { statusReportStatus } from "@openstatus/db/src/schema"; - -import { isoDate } from "../utils"; - -export const ParamsSchema = z.object({ - id: z - .string() - .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - description: "The id of the update", - example: "1", - }), -}); - -export const StatusReportUpdateSchema = z.object({ - id: z.coerce.string().openapi({ description: "The id of the update" }), - status: z.enum(statusReportStatus).openapi({ - description: "The status of the update", - }), - date: isoDate.openapi({ - description: "The date of the update in ISO8601 format", - }), - message: z.string().openapi({ - description: "The message of the update", - }), - statusReportId: z.number().openapi({ - description: "The id of the status report", - }), -}); - -export type StatusReportUpdateSchema = z.infer; diff --git a/apps/server/src/v1/statusReportUpdates/statusReportUpdates.test.ts b/apps/server/src/v1/statusReportUpdates/statusReportUpdates.test.ts deleted file mode 100644 index 758ea317..00000000 --- a/apps/server/src/v1/statusReportUpdates/statusReportUpdates.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; -import { iso8601Regex } from "../test-utils"; - -test("GET one status report update ", async () => { - const res = await api.request("/status_report_update/1", { - headers: { - "x-openstatus-key": "1", - }, - }); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json).toMatchObject({ - status: "investigating", - message: "Message", - date: expect.stringMatching(iso8601Regex), - }); -}); - -test("create one status report update ", async () => { - const res = await api.request("/status_report_update", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - date: "2023-11-08T21:03:13.000Z", - message: "test", - statusReportId: 1, - }), - }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ - status: "investigating", - message: "test", - }); -}); - -test("create one status report update without auth key should return 401", async () => { - const res = await api.request("/status_report_update", { - method: "POST", - headers: { - //not passing in the key - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - date: expect.stringMatching(iso8601Regex), - message: "test", - statusReportId: 1, - }), - }); - expect(res.status).toBe(401); -}); - -test("create one status report update with invalid data should return 403", async () => { - const res = await api.request("/status_report_update", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - //incompelete body - status: "investigating", - date: "2023-11-08T21:03:13.000Z", - }), - }); - - expect(res.status).toBe(400); -}); diff --git a/apps/server/src/v1/statusReports/schema.ts b/apps/server/src/v1/statusReports/schema.ts deleted file mode 100644 index 7536bd86..00000000 --- a/apps/server/src/v1/statusReports/schema.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -import { statusReportStatusSchema } from "@openstatus/db/src/schema"; - -export const ParamsSchema = z.object({ - id: z - .string() - .min(1) - .openapi({ - param: { - name: "id", - in: "path", - }, - description: "The id of the status report", - example: "1", - }), -}); - -export const StatusReportSchema = z.object({ - id: z.number().openapi({ description: "The id of the status report" }), - title: z.string().openapi({ - example: "Documenso", - description: "The title of the status report", - }), - status: statusReportStatusSchema.openapi({ - description: "The current status of the report", - }), - // REMINDER: extended only on POST requests - // date: isoDate.openapi({ - // description: "The date of the report in ISO8601 format", - // }), - // message: z.string().openapi({ - // description: "The message of the current status of incident", - // }), - statusReportUpdateIds: z - .array(z.number()) - .optional() - .nullable() - .default([]) - .openapi({ - description: "The ids of the status report updates", - }), - monitorIds: z - .array(z.number()) - .optional() - .nullable() - .default([]) - .openapi({ - description: "id of monitors this report needs to refer", - }) - .nullable(), - - pageId: z.number().optional().nullable().openapi({ - description: "The id of the page this status report belongs to", - }), -}); - -export type StatusReportSchema = z.infer; diff --git a/apps/server/src/v1/statusReports/statusReports.test.ts b/apps/server/src/v1/statusReports/statusReports.test.ts deleted file mode 100644 index 5e2c5aed..00000000 --- a/apps/server/src/v1/statusReports/statusReports.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { expect, test } from "bun:test"; - -import { api } from "../index"; - -test("GET one status report", async () => { - const res = await api.request("/status_report/1", { - headers: { - "x-openstatus-key": "1", - }, - }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ - id: 1, - title: "Test Status Report", - status: "monitoring", - statusReportUpdateIds: expect.arrayContaining([1, 3]), // depending on the order of the updates - monitorIds: null, - pageId: 1, - }); -}); - -test("Get all status report", async () => { - const res = await api.request("/status_report", { - headers: { - "x-openstatus-key": "1", - }, - }); - expect(res.status).toBe(200); - expect({ data: await res.json() }).toMatchObject({ - data: [ - { - id: 1, - title: "Test Status Report", - status: "monitoring", - statusReportUpdateIds: expect.arrayContaining([1, 3]), // depending on the order of the updates - monitorIds: [], - pageId: 1, - }, - { - id: 2, - title: "Test Status Report", - status: "investigating", - statusReportUpdateIds: expect.arrayContaining([2]), // depending on the order of the updates - monitorIds: [1, 2], - pageId: 1, - }, - ], - }); -}); - -test("Create one status report including passing optional fields", async () => { - const res = await api.request("/status_report", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - title: "New Status Report", - message: "Message", - monitorIds: [1], - pageId: 1, - }), - }); - const json = await res.json(); - - expect(res.status).toBe(200); - - expect(json).toMatchObject({ - id: expect.any(Number), - title: "New Status Report", - status: "investigating", - statusReportUpdateIds: [expect.any(Number)], - monitorIds: [1], - pageId: 1, - }); -}); - -test("Create one status report without auth key should return 401", async () => { - const res = await api.request("/status_report", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - title: "Test Status Report", - }), - }); - expect(res.status).toBe(401); //unauthenticated -}); - -test("Create one status report with invalid data should return 403", async () => { - const res = await api.request("/status_report", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - //passing incompelete body - title: "Test Status Report", - }), - }); - expect(res.status).toBe(400); -}); - -test("Create status report with non existing monitor ids should return 400", async () => { - const res = await api.request("/status_report", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - title: "New Status Report", - message: "Message", - monitorIds: [100], - pageId: 1, - }), - }); - - expect(res.status).toBe(400); -}); - -test("Create status report with non existing page ids should return 400", async () => { - const res = await api.request("/status_report", { - method: "POST", - headers: { - "x-openstatus-key": "1", - "content-type": "application/json", - }, - body: JSON.stringify({ - status: "investigating", - title: "New Status Report", - message: "Message", - monitorIds: [1], - pageId: 100, - }), - }); - - expect(res.status).toBe(400); -}); - -test("Delete a status report", async () => { - const res = await api.request("/status_report/3", { - method: "DELETE", - headers: { - "x-openstatus-key": "1", - }, - }); - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({}); -}); diff --git a/apps/server/src/v1/test-utils.ts b/apps/server/src/v1/test-utils.ts deleted file mode 100644 index bd7a2f6f..00000000 --- a/apps/server/src/v1/test-utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const iso8601Regex: RegExp = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/; diff --git a/apps/server/src/v1/whoami/schema.ts b/apps/server/src/v1/whoami/schema.ts deleted file mode 100644 index 6f0c099d..00000000 --- a/apps/server/src/v1/whoami/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "@hono/zod-openapi"; - -import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; - -export const schema = z.object({ - name: z - .string() - .openapi({ description: "The current workspace name" }) - .optional(), - slug: z.string().openapi({ description: "The current workspace slug" }), - plan: z - .enum(workspacePlans) - .nullable() - .default("free") - .transform((val) => val ?? "free") - .openapi({ - description: "The current workspace plan", - }), -}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 3b81438d..40286271 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,6 +2,13 @@ "extends": "@openstatus/tsconfig/base.json", "include": ["src", "*.ts", "**/*.ts"], "compilerOptions": { - "types": ["bun-types"] + "jsx": "react-jsx", + "jsxImportSource": "react", + "allowJs": true, + "types": ["bun-types"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } } } diff --git a/apps/web/src/app/(content)/blog/feed.xml/route.ts b/apps/web/src/app/(content)/blog/feed.xml/route.ts index bf2bce7a..09607565 100644 --- a/apps/web/src/app/(content)/blog/feed.xml/route.ts +++ b/apps/web/src/app/(content)/blog/feed.xml/route.ts @@ -14,7 +14,7 @@ export async function GET() { author: { name: "OpenStatus Team", email: "ping@openstatus.dev", - link: "https://openstatus.dev" + link: "https://openstatus.dev", }, copyright: `Copyright ${new Date().getFullYear().toString()}, OpenStatus`, language: "en-US", @@ -33,10 +33,12 @@ export async function GET() { title: post.title, description: post.description, link: `https://www.openstatus.dev/blog/${post.slug}`, - author: [{ - name: post.author.name, - link: post.author.url, - }], + author: [ + { + name: post.author.name, + link: post.author.url, + }, + ], date: post.publishedAt, }); }); diff --git a/apps/web/src/app/(content)/changelog/feed.xml/route.ts b/apps/web/src/app/(content)/changelog/feed.xml/route.ts index 04d22dc0..32e32861 100644 --- a/apps/web/src/app/(content)/changelog/feed.xml/route.ts +++ b/apps/web/src/app/(content)/changelog/feed.xml/route.ts @@ -14,7 +14,7 @@ export async function GET() { author: { name: "OpenStatus Team", email: "ping@openstatus.dev", - link: "https://openstatus.dev" + link: "https://openstatus.dev", }, copyright: `Copyright ${new Date().getFullYear().toString()}, OpenStatus`, language: "en-US", @@ -33,11 +33,13 @@ export async function GET() { title: post.title, description: post.description, link: `https://www.openstatus.dev/changelog/${post.slug}`, - author: [{ - name: "OpenStatus Team", - email: "ping@openstatus.dev", - link: "https://openstatus.dev" - }], + author: [ + { + name: "OpenStatus Team", + email: "ping@openstatus.dev", + link: "https://openstatus.dev", + }, + ], date: post.publishedAt, }); }); diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx index 3fe50681..3cf2bb39 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx @@ -19,11 +19,14 @@ export default async function BillingPage() {
{Object.entries(currentNumbers).map(([key, value]) => { const limit = workspace.limits[key as keyof typeof currentNumbers]; + // TODO: find a better way to determine if the limit is monthly + const isMonthly = ["synthetic-checks"].includes(key); return (

{key.replace("-", " ")}

+ {isMonthly ? "monthly" : null}{" "} {value} / {limit}

diff --git a/apps/web/src/app/status-page/[domain]/_components/actions.ts b/apps/web/src/app/status-page/[domain]/_components/actions.ts index 7f58a3d0..f1eb5f60 100644 --- a/apps/web/src/app/status-page/[domain]/_components/actions.ts +++ b/apps/web/src/app/status-page/[domain]/_components/actions.ts @@ -66,6 +66,16 @@ export async function handleSubscribe(formData: FormData) { const token = crypto.randomUUID(); + await db + .insert(pageSubscriber) + .values({ + email: validatedFields.data.email, + token, + pageId: pageData.id, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }) + .execute(); + await sendEmail({ react: SubscribeEmail({ domain: pageData.slug, @@ -77,16 +87,6 @@ export async function handleSubscribe(formData: FormData) { subject: `Verify your subscription to ${pageData.title}`, }); - await db - .insert(pageSubscriber) - .values({ - email: validatedFields.data.email, - token, - pageId: pageData.id, - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - }) - .execute(); - const analytics = await setupAnalytics({}); analytics.track({ ...Events.SubscribePage, slug: pageData.slug }); } diff --git a/apps/web/src/app/status-page/[domain]/subscribe/route.ts b/apps/web/src/app/status-page/[domain]/subscribe/route.ts index ada4dbe8..05af1525 100644 --- a/apps/web/src/app/status-page/[domain]/subscribe/route.ts +++ b/apps/web/src/app/status-page/[domain]/subscribe/route.ts @@ -38,18 +38,8 @@ export async function POST( return new Response("Not found", { status: 401 }); } - const token = (Math.random() + 1).toString(36).substring(10); + const token = crypto.randomUUID(); - await sendEmail({ - react: SubscribeEmail({ - domain: params.domain, - token: token, - page: pageData.title, - }), - from: "OpenStatus ", - to: [result.email], - subject: `Verify your subscription to ${pageData.title}`, - }); await db .insert(pageSubscriber) .values({ @@ -59,5 +49,17 @@ export async function POST( expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }) .execute(); + + await sendEmail({ + react: SubscribeEmail({ + domain: params.domain, + token, + page: pageData.title, + }), + from: "OpenStatus ", + to: [result.email], + subject: `Verify your subscription to ${pageData.title}`, + }); + return Response.json({ message: "Hello world" }); } diff --git a/packages/analytics/src/server.ts b/packages/analytics/src/server.ts index 8f87b2dc..96eacb79 100644 --- a/packages/analytics/src/server.ts +++ b/packages/analytics/src/server.ts @@ -67,7 +67,7 @@ async function noop() { opts: EventProps & PostEventPayload["properties"], ): Promise => { return new Promise((resolve) => { - console.log(`>>> Track Event: ${opts.name}`); + console.log(`>>> Track Noop Event: ${opts.name}`); resolve(null); }); }, diff --git a/packages/api/src/router/page.test.ts b/packages/api/src/router/page.test.ts index 6007dbda..3caccaae 100644 --- a/packages/api/src/router/page.test.ts +++ b/packages/api/src/router/page.test.ts @@ -17,15 +17,15 @@ test("Get Test Page", async () => { const result = await caller.page.getPageBySlug({ slug: "status" }); expect(result).toMatchObject({ createdAt: expect.any(Date), - customDomain: "", - description: "hello", - icon: "https://www.openstatus.dev/favicon.ico", + customDomain: expect.any(String), + description: expect.any(String), + icon: expect.any(String), statusReports: expect.any(Array), monitors: expect.any(Array), incidents: expect.any(Array), - published: true, - slug: "status", - title: "Test Page", + published: expect.any(Boolean), + slug: expect.any(String), + title: expect.any(String), updatedAt: expect.any(Date), }); }); diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index ab63b5c6..6f792293 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -12,6 +12,7 @@ import { selectPageSchemaWithMonitorsRelation, selectPublicPageSchemaWithRelation, statusReport, + subdomainSafeList, workspace, } from "@openstatus/db/src/schema"; @@ -345,19 +346,7 @@ export const pageRouter = createTRPCRouter({ .input(z.object({ slug: z.string().toLowerCase() })) .query(async (opts) => { // had filter on some words we want to keep for us - if ( - [ - "api", - "app", - "www", - "docs", - "checker", - "time", - "help", - "data-table", - "light", - ].includes(opts.input.slug) - ) { + if (subdomainSafeList.includes(opts.input.slug)) { return false; } const result = await opts.ctx.db.query.page.findMany({ diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index d747cabd..e2cc9cf2 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -2,10 +2,11 @@ import { TRPCError } from "@trpc/server"; import * as randomWordSlugs from "random-word-slugs"; import { z } from "zod"; -import { and, eq, isNull, sql } from "@openstatus/db"; +import { and, eq, gte, isNull, sql } from "@openstatus/db"; import { application, monitor, + monitorRun, notification, page, selectApplicationSchema, @@ -197,7 +198,8 @@ export const workspaceRouter = createTRPCRouter({ }), getCurrentWorkspaceNumbers: protectedProcedure.query(async (opts) => { - console.log(opts.ctx.workspace.id); + const lastMonth = new Date().setMonth(new Date().getMonth() - 1); + const currentNumbers = await opts.ctx.db.transaction(async (tx) => { const notifications = await tx .select({ count: sql`count(*)` }) @@ -216,10 +218,23 @@ export const workspaceRouter = createTRPCRouter({ .select({ count: sql`count(*)` }) .from(page) .where(eq(page.workspaceId, opts.ctx.workspace.id)); + + const runs = await tx + .select({ count: sql`count(*)` }) + .from(monitorRun) + .where( + and( + eq(monitorRun.workspaceId, opts.ctx.workspace.id), + gte(monitorRun.createdAt, new Date(lastMonth)), + ), + ) + .all(); + return { "notification-channels": notifications?.[0].count || 0, monitors: monitors?.[0].count || 0, "status-pages": pages?.[0].count || 0, + "synthetic-checks": runs?.[0].count || 0, } satisfies Partial; }); diff --git a/packages/db/src/schema/pages/constants.ts b/packages/db/src/schema/pages/constants.ts new file mode 100644 index 00000000..e31b9f7c --- /dev/null +++ b/packages/db/src/schema/pages/constants.ts @@ -0,0 +1,12 @@ +export const subdomainSafeList = [ + "api", + "app", + "www", + "docs", + "checker", + "time", + "help", + "data-table", + "light", + "workflows", +]; diff --git a/packages/db/src/schema/pages/index.ts b/packages/db/src/schema/pages/index.ts index 455e18d1..b09f0300 100644 --- a/packages/db/src/schema/pages/index.ts +++ b/packages/db/src/schema/pages/index.ts @@ -1,3 +1,4 @@ export * from "./page"; export * from "./validation"; export type * from "./validation"; +export * from "./constants"; diff --git a/packages/db/src/schema/plan/schema.ts b/packages/db/src/schema/plan/schema.ts index ac10c924..5e730532 100644 --- a/packages/db/src/schema/plan/schema.ts +++ b/packages/db/src/schema/plan/schema.ts @@ -10,7 +10,7 @@ export const limitsSchema = z.object({ * Monitor limits */ monitors: z.number().default(1), - "synthetic-checks": z.number().default(30), + "synthetic-checks": z.number().default(30), // monthly limits periodicity: monitorPeriodicitySchema.array().default(["10m", "30m", "1h"]), "multi-region": z.boolean().default(true), "max-regions": z.number().default(6), diff --git a/packages/emails/src/send.ts b/packages/emails/src/send.ts index 846b8482..6c623b19 100644 --- a/packages/emails/src/send.ts +++ b/packages/emails/src/send.ts @@ -19,15 +19,19 @@ export type EmailHtml = { from: string; }; export const sendEmail = async (email: Emails) => { + if (process.env.NODE_ENV !== "production") return; await resend.emails.send(email); }; export const sendBatchEmailHtml = async (emails: EmailHtml[]) => { + if (process.env.NODE_ENV !== "production") return; await resend.batch.send(emails); }; // TODO: delete in favor of sendBatchEmailHtml export const sendEmailHtml = async (emails: EmailHtml[]) => { + if (process.env.NODE_ENV !== "production") return; + await fetch("https://api.resend.com/emails/batch", { method: "POST", headers: { diff --git a/packages/emails/src/utils.ts b/packages/emails/src/utils.ts index 6e51c80f..2ac8ff0d 100644 --- a/packages/emails/src/utils.ts +++ b/packages/emails/src/utils.ts @@ -2,7 +2,7 @@ export const validateEmailNotDisposable = async (mailHost: string) => { const response = await fetch( `https://open.kickbox.com/v1/disposable/${mailHost}`, ); - const status = await response.json(); + const status = (await response.json()) as Record; return status.disposable; }; diff --git a/packages/error/src/error-code.ts b/packages/error/src/error-code.ts index 7c405f0a..7b1655bb 100644 --- a/packages/error/src/error-code.ts +++ b/packages/error/src/error-code.ts @@ -1,17 +1,17 @@ import { z } from "zod"; -export const ErrorCodeEnum = z.enum([ +export const ErrorCodes = [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", - "USAGE_EXCEEDED", - "DISABLED", + "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", - "NOT_UNIQUE", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY", -]); +] as const; + +export const ErrorCodeEnum = z.enum(ErrorCodes); export type ErrorCode = z.infer; diff --git a/packages/error/src/utils.ts b/packages/error/src/utils.ts index d437d093..22663001 100644 --- a/packages/error/src/utils.ts +++ b/packages/error/src/utils.ts @@ -8,6 +8,8 @@ export function statusToCode(status: number): ErrorCode { return "BAD_REQUEST"; case 401: return "UNAUTHORIZED"; + case 402: + return "PAYMENT_REQUIRED"; case 403: return "FORBIDDEN"; case 404: @@ -15,7 +17,7 @@ export function statusToCode(status: number): ErrorCode { case 405: return "METHOD_NOT_ALLOWED"; case 409: - return "METHOD_NOT_ALLOWED"; + return "CONFLICT"; case 422: return "UNPROCESSABLE_ENTITY"; case 500: @@ -25,12 +27,14 @@ export function statusToCode(status: number): ErrorCode { } } -export function codeToStatus(code: ErrorCode): number { +export function codeToStatus(code: ErrorCode) { switch (code) { case "BAD_REQUEST": return 400; case "UNAUTHORIZED": return 401; + case "PAYMENT_REQUIRED": + return 402; case "FORBIDDEN": return 403; case "NOT_FOUND": diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index c6b489ba..0b9ebf18 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -9,11 +9,11 @@ export class OSTinybird { private readonly tb: Client; constructor(token: string) { - if (process.env.NODE_ENV === "development") { - this.tb = new NoopTinybird(); - } else { - this.tb = new Client({ token }); - } + // if (process.env.NODE_ENV === "development") { + // this.tb = new NoopTinybird(); + // } else { + this.tb = new Client({ token }); + // } } public get homeStats() { @@ -346,7 +346,8 @@ export class OSTinybird { timestamp: z.number(), workspaceId: z.string(), }), - opts: { cache: "no-store" }, + // REMINDER: cache the result for accessing the data for a check as it won't change + opts: { cache: "force-cache" }, }); } @@ -685,7 +686,8 @@ export class OSTinybird { timestamp: z.number(), workspaceId: z.string(), }), - opts: { cache: "no-store" }, + // REMINDER: cache the result for accessing the data for a check as it won't change + opts: { cache: "force-cache" }, }); } } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 64798b87..3cfbd5c5 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -461,3 +461,5 @@ export const tpcPayloadSchema = z.object({ degradedAfter: z.number().nullable(), trigger: z.enum(["cron", "api"]).optional().nullable().default("cron"), }); + +export type TcpPayload = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f949dfc..67bb8703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,8 +122,11 @@ importers: specifier: 0.2.2 version: 0.2.2(hono@4.5.3)(zod@3.23.8) '@openstatus/analytics': - specifier: workspace:^ + specifier: workspace:* version: link:../../packages/analytics + '@openstatus/assertions': + specifier: workspace:* + version: link:../../packages/assertions '@openstatus/db': specifier: workspace:* version: link:../../packages/db