Openstatus www.openstatus.dev
at main 660 lines 19 kB view raw
1import { TRPCError } from "@trpc/server"; 2import { z } from "zod"; 3 4import { 5 type SQL, 6 and, 7 desc, 8 eq, 9 inArray, 10 isNull, 11 sql, 12 syncPageComponentToMonitorsToPageInsertMany, 13} from "@openstatus/db"; 14import { 15 insertPageSchema, 16 monitor, 17 page, 18 pageAccessTypes, 19 pageComponent, 20 selectMaintenanceSchema, 21 selectPageComponentGroupSchema, 22 selectPageComponentSchema, 23 selectPageSchema, 24 subdomainSafeList, 25} from "@openstatus/db/src/schema"; 26 27import { Events } from "@openstatus/analytics"; 28import { env } from "../env"; 29import { createTRPCRouter, protectedProcedure } from "../trpc"; 30 31if (process.env.NODE_ENV === "test") { 32 require("../test/preload"); 33} 34 35// Helper functions to reuse Vercel API logic 36async function addDomainToVercel(domain: string) { 37 const data = await fetch( 38 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, 39 { 40 body: JSON.stringify({ name: domain }), 41 headers: { 42 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, 43 "Content-Type": "application/json", 44 }, 45 method: "POST", 46 }, 47 ); 48 return data.json(); 49} 50 51async function removeDomainFromVercel(domain: string) { 52 const data = await fetch( 53 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, 54 { 55 headers: { 56 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, 57 }, 58 method: "DELETE", 59 }, 60 ); 61 return data.json(); 62} 63 64export const pageRouter = createTRPCRouter({ 65 create: protectedProcedure 66 .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 67 .input(insertPageSchema) 68 .mutation(async (opts) => { 69 const { monitors, workspaceId, id, configuration, ...pageProps } = 70 opts.input; 71 72 const monitorIds = monitors?.map((item) => item.monitorId) || []; 73 74 const pageNumbers = ( 75 await opts.ctx.db.query.page.findMany({ 76 where: eq(page.workspaceId, opts.ctx.workspace.id), 77 }) 78 ).length; 79 80 const limit = opts.ctx.workspace.limits; 81 82 // the user has reached the status page number limits 83 if (pageNumbers >= limit["status-pages"]) { 84 throw new TRPCError({ 85 code: "FORBIDDEN", 86 message: "You reached your status-page limits.", 87 }); 88 } 89 90 // the user is not eligible for password protection 91 if ( 92 limit["password-protection"] === false && 93 opts.input.passwordProtected === true 94 ) { 95 throw new TRPCError({ 96 code: "FORBIDDEN", 97 message: 98 "Password protection is not available for your current plan.", 99 }); 100 } 101 102 const newPage = await opts.ctx.db 103 .insert(page) 104 .values({ 105 workspaceId: opts.ctx.workspace.id, 106 configuration: JSON.stringify(configuration), 107 ...pageProps, 108 authEmailDomains: pageProps.authEmailDomains?.join(","), 109 }) 110 .returning() 111 .get(); 112 113 if (monitorIds.length) { 114 // We should make sure the user has access to the monitors AND they are active 115 const allMonitors = await opts.ctx.db.query.monitor.findMany({ 116 where: and( 117 inArray(monitor.id, monitorIds), 118 eq(monitor.workspaceId, opts.ctx.workspace.id), 119 eq(monitor.active, true), // Only allow active monitors 120 isNull(monitor.deletedAt), 121 ), 122 }); 123 124 if (allMonitors.length !== monitorIds.length) { 125 throw new TRPCError({ 126 code: "FORBIDDEN", 127 message: 128 "You don't have access to all the monitors or some monitors are inactive.", 129 }); 130 } 131 132 // Build a map for quick lookup 133 const monitorMap = new Map(allMonitors.map((m) => [m.id, m])); 134 135 // Build pageComponent values (primary table) 136 const pageComponentValues = monitors 137 .map(({ monitorId }, index) => { 138 const m = monitorMap.get(monitorId); 139 if (!m || !m.workspaceId) return null; 140 return { 141 workspaceId: m.workspaceId, 142 pageId: newPage.id, 143 type: "monitor" as const, 144 monitorId, 145 name: m.externalName || m.name, 146 order: index, 147 groupId: null, 148 groupOrder: 0, 149 }; 150 }) 151 .filter((v): v is NonNullable<typeof v> => v !== null); 152 153 // Insert into pageComponents (primary table) 154 await opts.ctx.db 155 .insert(pageComponent) 156 .values(pageComponentValues) 157 .run(); 158 159 // Build values for reverse sync to monitorsToPages 160 const monitorsToPageValues = monitors.map(({ monitorId }, index) => ({ 161 pageId: newPage.id, 162 order: index, 163 monitorId, 164 })); 165 166 // Reverse sync to monitorsToPages (for backwards compatibility) 167 await syncPageComponentToMonitorsToPageInsertMany( 168 opts.ctx.db, 169 monitorsToPageValues, 170 ); 171 } 172 173 return newPage; 174 }), 175 176 delete: protectedProcedure 177 .meta({ track: Events.DeletePage }) 178 .input(z.object({ id: z.number() })) 179 .mutation(async (opts) => { 180 const whereConditions: SQL[] = [ 181 eq(page.id, opts.input.id), 182 eq(page.workspaceId, opts.ctx.workspace.id), 183 ]; 184 185 await opts.ctx.db 186 .delete(page) 187 .where(and(...whereConditions)) 188 .run(); 189 }), 190 191 getSlugUniqueness: protectedProcedure 192 .input(z.object({ slug: z.string().toLowerCase() })) 193 .query(async (opts) => { 194 // had filter on some words we want to keep for us 195 if (subdomainSafeList.includes(opts.input.slug)) { 196 return false; 197 } 198 const result = await opts.ctx.db.query.page.findMany({ 199 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 200 }); 201 return !(result?.length > 0); 202 }), 203 204 addCustomDomain: protectedProcedure 205 .input( 206 z.object({ customDomain: z.string().toLowerCase(), pageId: z.number() }), 207 ) 208 .mutation(async (opts) => { 209 if (opts.input.customDomain.toLowerCase().includes("openstatus")) { 210 throw new TRPCError({ 211 code: "BAD_REQUEST", 212 message: "Domain cannot contain 'openstatus'", 213 }); 214 } 215 216 // TODO Add some check ? 217 await opts.ctx.db 218 .update(page) 219 .set({ customDomain: opts.input.customDomain, updatedAt: new Date() }) 220 .where(eq(page.id, opts.input.pageId)) 221 .returning() 222 .get(); 223 }), 224 225 list: protectedProcedure 226 .input( 227 z 228 .object({ 229 order: z.enum(["asc", "desc"]).optional(), 230 }) 231 .optional(), 232 ) 233 .query(async (opts) => { 234 const whereConditions: SQL[] = [ 235 eq(page.workspaceId, opts.ctx.workspace.id), 236 ]; 237 238 const result = await opts.ctx.db.query.page.findMany({ 239 where: and(...whereConditions), 240 with: { 241 statusReports: true, 242 }, 243 orderBy: (pages, { asc }) => [ 244 opts.input?.order === "asc" 245 ? asc(pages.createdAt) 246 : desc(pages.createdAt), 247 ], 248 }); 249 250 return result; 251 }), 252 253 get: protectedProcedure 254 .input(z.object({ id: z.number() })) 255 .query(async (opts) => { 256 const whereConditions: SQL[] = [ 257 eq(page.workspaceId, opts.ctx.workspace.id), 258 eq(page.id, opts.input.id), 259 ]; 260 261 const data = await opts.ctx.db.query.page.findFirst({ 262 where: and(...whereConditions), 263 with: { 264 maintenances: true, 265 pageComponents: true, 266 pageComponentGroups: true, 267 }, 268 }); 269 270 return selectPageSchema 271 .extend({ 272 pageComponentGroups: z 273 .array(selectPageComponentGroupSchema) 274 .prefault([]), 275 maintenances: z.array(selectMaintenanceSchema).prefault([]), 276 pageComponents: z.array(selectPageComponentSchema).prefault([]), 277 }) 278 .parse({ 279 ...data, 280 pageComponentGroups: data?.pageComponentGroups ?? [], 281 maintenances: data?.maintenances, 282 pageComponents: data?.pageComponents, 283 }); 284 }), 285 286 // TODO: rename to create 287 new: protectedProcedure 288 .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 289 .input( 290 z.object({ 291 title: z.string(), 292 slug: z.string().toLowerCase(), 293 icon: z.string().nullish(), 294 description: z.string().nullish(), 295 }), 296 ) 297 .mutation(async (opts) => { 298 const pageNumbers = ( 299 await opts.ctx.db.query.page.findMany({ 300 where: eq(page.workspaceId, opts.ctx.workspace.id), 301 }) 302 ).length; 303 304 const limit = opts.ctx.workspace.limits; 305 306 // the user has reached the status page number limits 307 if (pageNumbers >= limit["status-pages"]) { 308 throw new TRPCError({ 309 code: "FORBIDDEN", 310 message: "You reached your status-page limits.", 311 }); 312 } 313 314 const result = await opts.ctx.db.query.page.findMany({ 315 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 316 }); 317 318 if (subdomainSafeList.includes(opts.input.slug) || result?.length > 0) { 319 throw new TRPCError({ 320 code: "BAD_REQUEST", 321 message: "This slug is already taken. Please choose another one.", 322 }); 323 } 324 325 // REMINDER: default config from legacy page 326 const defaultConfiguration = { 327 type: "absolute", 328 value: "requests", 329 uptime: true, 330 theme: "default-rounded", 331 } satisfies Record<string, string | boolean | undefined>; 332 333 const newPage = await opts.ctx.db 334 .insert(page) 335 .values({ 336 workspaceId: opts.ctx.workspace.id, 337 title: opts.input.title, 338 slug: opts.input.slug, 339 description: opts.input.description ?? "", 340 icon: opts.input.icon ?? "", 341 legacyPage: false, 342 configuration: defaultConfiguration, 343 customDomain: "", // TODO: make nullable 344 }) 345 .returning() 346 .get(); 347 348 return newPage; 349 }), 350 351 updateGeneral: protectedProcedure 352 .meta({ track: Events.UpdatePage }) 353 .input( 354 z.object({ 355 id: z.number(), 356 title: z.string(), 357 slug: z.string().toLowerCase(), 358 description: z.string().nullish(), 359 icon: z.string().nullish(), 360 }), 361 ) 362 .mutation(async (opts) => { 363 const whereConditions: SQL[] = [ 364 eq(page.workspaceId, opts.ctx.workspace.id), 365 eq(page.id, opts.input.id), 366 ]; 367 368 const result = await opts.ctx.db.query.page.findMany({ 369 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 370 }); 371 372 const oldSlug = await opts.ctx.db.query.page.findFirst({ 373 where: and(...whereConditions), 374 }); 375 376 if ( 377 subdomainSafeList.includes(opts.input.slug) || 378 (oldSlug?.slug !== opts.input.slug && result?.length > 0) 379 ) { 380 throw new TRPCError({ 381 code: "BAD_REQUEST", 382 message: "This slug is already taken. Please choose another one.", 383 }); 384 } 385 386 await opts.ctx.db 387 .update(page) 388 .set({ 389 title: opts.input.title, 390 slug: opts.input.slug, 391 description: opts.input.description ?? "", 392 icon: opts.input.icon ?? "", 393 updatedAt: new Date(), 394 }) 395 .where(and(...whereConditions)) 396 .run(); 397 }), 398 399 updateCustomDomain: protectedProcedure 400 .meta({ track: Events.UpdatePageDomain, trackProps: ["customDomain"] }) 401 .input(z.object({ id: z.number(), customDomain: z.string().toLowerCase() })) 402 .mutation(async (opts) => { 403 const whereConditions: SQL[] = [ 404 eq(page.workspaceId, opts.ctx.workspace.id), 405 eq(page.id, opts.input.id), 406 ]; 407 408 if (opts.input.customDomain.includes("openstatus")) { 409 throw new TRPCError({ 410 code: "BAD_REQUEST", 411 message: "Domain cannot contain 'openstatus'", 412 }); 413 } 414 415 // Get the current page to check the existing custom domain 416 const currentPage = await opts.ctx.db.query.page.findFirst({ 417 where: and(...whereConditions), 418 }); 419 420 if (!currentPage) { 421 throw new TRPCError({ 422 code: "NOT_FOUND", 423 message: "Page not found", 424 }); 425 } 426 427 const oldDomain = currentPage.customDomain; 428 const newDomain = opts.input.customDomain; 429 430 try { 431 // Handle domain changes 432 if (newDomain && !oldDomain) { 433 // Adding a new domain 434 await opts.ctx.db 435 .update(page) 436 .set({ customDomain: newDomain, updatedAt: new Date() }) 437 .where(and(...whereConditions)) 438 .run(); 439 440 // Add domain to Vercel using the domain router logic 441 await addDomainToVercel(newDomain); 442 } else if (oldDomain && newDomain !== oldDomain) { 443 // Changing domain - remove old and add new 444 await opts.ctx.db 445 .update(page) 446 .set({ customDomain: newDomain, updatedAt: new Date() }) 447 .where(and(...whereConditions)) 448 .run(); 449 450 // Remove old domain from Vercel 451 await removeDomainFromVercel(oldDomain); 452 453 // Add new domain to Vercel 454 if (newDomain) { 455 await addDomainToVercel(newDomain); 456 } 457 } else if (oldDomain && newDomain === "") { 458 // Removing domain 459 await opts.ctx.db 460 .update(page) 461 .set({ customDomain: "", updatedAt: new Date() }) 462 .where(and(...whereConditions)) 463 .run(); 464 465 // Remove domain from Vercel 466 await removeDomainFromVercel(oldDomain); 467 } else { 468 // No change needed, just update the database 469 await opts.ctx.db 470 .update(page) 471 .set({ customDomain: newDomain, updatedAt: new Date() }) 472 .where(and(...whereConditions)) 473 .run(); 474 } 475 } catch (error) { 476 // If Vercel operations fail, we should rollback the database change 477 // For now, we'll just throw the error 478 console.error("Error updating custom domain:", error); 479 throw new TRPCError({ 480 code: "INTERNAL_SERVER_ERROR", 481 message: "Failed to update custom domain", 482 }); 483 } 484 }), 485 486 updatePasswordProtection: protectedProcedure 487 .meta({ track: Events.UpdatePage }) 488 .input( 489 z.object({ 490 id: z.number(), 491 accessType: z.enum(pageAccessTypes), 492 authEmailDomains: z.array(z.string()).nullish(), 493 password: z.string().nullish(), 494 }), 495 ) 496 .mutation(async (opts) => { 497 const whereConditions: SQL[] = [ 498 eq(page.workspaceId, opts.ctx.workspace.id), 499 eq(page.id, opts.input.id), 500 ]; 501 502 const limit = opts.ctx.workspace.limits; 503 504 // the user is not eligible for password protection 505 if ( 506 limit["password-protection"] === false && 507 opts.input.accessType === "password" 508 ) { 509 throw new TRPCError({ 510 code: "FORBIDDEN", 511 message: 512 "Password protection is not available for your current plan.", 513 }); 514 } 515 516 if ( 517 limit["email-domain-protection"] === false && 518 opts.input.accessType === "email-domain" 519 ) { 520 throw new TRPCError({ 521 code: "FORBIDDEN", 522 message: 523 "Email domain protection is not available for your current plan.", 524 }); 525 } 526 527 await opts.ctx.db 528 .update(page) 529 .set({ 530 accessType: opts.input.accessType, 531 authEmailDomains: opts.input.authEmailDomains?.join(","), 532 password: opts.input.password, 533 updatedAt: new Date(), 534 }) 535 .where(and(...whereConditions)) 536 .run(); 537 }), 538 539 updateAppearance: protectedProcedure 540 .meta({ track: Events.UpdatePage }) 541 .input( 542 z.object({ 543 id: z.number(), 544 forceTheme: z.enum(["light", "dark", "system"]), 545 configuration: z.object({ 546 theme: z.string(), 547 }), 548 }), 549 ) 550 .mutation(async (opts) => { 551 const whereConditions: SQL[] = [ 552 eq(page.workspaceId, opts.ctx.workspace.id), 553 eq(page.id, opts.input.id), 554 ]; 555 556 const _page = await opts.ctx.db.query.page.findFirst({ 557 where: and(...whereConditions), 558 }); 559 560 if (!_page) { 561 throw new TRPCError({ 562 code: "NOT_FOUND", 563 message: "Page not found", 564 }); 565 } 566 567 const currentConfiguration = 568 (typeof _page.configuration === "object" && 569 _page.configuration !== null && 570 _page.configuration) || 571 {}; 572 const updatedConfiguration = { 573 ...currentConfiguration, 574 theme: opts.input.configuration.theme, 575 }; 576 577 await opts.ctx.db 578 .update(page) 579 .set({ 580 forceTheme: opts.input.forceTheme, 581 configuration: updatedConfiguration, 582 updatedAt: new Date(), 583 }) 584 .where(and(...whereConditions)) 585 .run(); 586 }), 587 588 updateLinks: protectedProcedure 589 .meta({ track: Events.UpdatePage }) 590 .input( 591 z.object({ 592 id: z.number(), 593 homepageUrl: z.string().nullish(), 594 contactUrl: z.string().nullish(), 595 }), 596 ) 597 .mutation(async (opts) => { 598 const whereConditions: SQL[] = [ 599 eq(page.workspaceId, opts.ctx.workspace.id), 600 eq(page.id, opts.input.id), 601 ]; 602 603 await opts.ctx.db 604 .update(page) 605 .set({ 606 homepageUrl: opts.input.homepageUrl, 607 contactUrl: opts.input.contactUrl, 608 updatedAt: new Date(), 609 }) 610 .where(and(...whereConditions)) 611 .run(); 612 }), 613 614 updatePageConfiguration: protectedProcedure 615 .meta({ track: Events.UpdatePage }) 616 .input( 617 z.object({ 618 id: z.number(), 619 configuration: z 620 .record(z.string(), z.string().or(z.boolean()).optional()) 621 .nullish(), 622 }), 623 ) 624 .mutation(async (opts) => { 625 const whereConditions: SQL[] = [ 626 eq(page.workspaceId, opts.ctx.workspace.id), 627 eq(page.id, opts.input.id), 628 ]; 629 630 const _page = await opts.ctx.db.query.page.findFirst({ 631 where: and(...whereConditions), 632 }); 633 634 if (!_page) { 635 throw new TRPCError({ 636 code: "NOT_FOUND", 637 message: "Page not found", 638 }); 639 } 640 641 const currentConfiguration = 642 (typeof _page.configuration === "object" && 643 _page.configuration !== null && 644 _page.configuration) || 645 {}; 646 const updatedConfiguration = { 647 ...currentConfiguration, 648 ...opts.input.configuration, 649 }; 650 651 await opts.ctx.db 652 .update(page) 653 .set({ 654 configuration: updatedConfiguration, 655 updatedAt: new Date(), 656 }) 657 .where(and(...whereConditions)) 658 .run(); 659 }), 660});