Openstatus www.openstatus.dev
6
fork

Configure Feed

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

๐Ÿ”ฅ create page via api (#715)

* ๐Ÿ”ฅ create page via api

* ๐Ÿ”ฅ add tests

* ๐Ÿ”ฅ add tests

* ๐Ÿ”ฅ improve page api

authored by

Thibault Le Ouay and committed by
GitHub
32c1a466 4efe1583

+456 -6
+1 -1
apps/server/src/v1/incidents.ts
··· 1 1 import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; 2 2 3 - import { and, db, eq, isNotNull } from "@openstatus/db"; 3 + import { and, db, eq } from "@openstatus/db"; 4 4 import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 5 6 6 import type { Variables } from "./index";
+4
apps/server/src/v1/index.ts
··· 1 1 import { OpenAPIHono } from "@hono/zod-openapi"; 2 2 import { logger } from "hono/logger"; 3 3 4 + import { page } from "@openstatus/db/src/schema"; 4 5 import type { Limits } from "@openstatus/plans/src/types"; 5 6 6 7 import { incidentsApi } from "./incidents"; 7 8 import { middleware } from "./middleware"; 8 9 import { monitorApi } from "./monitor"; 10 + import { pageApi } from "./page"; 9 11 import { statusReportApi } from "./statusReport"; 10 12 import { statusReportUpdateApi } from "./statusReportUpdate"; 11 13 ··· 37 39 api.route("/monitor", monitorApi); 38 40 api.route("/status_report_update", statusReportUpdateApi); 39 41 api.route("/incident", incidentsApi); 42 + api.route("/page", pageApi); 43 + 40 44 api.route("/status_report", statusReportApi);
+67
apps/server/src/v1/page.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { api } from "."; 4 + import { iso8601Regex } from "./test-utils"; 5 + 6 + test.only("Create a page", async () => { 7 + const data = { 8 + title: "OpenStatus", 9 + description: "OpenStatus website", 10 + slug: "openstatus", 11 + }; 12 + const res = await api.request("/page", { 13 + method: "POST", 14 + headers: { 15 + "x-openstatus-key": "1", 16 + "content-type": "application/json", 17 + }, 18 + body: JSON.stringify(data), 19 + }); 20 + expect(res.status).toBe(200); 21 + 22 + expect(await res.json()).toMatchObject({ 23 + id: expect.any(Number), 24 + title: "OpenStatus", 25 + description: "OpenStatus website", 26 + slug: "openstatus", 27 + }); 28 + }); 29 + 30 + test("Create a page without auth key should return 401", async () => { 31 + const data = { 32 + title: "OpenStatus", 33 + description: "OpenStatus website", 34 + slug: "openstatus", 35 + }; 36 + const res = await api.request("/page", { 37 + method: "POST", 38 + headers: { 39 + "content-type": "application/json", 40 + }, 41 + body: JSON.stringify(data), 42 + }); 43 + expect(res.status).toBe(401); 44 + }); 45 + 46 + test("Create a page with invalid data should return 403", async () => { 47 + const data = { 48 + description: "OpenStatus website", 49 + slug: "openstatus", 50 + }; 51 + const res = await api.request("/page", { 52 + method: "POST", 53 + headers: { 54 + "x-openstatus-key": "1", 55 + "content-type": "application/json", 56 + }, 57 + body: JSON.stringify(data), 58 + }); 59 + expect(res.status).toBe(400); 60 + expect(await res.json()).toMatchObject({ 61 + error: { 62 + issues: expect.any(Array), 63 + name: "ZodError", 64 + }, 65 + success: false, 66 + }); 67 + });
+383 -4
apps/server/src/v1/page.ts
··· 1 1 import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; 2 2 3 - import { and, eq } from "@openstatus/db"; 3 + import { and, desc, eq, inArray, sql } from "@openstatus/db"; 4 4 import { db } from "@openstatus/db/src/db"; 5 - import { page, pageSubscriber } from "@openstatus/db/src/schema"; 5 + import { 6 + monitor, 7 + monitorsToPages, 8 + page, 9 + pageSubscriber, 10 + } from "@openstatus/db/src/schema"; 6 11 import { SubscribeEmail } from "@openstatus/emails"; 7 12 import { sendEmail } from "@openstatus/emails/emails/send"; 8 13 ··· 25 30 }), 26 31 }); 27 32 33 + const PageSchema = z.object({ 34 + id: z.number().openapi({ 35 + description: "The id of the page", 36 + example: 1, 37 + }), 38 + title: z.string().openapi({ 39 + description: "The title of the page", 40 + example: "My Page", 41 + }), 42 + description: z.string().openapi({ 43 + description: "The description of the page", 44 + example: "My awesome status page", 45 + }), 46 + slug: z.string().openapi({ 47 + description: "The slug of the page", 48 + example: "my-page", 49 + }), 50 + customDomain: z 51 + .string() 52 + .openapi({ 53 + description: "The custom domain of the page", 54 + example: "my-page.com", 55 + }) 56 + .transform((val) => (val ? val : undefined)) 57 + .nullish(), 58 + 59 + icon: z 60 + .string() 61 + .openapi({ 62 + description: "The icon of the page", 63 + example: "https://example.com/icon.png", 64 + }) 65 + .url() 66 + .or(z.literal("")) 67 + .transform((val) => (val ? val : undefined)) 68 + .nullish(), 69 + monitors: z 70 + .array(z.number()) 71 + .openapi({ 72 + description: "The monitors of the page", 73 + example: [1, 2], 74 + }) 75 + .optional(), 76 + }); 77 + 78 + const CreatePageSchema = z.object({ 79 + title: z.string().openapi({ 80 + description: "The title of the page", 81 + example: "My Page", 82 + }), 83 + description: z.string().openapi({ 84 + description: "The description of the page", 85 + example: "My awesome status page", 86 + }), 87 + icon: z 88 + .string() 89 + .url() 90 + .openapi({ 91 + description: "The icon of the page", 92 + example: "https://example.com/icon.png", 93 + }) 94 + .optional(), 95 + slug: z.string().openapi({ 96 + description: "The slug of the page", 97 + example: "my-page", 98 + }), 99 + customDomain: z 100 + .string() 101 + .openapi({ 102 + description: "The custom domain of the page", 103 + example: "my-page.com", 104 + }) 105 + .optional() 106 + .default(""), 107 + monitors: z 108 + .array(z.number()) 109 + .openapi({ 110 + description: "The monitors of the page", 111 + example: [1, 2], 112 + }) 113 + .nullish(), 114 + }); 115 + 116 + const UpdatePageSchema = z.object({ 117 + title: z 118 + .string() 119 + .openapi({ 120 + description: "The title of the page", 121 + example: "My Page", 122 + }) 123 + .optional(), 124 + description: z.string().openapi({ 125 + description: "The description of the page", 126 + example: "My awesome status page", 127 + }), 128 + icon: z 129 + .string() 130 + .url() 131 + .openapi({ 132 + description: "The icon of the page", 133 + example: "https://example.com/icon.png", 134 + }) 135 + .optional(), 136 + slug: z 137 + .string() 138 + .openapi({ 139 + description: "The slug of the page", 140 + example: "my-page", 141 + }) 142 + .optional(), 143 + customDomain: z 144 + .string() 145 + .openapi({ 146 + description: "The custom domain of the page", 147 + example: "my-page.com", 148 + }) 149 + .optional() 150 + .default(""), 151 + monitors: z 152 + .array(z.number()) 153 + .openapi({ 154 + description: "The monitors of the page", 155 + example: [1, 2], 156 + }) 157 + .nullish(), 158 + }); 159 + 28 160 const pageSubscriberSchema = z.object({ 29 161 email: z.string().email().openapi({ 30 162 description: "The email of the subscriber", ··· 33 165 34 166 const postRouteSubscriber = createRoute({ 35 167 method: "post", 36 - tags: ["status_report"], 168 + tags: ["page"], 37 169 path: "/:id/update", 38 - description: "Add a subscriber to a status report", 170 + description: "Add a subscriber to a status page", 39 171 request: { 40 172 params: ParamsSchema, 41 173 body: { ··· 129 261 ...data, 130 262 }); 131 263 }); 264 + 265 + const getRoute = createRoute({ 266 + method: "get", 267 + tags: ["page"], 268 + description: "Get a status page", 269 + path: "/:id", 270 + request: { 271 + params: ParamsSchema, 272 + }, 273 + responses: { 274 + 200: { 275 + content: { 276 + "application/json": { 277 + schema: PageSchema, 278 + }, 279 + }, 280 + description: "Get an Status page", 281 + }, 282 + 400: { 283 + content: { 284 + "application/json": { 285 + schema: ErrorSchema, 286 + }, 287 + }, 288 + description: "Returns an error", 289 + }, 290 + }, 291 + }); 292 + 293 + pageApi.openapi(getRoute, async (c) => { 294 + const workspaceId = Number(c.get("workspaceId")); 295 + const { id } = c.req.valid("param"); 296 + 297 + const pageId = Number(id); 298 + const result = await db 299 + .select() 300 + .from(page) 301 + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, pageId))) 302 + .get(); 303 + 304 + if (!result) return c.json({ code: 404, message: "Not Found" }, 404); 305 + const data = PageSchema.parse(result); 306 + 307 + return c.json(data); 308 + }); 309 + 310 + const postRoute = createRoute({ 311 + method: "post", 312 + tags: ["page"], 313 + description: "Create a status page", 314 + path: "/", 315 + request: { 316 + body: { 317 + description: "The monitor to create", 318 + content: { 319 + "application/json": { 320 + schema: CreatePageSchema, 321 + }, 322 + }, 323 + }, 324 + }, 325 + responses: { 326 + 200: { 327 + content: { 328 + "application/json": { 329 + schema: PageSchema, 330 + }, 331 + }, 332 + description: "Get an Status page", 333 + }, 334 + 400: { 335 + content: { 336 + "application/json": { 337 + schema: ErrorSchema, 338 + }, 339 + }, 340 + description: "Returns an error", 341 + }, 342 + }, 343 + }); 344 + 345 + pageApi.openapi(postRoute, async (c) => { 346 + const workspaceId = Number(c.get("workspaceId")); 347 + 348 + // Check if the user has reached the limit of pages 349 + const count = ( 350 + await db 351 + .select({ count: sql<number>`count(*)` }) 352 + .from(page) 353 + .where(eq(page.workspaceId, workspaceId)) 354 + .all() 355 + )[0].count; 356 + 357 + const workspacePlan = c.get("workspacePlan"); 358 + 359 + if (count >= workspacePlan.limits["status-pages"]) 360 + return c.json({ code: 403, message: "Forbidden" }, 403); 361 + 362 + const input = c.req.valid("json"); 363 + 364 + const countSlug = ( 365 + await db 366 + .select({ count: sql<number>`count(*)` }) 367 + .from(page) 368 + .where(eq(page.slug, input.slug)) 369 + .all() 370 + )[0].count; 371 + 372 + if (countSlug > 0) 373 + return c.json({ code: 400, message: "Slug already taken" }, 400); 374 + 375 + const { monitors, ...rest } = input; 376 + if (monitors) { 377 + const monitorsData = await db 378 + .select() 379 + .from(monitor) 380 + .where( 381 + and( 382 + inArray(monitor.id, monitors), 383 + eq(monitor.workspaceId, workspaceId), 384 + ), 385 + ) 386 + .all(); 387 + if (monitorsData.length !== monitors.length) 388 + return c.json({ code: 400, message: "Monitor not found" }, 400); 389 + } 390 + const newPage = await db 391 + .insert(page) 392 + .values({ workspaceId, ...rest }) 393 + .returning() 394 + .get(); 395 + 396 + if (monitors) { 397 + for (const monitorId of monitors) { 398 + await db 399 + .insert(monitorsToPages) 400 + .values({ pageId: newPage.id, monitorId }) 401 + .run(); 402 + } 403 + } 404 + const data = PageSchema.parse(newPage); 405 + return c.json(data); 406 + }); 407 + 408 + const putRoute = createRoute({ 409 + method: "put", 410 + tags: ["page"], 411 + description: "Update a status page", 412 + path: "/:id", 413 + request: { 414 + params: ParamsSchema, 415 + body: { 416 + description: "The monitor to update", 417 + content: { 418 + "application/json": { 419 + schema: UpdatePageSchema, 420 + }, 421 + }, 422 + }, 423 + }, 424 + responses: { 425 + 200: { 426 + content: { 427 + "application/json": { 428 + schema: PageSchema, 429 + }, 430 + }, 431 + description: "Get an Status page", 432 + }, 433 + 400: { 434 + content: { 435 + "application/json": { 436 + schema: ErrorSchema, 437 + }, 438 + }, 439 + description: "Returns an error", 440 + }, 441 + }, 442 + }); 443 + 444 + pageApi.openapi(putRoute, async (c) => { 445 + const input = c.req.valid("json"); 446 + 447 + const workspaceId = Number(c.get("workspaceId")); 448 + const { id } = c.req.valid("param"); 449 + 450 + if (!id) return c.json({ code: 400, message: "Bad Request" }, 400); 451 + 452 + const _page = await db 453 + .select() 454 + .from(page) 455 + .where(eq(page.id, Number(id))) 456 + .get(); 457 + 458 + if (!_page) return c.json({ code: 404, message: "Not Found" }, 404); 459 + 460 + if (workspaceId !== _page.workspaceId) 461 + return c.json({ code: 401, message: "Unauthorized" }, 401); 462 + 463 + if (input.slug) { 464 + const countSlug = ( 465 + await db 466 + .select({ count: sql<number>`count(*)` }) 467 + .from(page) 468 + .where(eq(page.slug, input.slug)) 469 + .all() 470 + )[0].count; 471 + 472 + if (countSlug > 0) 473 + return c.json({ code: 400, message: "Slug already taken" }, 400); 474 + } 475 + const { monitors, ...rest } = input; 476 + if (monitors) { 477 + const monitorsData = await db 478 + .select() 479 + .from(monitor) 480 + .where( 481 + and( 482 + inArray(monitor.id, monitors), 483 + eq(monitor.workspaceId, workspaceId), 484 + ), 485 + ) 486 + .all(); 487 + if (monitorsData.length !== monitors.length) 488 + return c.json({ code: 400, message: "Monitor not found" }, 400); 489 + } 490 + 491 + const newPage = await db 492 + .update(page) 493 + .set({ ...rest }) 494 + .returning() 495 + .get(); 496 + 497 + const data = PageSchema.parse(newPage); 498 + 499 + if (monitors) { 500 + for (const monitorId of monitors) { 501 + await db 502 + .insert(monitorsToPages) 503 + .values({ pageId: _page.id, monitorId }) 504 + .run(); 505 + } 506 + } 507 + return c.json(data); 508 + }); 509 + 510 + export { pageApi };
+1 -1
packages/notifications/email/src/index.ts
··· 26 26 }, 27 27 body: JSON.stringify({ 28 28 to: email, 29 - from: "Notifications <ping@openstatus.dev>", 29 + from: "Notifications <ping@notifications.openstatus.dev>", 30 30 31 31 subject: `Your monitor ${monitor.name} is down ๐Ÿšจ`, 32 32 html: `<p>Hi,<br><br>Your monitor ${monitor.name} is down. </p><p>URL : ${