Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 1036 lines 31 kB view raw
1import { TRPCError } from "@trpc/server"; 2import { z } from "zod"; 3 4import { 5 type SQL, 6 and, 7 desc, 8 eq, 9 gte, 10 inArray, 11 isNull, 12 lte, 13 sql, 14 syncMonitorGroupDeleteMany, 15 syncMonitorGroupInsert, 16 syncMonitorsToPageDelete, 17 syncMonitorsToPageDeleteByPage, 18 syncMonitorsToPageInsertMany, 19} from "@openstatus/db"; 20import { 21 incidentTable, 22 insertPageSchema, 23 legacy_selectPublicPageSchemaWithRelation, 24 maintenance, 25 monitor, 26 monitorGroup, 27 monitorsToPages, 28 page, 29 pageAccessTypes, 30 selectMaintenanceSchema, 31 selectMonitorGroupSchema, 32 selectMonitorSchema, 33 selectPageSchema, 34 selectPageSchemaWithMonitorsRelation, 35 statusReport, 36 subdomainSafeList, 37 workspace, 38} from "@openstatus/db/src/schema"; 39 40import { Events } from "@openstatus/analytics"; 41import { env } from "../env"; 42import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 43 44if (process.env.NODE_ENV === "test") { 45 require("../test/preload"); 46} 47 48// Helper functions to reuse Vercel API logic 49async function addDomainToVercel(domain: string) { 50 const data = await fetch( 51 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, 52 { 53 body: JSON.stringify({ name: domain }), 54 headers: { 55 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, 56 "Content-Type": "application/json", 57 }, 58 method: "POST", 59 }, 60 ); 61 return data.json(); 62} 63 64async function removeDomainFromVercel(domain: string) { 65 const data = await fetch( 66 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, 67 { 68 headers: { 69 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, 70 }, 71 method: "DELETE", 72 }, 73 ); 74 return data.json(); 75} 76 77export const pageRouter = createTRPCRouter({ 78 create: protectedProcedure 79 .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 80 .input(insertPageSchema) 81 .mutation(async (opts) => { 82 const { monitors, workspaceId, id, configuration, ...pageProps } = 83 opts.input; 84 85 const monitorIds = monitors?.map((item) => item.monitorId) || []; 86 87 const pageNumbers = ( 88 await opts.ctx.db.query.page.findMany({ 89 where: eq(page.workspaceId, opts.ctx.workspace.id), 90 }) 91 ).length; 92 93 const limit = opts.ctx.workspace.limits; 94 95 // the user has reached the status page number limits 96 if (pageNumbers >= limit["status-pages"]) { 97 throw new TRPCError({ 98 code: "FORBIDDEN", 99 message: "You reached your status-page limits.", 100 }); 101 } 102 103 // the user is not eligible for password protection 104 if ( 105 limit["password-protection"] === false && 106 opts.input.passwordProtected === true 107 ) { 108 throw new TRPCError({ 109 code: "FORBIDDEN", 110 message: 111 "Password protection is not available for your current plan.", 112 }); 113 } 114 115 const newPage = await opts.ctx.db 116 .insert(page) 117 .values({ 118 workspaceId: opts.ctx.workspace.id, 119 configuration: JSON.stringify(configuration), 120 ...pageProps, 121 authEmailDomains: pageProps.authEmailDomains?.join(","), 122 }) 123 .returning() 124 .get(); 125 126 if (monitorIds.length) { 127 // We should make sure the user has access to the monitors 128 const allMonitors = await opts.ctx.db.query.monitor.findMany({ 129 where: and( 130 inArray(monitor.id, monitorIds), 131 eq(monitor.workspaceId, opts.ctx.workspace.id), 132 isNull(monitor.deletedAt), 133 ), 134 }); 135 136 if (allMonitors.length !== monitorIds.length) { 137 throw new TRPCError({ 138 code: "FORBIDDEN", 139 message: "You don't have access to all the monitors.", 140 }); 141 } 142 143 const values = monitors.map(({ monitorId }, index) => ({ 144 pageId: newPage.id, 145 order: index, 146 monitorId, 147 })); 148 149 await opts.ctx.db.insert(monitorsToPages).values(values).run(); 150 // Sync to page components 151 await syncMonitorsToPageInsertMany(opts.ctx.db, values); 152 } 153 154 return newPage; 155 }), 156 getPageById: protectedProcedure 157 .input(z.object({ id: z.number() })) 158 .query(async (opts) => { 159 const firstPage = await opts.ctx.db.query.page.findFirst({ 160 where: and( 161 eq(page.id, opts.input.id), 162 eq(page.workspaceId, opts.ctx.workspace.id), 163 ), 164 with: { 165 monitorsToPages: { 166 with: { monitor: true }, 167 orderBy: (monitorsToPages, { asc }) => [asc(monitorsToPages.order)], 168 }, 169 }, 170 }); 171 return selectPageSchemaWithMonitorsRelation.parse(firstPage); 172 }), 173 174 update: protectedProcedure 175 .meta({ track: Events.UpdatePage }) 176 .input(insertPageSchema) 177 .mutation(async (opts) => { 178 const { monitors, ...pageInput } = opts.input; 179 if (!pageInput.id) return; 180 181 const monitorIds = monitors?.map((item) => item.monitorId) || []; 182 183 const limit = opts.ctx.workspace.limits; 184 185 // the user is not eligible for password protection 186 if ( 187 limit["password-protection"] === false && 188 opts.input.passwordProtected === true 189 ) { 190 throw new TRPCError({ 191 code: "FORBIDDEN", 192 message: 193 "Password protection is not available for your current plan.", 194 }); 195 } 196 197 const currentPage = await opts.ctx.db 198 .update(page) 199 .set({ 200 ...pageInput, 201 updatedAt: new Date(), 202 authEmailDomains: pageInput.authEmailDomains?.join(","), 203 }) 204 .where( 205 and( 206 eq(page.id, pageInput.id), 207 eq(page.workspaceId, opts.ctx.workspace.id), 208 ), 209 ) 210 .returning() 211 .get(); 212 213 if (monitorIds.length) { 214 // We should make sure the user has access to the monitors 215 const allMonitors = await opts.ctx.db.query.monitor.findMany({ 216 where: and( 217 inArray(monitor.id, monitorIds), 218 eq(monitor.workspaceId, opts.ctx.workspace.id), 219 isNull(monitor.deletedAt), 220 ), 221 }); 222 223 if (allMonitors.length !== monitorIds.length) { 224 throw new TRPCError({ 225 code: "FORBIDDEN", 226 message: "You don't have access to all the monitors.", 227 }); 228 } 229 } 230 231 // TODO: check for monitor order! 232 const currentMonitorsToPages = await opts.ctx.db 233 .select() 234 .from(monitorsToPages) 235 .where(eq(monitorsToPages.pageId, currentPage.id)) 236 .all(); 237 238 const removedMonitors = currentMonitorsToPages 239 .map(({ monitorId }) => monitorId) 240 .filter((x) => !monitorIds?.includes(x)); 241 242 if (removedMonitors.length) { 243 await opts.ctx.db 244 .delete(monitorsToPages) 245 .where( 246 and( 247 inArray(monitorsToPages.monitorId, removedMonitors), 248 eq(monitorsToPages.pageId, currentPage.id), 249 ), 250 ); 251 // Sync delete to page components 252 for (const monitorId of removedMonitors) { 253 await syncMonitorsToPageDelete(opts.ctx.db, { 254 monitorId, 255 pageId: currentPage.id, 256 }); 257 } 258 } 259 260 const values = monitors.map(({ monitorId }, index) => ({ 261 pageId: currentPage.id, 262 order: index, 263 monitorId, 264 })); 265 266 if (values.length) { 267 await opts.ctx.db 268 .insert(monitorsToPages) 269 .values(values) 270 .onConflictDoUpdate({ 271 target: [monitorsToPages.monitorId, monitorsToPages.pageId], 272 set: { order: sql.raw("excluded.`order`") }, 273 }); 274 // Sync new monitors to page components (existing ones will be ignored due to onConflictDoNothing) 275 await syncMonitorsToPageInsertMany(opts.ctx.db, values); 276 } 277 }), 278 delete: protectedProcedure 279 .meta({ track: Events.DeletePage }) 280 .input(z.object({ id: z.number() })) 281 .mutation(async (opts) => { 282 const whereConditions: SQL[] = [ 283 eq(page.id, opts.input.id), 284 eq(page.workspaceId, opts.ctx.workspace.id), 285 ]; 286 287 await opts.ctx.db 288 .delete(page) 289 .where(and(...whereConditions)) 290 .run(); 291 }), 292 293 getPagesByWorkspace: protectedProcedure.query(async (opts) => { 294 const allPages = await opts.ctx.db.query.page.findMany({ 295 where: and(eq(page.workspaceId, opts.ctx.workspace.id)), 296 with: { 297 monitorsToPages: { with: { monitor: true } }, 298 maintenances: { 299 where: and( 300 lte(maintenance.from, new Date()), 301 gte(maintenance.to, new Date()), 302 ), 303 }, 304 statusReports: { 305 orderBy: (reports, { desc }) => desc(reports.updatedAt), 306 with: { 307 statusReportUpdates: { 308 orderBy: (updates, { desc }) => desc(updates.date), 309 }, 310 }, 311 }, 312 }, 313 }); 314 return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages); 315 }), 316 317 // public if we use trpc hooks to get the page from the url 318 getPageBySlug: publicProcedure 319 .input(z.object({ slug: z.string().toLowerCase() })) 320 .output(legacy_selectPublicPageSchemaWithRelation.nullish()) 321 .query(async (opts) => { 322 if (!opts.input.slug) return; 323 324 const result = await opts.ctx.db 325 .select() 326 .from(page) 327 .where( 328 sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 329 ) 330 .get(); 331 332 if (!result) return; 333 334 const [workspaceResult, monitorsToPagesResult] = await Promise.all([ 335 opts.ctx.db 336 .select() 337 .from(workspace) 338 .where(eq(workspace.id, result.workspaceId)) 339 .get(), 340 opts.ctx.db 341 .select() 342 .from(monitorsToPages) 343 .leftJoin(monitor, eq(monitorsToPages.monitorId, monitor.id)) 344 .where( 345 // make sur only active monitors are returned! 346 and( 347 eq(monitorsToPages.pageId, result.id), 348 eq(monitor.active, true), 349 ), 350 ) 351 .all(), 352 ]); 353 354 // FIXME: There is probably a better way to do this 355 356 const monitorsId = monitorsToPagesResult.map( 357 ({ monitors_to_pages }) => monitors_to_pages.monitorId, 358 ); 359 360 const statusReports = await opts.ctx.db.query.statusReport.findMany({ 361 where: eq(statusReport.pageId, result.id), 362 with: { 363 statusReportUpdates: { 364 orderBy: (reports, { desc }) => desc(reports.date), 365 }, 366 monitorsToStatusReports: { with: { monitor: true } }, 367 }, 368 }); 369 370 const monitorQuery = 371 monitorsId.length > 0 372 ? opts.ctx.db 373 .select() 374 .from(monitor) 375 .where( 376 and( 377 inArray(monitor.id, monitorsId), 378 eq(monitor.active, true), 379 isNull(monitor.deletedAt), 380 ), // REMINDER: this is hardcoded 381 ) 382 .all() 383 : []; 384 385 const maintenancesQuery = opts.ctx.db.query.maintenance.findMany({ 386 where: eq(maintenance.pageId, result.id), 387 with: { maintenancesToMonitors: { with: { monitor: true } } }, 388 orderBy: (maintenances, { desc }) => desc(maintenances.from), 389 }); 390 391 const incidentsQuery = 392 monitorsId.length > 0 393 ? await opts.ctx.db 394 .select() 395 .from(incidentTable) 396 .where(inArray(incidentTable.monitorId, monitorsId)) 397 .all() 398 : []; 399 // TODO: monitorsToPagesResult has the result already, no need to query again 400 const [monitors, maintenances, incidents] = await Promise.all([ 401 monitorQuery, 402 maintenancesQuery, 403 incidentsQuery, 404 ]); 405 406 return legacy_selectPublicPageSchemaWithRelation.parse({ 407 ...result, 408 // TODO: improve performance and move into SQLite query 409 monitors: monitors.sort((a, b) => { 410 const aIndex = 411 monitorsToPagesResult.find((m) => m.monitor?.id === a.id) 412 ?.monitors_to_pages.order || 0; 413 const bIndex = 414 monitorsToPagesResult.find((m) => m.monitor?.id === b.id) 415 ?.monitors_to_pages.order || 0; 416 return aIndex - bIndex; 417 }), 418 incidents, 419 statusReports, 420 maintenances: maintenances.map((m) => ({ 421 ...m, 422 monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 423 })), 424 workspacePlan: workspaceResult?.plan, 425 }); 426 }), 427 428 getSlugUniqueness: protectedProcedure 429 .input(z.object({ slug: z.string().toLowerCase() })) 430 .query(async (opts) => { 431 // had filter on some words we want to keep for us 432 if (subdomainSafeList.includes(opts.input.slug)) { 433 return false; 434 } 435 const result = await opts.ctx.db.query.page.findMany({ 436 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 437 }); 438 return !(result?.length > 0); 439 }), 440 441 addCustomDomain: protectedProcedure 442 .input( 443 z.object({ customDomain: z.string().toLowerCase(), pageId: z.number() }), 444 ) 445 .mutation(async (opts) => { 446 if (opts.input.customDomain.toLowerCase().includes("openstatus")) { 447 throw new TRPCError({ 448 code: "BAD_REQUEST", 449 message: "Domain cannot contain 'openstatus'", 450 }); 451 } 452 453 // TODO Add some check ? 454 await opts.ctx.db 455 .update(page) 456 .set({ customDomain: opts.input.customDomain, updatedAt: new Date() }) 457 .where(eq(page.id, opts.input.pageId)) 458 .returning() 459 .get(); 460 }), 461 462 isPageLimitReached: protectedProcedure.query(async (opts) => { 463 const pageLimit = opts.ctx.workspace.limits["status-pages"]; 464 const pageNumbers = ( 465 await opts.ctx.db.query.page.findMany({ 466 where: eq(monitor.workspaceId, opts.ctx.workspace.id), 467 }) 468 ).length; 469 470 return pageNumbers >= pageLimit; 471 }), 472 473 // DASHBOARD 474 475 list: protectedProcedure 476 .input( 477 z 478 .object({ 479 order: z.enum(["asc", "desc"]).optional(), 480 }) 481 .optional(), 482 ) 483 .query(async (opts) => { 484 const whereConditions: SQL[] = [ 485 eq(page.workspaceId, opts.ctx.workspace.id), 486 ]; 487 488 const result = await opts.ctx.db.query.page.findMany({ 489 where: and(...whereConditions), 490 with: { 491 statusReports: true, 492 }, 493 orderBy: (pages, { asc }) => [ 494 opts.input?.order === "asc" 495 ? asc(pages.createdAt) 496 : desc(pages.createdAt), 497 ], 498 }); 499 500 return result; 501 }), 502 503 get: protectedProcedure 504 .input(z.object({ id: z.number() })) 505 .query(async (opts) => { 506 const whereConditions: SQL[] = [ 507 eq(page.workspaceId, opts.ctx.workspace.id), 508 eq(page.id, opts.input.id), 509 ]; 510 511 const data = await opts.ctx.db.query.page.findFirst({ 512 where: and(...whereConditions), 513 with: { 514 monitorsToPages: { with: { monitor: true, monitorGroup: true } }, 515 maintenances: true, 516 }, 517 }); 518 519 return selectPageSchema 520 .extend({ 521 monitors: z 522 .array( 523 selectMonitorSchema.extend({ 524 order: z.number().prefault(0), 525 groupOrder: z.number().prefault(0), 526 groupId: z.number().nullable(), 527 }), 528 ) 529 .prefault([]), 530 monitorGroups: z.array(selectMonitorGroupSchema).prefault([]), 531 maintenances: z.array(selectMaintenanceSchema).prefault([]), 532 }) 533 .parse({ 534 ...data, 535 monitors: data?.monitorsToPages.map((m) => ({ 536 ...m.monitor, 537 order: m.order, 538 groupId: m.monitorGroupId, 539 groupOrder: m.groupOrder, 540 })), 541 monitorGroups: Array.from( 542 new Map( 543 data?.monitorsToPages 544 .filter((m) => m.monitorGroup) 545 .map((m) => [m.monitorGroup?.id, m.monitorGroup]), 546 ).values(), 547 ), 548 maintenances: data?.maintenances, 549 }); 550 }), 551 552 // TODO: rename to create 553 new: protectedProcedure 554 .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 555 .input( 556 z.object({ 557 title: z.string(), 558 slug: z.string().toLowerCase(), 559 icon: z.string().nullish(), 560 description: z.string().nullish(), 561 }), 562 ) 563 .mutation(async (opts) => { 564 const pageNumbers = ( 565 await opts.ctx.db.query.page.findMany({ 566 where: eq(page.workspaceId, opts.ctx.workspace.id), 567 }) 568 ).length; 569 570 const limit = opts.ctx.workspace.limits; 571 572 // the user has reached the status page number limits 573 if (pageNumbers >= limit["status-pages"]) { 574 throw new TRPCError({ 575 code: "FORBIDDEN", 576 message: "You reached your status-page limits.", 577 }); 578 } 579 580 const result = await opts.ctx.db.query.page.findMany({ 581 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 582 }); 583 584 if (subdomainSafeList.includes(opts.input.slug) || result?.length > 0) { 585 throw new TRPCError({ 586 code: "BAD_REQUEST", 587 message: "This slug is already taken. Please choose another one.", 588 }); 589 } 590 591 // REMINDER: default config from legacy page 592 const defaultConfiguration = { 593 type: "absolute", 594 value: "requests", 595 uptime: true, 596 theme: "default-rounded", 597 } satisfies Record<string, string | boolean | undefined>; 598 599 const newPage = await opts.ctx.db 600 .insert(page) 601 .values({ 602 workspaceId: opts.ctx.workspace.id, 603 title: opts.input.title, 604 slug: opts.input.slug, 605 description: opts.input.description ?? "", 606 icon: opts.input.icon ?? "", 607 legacyPage: false, 608 configuration: defaultConfiguration, 609 customDomain: "", // TODO: make nullable 610 }) 611 .returning() 612 .get(); 613 614 return newPage; 615 }), 616 617 updateGeneral: protectedProcedure 618 .meta({ track: Events.UpdatePage }) 619 .input( 620 z.object({ 621 id: z.number(), 622 title: z.string(), 623 slug: z.string().toLowerCase(), 624 description: z.string().nullish(), 625 icon: z.string().nullish(), 626 }), 627 ) 628 .mutation(async (opts) => { 629 const whereConditions: SQL[] = [ 630 eq(page.workspaceId, opts.ctx.workspace.id), 631 eq(page.id, opts.input.id), 632 ]; 633 634 const result = await opts.ctx.db.query.page.findMany({ 635 where: sql`lower(${page.slug}) = ${opts.input.slug}`, 636 }); 637 638 const oldSlug = await opts.ctx.db.query.page.findFirst({ 639 where: and(...whereConditions), 640 }); 641 642 if ( 643 subdomainSafeList.includes(opts.input.slug) || 644 (oldSlug?.slug !== opts.input.slug && result?.length > 0) 645 ) { 646 throw new TRPCError({ 647 code: "BAD_REQUEST", 648 message: "This slug is already taken. Please choose another one.", 649 }); 650 } 651 652 await opts.ctx.db 653 .update(page) 654 .set({ 655 title: opts.input.title, 656 slug: opts.input.slug, 657 description: opts.input.description ?? "", 658 icon: opts.input.icon ?? "", 659 updatedAt: new Date(), 660 }) 661 .where(and(...whereConditions)) 662 .run(); 663 }), 664 665 updateCustomDomain: protectedProcedure 666 .meta({ track: Events.UpdatePageDomain, trackProps: ["customDomain"] }) 667 .input(z.object({ id: z.number(), customDomain: z.string().toLowerCase() })) 668 .mutation(async (opts) => { 669 const whereConditions: SQL[] = [ 670 eq(page.workspaceId, opts.ctx.workspace.id), 671 eq(page.id, opts.input.id), 672 ]; 673 674 if (opts.input.customDomain.includes("openstatus")) { 675 throw new TRPCError({ 676 code: "BAD_REQUEST", 677 message: "Domain cannot contain 'openstatus'", 678 }); 679 } 680 681 // Get the current page to check the existing custom domain 682 const currentPage = await opts.ctx.db.query.page.findFirst({ 683 where: and(...whereConditions), 684 }); 685 686 if (!currentPage) { 687 throw new TRPCError({ 688 code: "NOT_FOUND", 689 message: "Page not found", 690 }); 691 } 692 693 const oldDomain = currentPage.customDomain; 694 const newDomain = opts.input.customDomain; 695 696 try { 697 // Handle domain changes 698 if (newDomain && !oldDomain) { 699 // Adding a new domain 700 await opts.ctx.db 701 .update(page) 702 .set({ customDomain: newDomain, updatedAt: new Date() }) 703 .where(and(...whereConditions)) 704 .run(); 705 706 // Add domain to Vercel using the domain router logic 707 await addDomainToVercel(newDomain); 708 } else if (oldDomain && newDomain !== oldDomain) { 709 // Changing domain - remove old and add new 710 await opts.ctx.db 711 .update(page) 712 .set({ customDomain: newDomain, updatedAt: new Date() }) 713 .where(and(...whereConditions)) 714 .run(); 715 716 // Remove old domain from Vercel 717 await removeDomainFromVercel(oldDomain); 718 719 // Add new domain to Vercel 720 if (newDomain) { 721 await addDomainToVercel(newDomain); 722 } 723 } else if (oldDomain && newDomain === "") { 724 // Removing domain 725 await opts.ctx.db 726 .update(page) 727 .set({ customDomain: "", updatedAt: new Date() }) 728 .where(and(...whereConditions)) 729 .run(); 730 731 // Remove domain from Vercel 732 await removeDomainFromVercel(oldDomain); 733 } else { 734 // No change needed, just update the database 735 await opts.ctx.db 736 .update(page) 737 .set({ customDomain: newDomain, updatedAt: new Date() }) 738 .where(and(...whereConditions)) 739 .run(); 740 } 741 } catch (error) { 742 // If Vercel operations fail, we should rollback the database change 743 // For now, we'll just throw the error 744 console.error("Error updating custom domain:", error); 745 throw new TRPCError({ 746 code: "INTERNAL_SERVER_ERROR", 747 message: "Failed to update custom domain", 748 }); 749 } 750 }), 751 752 updatePasswordProtection: protectedProcedure 753 .meta({ track: Events.UpdatePage }) 754 .input( 755 z.object({ 756 id: z.number(), 757 accessType: z.enum(pageAccessTypes), 758 authEmailDomains: z.array(z.string()).nullish(), 759 password: z.string().nullish(), 760 }), 761 ) 762 .mutation(async (opts) => { 763 const whereConditions: SQL[] = [ 764 eq(page.workspaceId, opts.ctx.workspace.id), 765 eq(page.id, opts.input.id), 766 ]; 767 768 const limit = opts.ctx.workspace.limits; 769 770 // the user is not eligible for password protection 771 if ( 772 limit["password-protection"] === false && 773 opts.input.accessType === "password" 774 ) { 775 throw new TRPCError({ 776 code: "FORBIDDEN", 777 message: 778 "Password protection is not available for your current plan.", 779 }); 780 } 781 782 if ( 783 limit["email-domain-protection"] === false && 784 opts.input.accessType === "email-domain" 785 ) { 786 throw new TRPCError({ 787 code: "FORBIDDEN", 788 message: 789 "Email domain protection is not available for your current plan.", 790 }); 791 } 792 793 await opts.ctx.db 794 .update(page) 795 .set({ 796 accessType: opts.input.accessType, 797 authEmailDomains: opts.input.authEmailDomains?.join(","), 798 password: opts.input.password, 799 updatedAt: new Date(), 800 }) 801 .where(and(...whereConditions)) 802 .run(); 803 }), 804 805 updateAppearance: protectedProcedure 806 .meta({ track: Events.UpdatePage }) 807 .input( 808 z.object({ 809 id: z.number(), 810 forceTheme: z.enum(["light", "dark", "system"]), 811 configuration: z.object({ 812 theme: z.string(), 813 }), 814 }), 815 ) 816 .mutation(async (opts) => { 817 const whereConditions: SQL[] = [ 818 eq(page.workspaceId, opts.ctx.workspace.id), 819 eq(page.id, opts.input.id), 820 ]; 821 822 const _page = await opts.ctx.db.query.page.findFirst({ 823 where: and(...whereConditions), 824 }); 825 826 if (!_page) { 827 throw new TRPCError({ 828 code: "NOT_FOUND", 829 message: "Page not found", 830 }); 831 } 832 833 const currentConfiguration = 834 (typeof _page.configuration === "object" && 835 _page.configuration !== null && 836 _page.configuration) || 837 {}; 838 const updatedConfiguration = { 839 ...currentConfiguration, 840 theme: opts.input.configuration.theme, 841 }; 842 843 await opts.ctx.db 844 .update(page) 845 .set({ 846 forceTheme: opts.input.forceTheme, 847 configuration: updatedConfiguration, 848 updatedAt: new Date(), 849 }) 850 .where(and(...whereConditions)) 851 .run(); 852 }), 853 854 updateLinks: protectedProcedure 855 .meta({ track: Events.UpdatePage }) 856 .input( 857 z.object({ 858 id: z.number(), 859 homepageUrl: z.string().nullish(), 860 contactUrl: z.string().nullish(), 861 }), 862 ) 863 .mutation(async (opts) => { 864 const whereConditions: SQL[] = [ 865 eq(page.workspaceId, opts.ctx.workspace.id), 866 eq(page.id, opts.input.id), 867 ]; 868 869 await opts.ctx.db 870 .update(page) 871 .set({ 872 homepageUrl: opts.input.homepageUrl, 873 contactUrl: opts.input.contactUrl, 874 updatedAt: new Date(), 875 }) 876 .where(and(...whereConditions)) 877 .run(); 878 }), 879 880 updatePageConfiguration: protectedProcedure 881 .meta({ track: Events.UpdatePage }) 882 .input( 883 z.object({ 884 id: z.number(), 885 configuration: z 886 .record(z.string(), z.string().or(z.boolean()).optional()) 887 .nullish(), 888 }), 889 ) 890 .mutation(async (opts) => { 891 const whereConditions: SQL[] = [ 892 eq(page.workspaceId, opts.ctx.workspace.id), 893 eq(page.id, opts.input.id), 894 ]; 895 896 const _page = await opts.ctx.db.query.page.findFirst({ 897 where: and(...whereConditions), 898 }); 899 900 if (!_page) { 901 throw new TRPCError({ 902 code: "NOT_FOUND", 903 message: "Page not found", 904 }); 905 } 906 907 const currentConfiguration = 908 (typeof _page.configuration === "object" && 909 _page.configuration !== null && 910 _page.configuration) || 911 {}; 912 const updatedConfiguration = { 913 ...currentConfiguration, 914 ...opts.input.configuration, 915 }; 916 917 await opts.ctx.db 918 .update(page) 919 .set({ 920 configuration: updatedConfiguration, 921 updatedAt: new Date(), 922 }) 923 .where(and(...whereConditions)) 924 .run(); 925 }), 926 927 updateMonitors: protectedProcedure 928 .meta({ track: Events.UpdatePage }) 929 .input( 930 z.object({ 931 id: z.number(), 932 monitors: z.array(z.object({ id: z.number(), order: z.number() })), 933 groups: z.array( 934 z.object({ 935 // id: z.number(), // we dont need it as we are deleting and adding 936 order: z.number(), 937 name: z.string(), 938 monitors: z.array(z.object({ id: z.number(), order: z.number() })), 939 }), 940 ), 941 }), 942 ) 943 .mutation(async (opts) => { 944 const monitorIds = opts.input.monitors.map((m) => m.id); 945 const groupMonitorIds = opts.input.groups.flatMap((g) => 946 g.monitors.map((m) => m.id), 947 ); 948 949 const allMonitorIds = [...new Set([...monitorIds, ...groupMonitorIds])]; 950 // check if the monitors are in the workspace 951 const monitors = await opts.ctx.db.query.monitor.findMany({ 952 where: and( 953 inArray(monitor.id, allMonitorIds), 954 eq(monitor.workspaceId, opts.ctx.workspace.id), 955 ), 956 }); 957 958 if (monitors.length !== allMonitorIds.length) { 959 throw new TRPCError({ 960 code: "FORBIDDEN", 961 message: "You don't have access to all the monitors.", 962 }); 963 } 964 965 await opts.ctx.db.transaction(async (tx) => { 966 // Get existing monitor groups to delete from page components 967 const existingGroups = await tx.query.monitorGroup.findMany({ 968 where: eq(monitorGroup.pageId, opts.input.id), 969 }); 970 const existingGroupIds = existingGroups.map((g) => g.id); 971 972 // Delete child records first to avoid foreign key constraint violation 973 await tx 974 .delete(monitorsToPages) 975 .where(eq(monitorsToPages.pageId, opts.input.id)); 976 await tx 977 .delete(monitorGroup) 978 .where(eq(monitorGroup.pageId, opts.input.id)); 979 980 // Sync deletes to page components 981 await syncMonitorsToPageDeleteByPage(tx, opts.input.id); 982 if (existingGroupIds.length > 0) { 983 await syncMonitorGroupDeleteMany(tx, existingGroupIds); 984 } 985 986 if (opts.input.groups.length > 0) { 987 const monitorGroups = await tx 988 .insert(monitorGroup) 989 .values( 990 opts.input.groups.map((g) => ({ 991 workspaceId: opts.ctx.workspace.id, 992 pageId: opts.input.id, 993 name: g.name, 994 })), 995 ) 996 .returning(); 997 998 // Sync new monitor groups to page component groups 999 for (const group of monitorGroups) { 1000 await syncMonitorGroupInsert(tx, { 1001 id: group.id, 1002 workspaceId: opts.ctx.workspace.id, 1003 pageId: opts.input.id, 1004 name: group.name, 1005 }); 1006 } 1007 1008 const groupMonitorValues = opts.input.groups.flatMap((g, i) => 1009 g.monitors.map((m) => ({ 1010 pageId: opts.input.id, 1011 monitorId: m.id, 1012 order: g.order, 1013 monitorGroupId: monitorGroups[i].id, 1014 groupOrder: m.order, 1015 })), 1016 ); 1017 1018 await tx.insert(monitorsToPages).values(groupMonitorValues); 1019 // Sync to page components 1020 await syncMonitorsToPageInsertMany(tx, groupMonitorValues); 1021 } 1022 1023 if (opts.input.monitors.length > 0) { 1024 const monitorValues = opts.input.monitors.map((m) => ({ 1025 pageId: opts.input.id, 1026 monitorId: m.id, 1027 order: m.order, 1028 })); 1029 1030 await tx.insert(monitorsToPages).values(monitorValues); 1031 // Sync to page components 1032 await syncMonitorsToPageInsertMany(tx, monitorValues); 1033 } 1034 }); 1035 }), 1036});