Openstatus www.openstatus.dev
at main 1317 lines 42 kB view raw
1import { z } from "zod"; 2 3import { and, eq, inArray, sql } from "@openstatus/db"; 4import { 5 maintenance, 6 page, 7 pageComponent, 8 pageConfigurationSchema, 9 pageSubscriber, 10 selectMaintenancePageSchema, 11 selectPageComponentWithMonitorRelation, 12 selectPublicMonitorSchema, 13 selectPublicPageLightSchemaWithRelation, 14 selectPublicPageSchemaWithRelation, 15 selectStatusReportPageSchema, 16 selectWorkspaceSchema, 17 statusReport, 18} from "@openstatus/db/src/schema"; 19 20import { Events } from "@openstatus/analytics"; 21import { TRPCError } from "@trpc/server"; 22import { endOfDay, startOfDay, subDays } from "date-fns"; 23import { createTRPCRouter, publicProcedure } from "../trpc"; 24import { 25 type StatusData, 26 fillStatusDataFor45Days, 27 fillStatusDataFor45DaysNoop, 28 getEvents, 29 getUptime, 30 getWorstVariant, 31 isMonitorComponent, 32 setDataByType, 33} from "./statusPage.utils"; 34import { 35 getMetricsLatencyMultiProcedure, 36 getMetricsLatencyProcedure, 37 getMetricsRegionsProcedure, 38 getStatusProcedure, 39 getUptimeProcedure, 40} from "./tinybird"; 41 42// NOTE: publicProcedure is used to get the status page 43// TODO: improve performance of SQL query (make a single query with joins) 44 45// IMPORTANT: we cannot use the tinybird procedure because it has protectedProcedure 46// instead, we should add TB logic in here!!!! 47 48// NOTE: this router is used on status pages only - do not confuse with the page router which is used in the dashboard for the config 49 50/** 51 * Right now, we do not allow workspaces to have a custom lookback period. 52 * If we decide to allow this in the future, we should move this to the database. 53 */ 54const WORKSPACES = 55 process.env.WORKSPACES_LOOKBACK_30?.split(",").map(Number) || []; 56 57export const statusPageRouter = createTRPCRouter({ 58 get: publicProcedure 59 .input( 60 z.object({ 61 slug: z.string().toLowerCase(), 62 // NOTE: override the defaults we are getting from the page configuration 63 cardType: z 64 .enum(["requests", "duration", "dominant", "manual"]) 65 .nullish(), 66 barType: z.enum(["absolute", "dominant", "manual"]).nullish(), 67 }), 68 ) 69 .output(selectPublicPageSchemaWithRelation.nullish()) 70 .query(async (opts) => { 71 if (!opts.input.slug) return null; 72 73 const _page = await opts.ctx.db.query.page.findFirst({ 74 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 75 with: { 76 workspace: true, 77 statusReports: { 78 // TODO: we need to order the based on statusReportUpdates instead 79 // orderBy: (reports, { desc }) => desc(reports.createdAt), 80 with: { 81 statusReportUpdates: { 82 orderBy: (reports, { desc }) => desc(reports.date), 83 }, 84 statusReportsToPageComponents: { with: { pageComponent: true } }, 85 }, 86 }, 87 maintenances: { 88 with: { 89 maintenancesToPageComponents: { with: { pageComponent: true } }, 90 }, 91 orderBy: (maintenances, { desc }) => desc(maintenances.from), 92 }, 93 pageComponents: { 94 with: { 95 monitor: { 96 with: { 97 incidents: true, 98 }, 99 }, 100 group: true, 101 }, 102 orderBy: (pageComponents, { asc }) => asc(pageComponents.order), 103 }, 104 pageComponentGroups: true, 105 }, 106 }); 107 108 if (!_page) return null; 109 110 const ws = selectWorkspaceSchema.safeParse(_page.workspace); 111 const pageComponents = selectPageComponentWithMonitorRelation 112 .array() 113 .parse(_page.pageComponents); 114 115 const configuration = pageConfigurationSchema.safeParse( 116 _page.configuration ?? {}, 117 ); 118 119 if (!configuration.success) { 120 console.error("Invalid configuration", configuration.error); 121 return null; 122 } 123 124 const barType = opts.input.barType ?? configuration.data.type; 125 // const cardType = opts.input.cardType ?? configuration.data.value; 126 127 const monitorComponents = pageComponents.filter(isMonitorComponent); 128 129 // Transform all page components (both monitor and static types) 130 const components = pageComponents.map((c) => { 131 const events = getEvents({ 132 maintenances: _page.maintenances, 133 incidents: c.monitor?.incidents ?? [], 134 reports: _page.statusReports, 135 pageComponentId: c.id, 136 monitorId: c.monitorId ?? undefined, 137 componentType: c.type, 138 }); 139 140 // Calculate status based on component type 141 let status: "success" | "degraded" | "error" | "info"; 142 143 if (c.type === "static") { 144 // Static: only reports and maintenances affect status 145 status = events.some((e) => e.type === "report" && !e.to) 146 ? "degraded" 147 : events.some( 148 (e) => 149 e.type === "maintenance" && 150 e.to && 151 e.from.getTime() <= new Date().getTime() && 152 e.to.getTime() >= new Date().getTime(), 153 ) 154 ? "info" 155 : "success"; 156 } else { 157 // Monitor: incidents, reports, and maintenances affect status 158 status = 159 events.some((e) => e.type === "incident" && !e.to) && 160 barType !== "manual" 161 ? "error" 162 : events.some((e) => e.type === "report" && !e.to) 163 ? "degraded" 164 : events.some( 165 (e) => 166 e.type === "maintenance" && 167 e.to && 168 e.from.getTime() <= new Date().getTime() && 169 e.to.getTime() >= new Date().getTime(), 170 ) 171 ? "info" 172 : "success"; 173 } 174 175 return { 176 ...c, 177 status, 178 events, 179 }; 180 }); 181 182 // Keep monitors for backward compatibility with existing fields 183 const monitors = monitorComponents.map((c) => { 184 const events = getEvents({ 185 maintenances: _page.maintenances, 186 incidents: c.monitor.incidents ?? [], 187 reports: _page.statusReports, 188 monitorId: c.monitor.id, 189 }); 190 const status = 191 events.some((e) => e.type === "incident" && !e.to) && 192 barType !== "manual" 193 ? "error" 194 : events.some((e) => e.type === "report" && !e.to) 195 ? "degraded" 196 : events.some( 197 (e) => 198 e.type === "maintenance" && 199 e.to && 200 e.from.getTime() <= new Date().getTime() && 201 e.to.getTime() >= new Date().getTime(), 202 ) 203 ? "info" 204 : "success"; 205 return { 206 ...c.monitor, 207 status, 208 events, 209 monitorGroupId: c.groupId, 210 order: c.order, 211 groupOrder: c.groupOrder, 212 }; 213 }); 214 215 const status = 216 monitors.some((m) => m.status === "error") && barType !== "manual" 217 ? "error" 218 : monitors.some((m) => m.status === "degraded") 219 ? "degraded" 220 : monitors.some((m) => m.status === "info") 221 ? "info" 222 : "success"; 223 224 // Get page-wide events (not tied to specific monitors) 225 const pageEvents = getEvents({ 226 maintenances: _page.maintenances, 227 incidents: monitorComponents.flatMap((c) => c.monitor.incidents ?? []), 228 reports: _page.statusReports, 229 // No monitorId provided, so we get all events for the page 230 }); 231 232 const threshold = new Date().getTime() - 7 * 24 * 60 * 60 * 1000; 233 const lastEvents = pageEvents 234 .filter((e) => { 235 if (e.type === "incident") return false; 236 if (!e.from || e.from.getTime() >= threshold) return true; 237 if (e.type === "report" && e.status !== "success") return true; 238 return false; 239 }) 240 .sort((a, b) => a.from.getTime() - b.from.getTime()); 241 242 const openEvents = pageEvents.filter((event) => { 243 if (event.type === "incident" && barType !== "manual") { 244 if (!event.to) return true; 245 if (event.to < new Date()) return false; 246 return false; 247 } 248 if (event.type === "report") { 249 if (!event.to) return true; 250 if (event.to < new Date()) return false; 251 return false; 252 } 253 if (event.type === "maintenance") { 254 if (!event.to) return false; // NOTE: this never happens 255 if (event.from <= new Date() && event.to >= new Date()) return true; 256 return false; 257 } 258 return false; 259 }); 260 261 const monitorGroups = _page.pageComponentGroups; 262 263 // Create trackers array with grouped and ungrouped components 264 const groupedMap = new Map< 265 number | null, 266 { 267 groupId: number | null; 268 groupName: string | null; 269 components: typeof components; 270 minOrder: number; 271 } 272 >(); 273 274 components.forEach((component) => { 275 const groupId = component.groupId ?? null; 276 const group = groupId 277 ? monitorGroups.find((g) => g?.id === groupId) 278 : null; 279 const groupName = group?.name ?? null; 280 281 if (!groupedMap.has(groupId)) { 282 groupedMap.set(groupId, { 283 groupId, 284 groupName, 285 components: [], 286 minOrder: component.order ?? 0, 287 }); 288 } 289 const currentGroup = groupedMap.get(groupId); 290 if (currentGroup) { 291 currentGroup.components.push(component); 292 currentGroup.minOrder = Math.min( 293 currentGroup.minOrder, 294 component.order ?? 0, 295 ); 296 } 297 }); 298 299 // Convert to trackers array 300 type PageComponentTracker = { 301 type: "component"; 302 component: (typeof components)[number]; 303 order: number; 304 }; 305 306 type GroupTracker = { 307 type: "group"; 308 groupId: number; 309 groupName: string; 310 components: typeof components; 311 status: "success" | "degraded" | "error" | "info" | "empty"; 312 order: number; 313 }; 314 315 type Tracker = PageComponentTracker | GroupTracker; 316 317 const trackers: Tracker[] = Array.from(groupedMap.values()) 318 .flatMap((group): Tracker[] => { 319 if (group.groupId === null) { 320 // Ungrouped components - return as individual trackers 321 return group.components.map( 322 (component): PageComponentTracker => ({ 323 type: "component", 324 component, 325 order: component.order ?? 0, 326 }), 327 ); 328 } 329 // Grouped components - return as single group tracker 330 const sortedComponents = group.components.sort( 331 (a, b) => (a.groupOrder ?? 0) - (b.groupOrder ?? 0), 332 ); 333 return [ 334 { 335 type: "group", 336 groupId: group.groupId, 337 groupName: group.groupName ?? "", 338 components: sortedComponents, 339 status: getWorstVariant( 340 group.components.map( 341 (c) => c.status as "success" | "degraded" | "error" | "info", 342 ), 343 ), 344 order: group.minOrder, 345 }, 346 ]; 347 }) 348 .sort((a, b) => a.order - b.order); 349 350 const whiteLabel = ws.data?.limits["white-label"] ?? false; 351 352 const statusReports = _page.statusReports.sort((a, b) => { 353 // Sort reports without updates to the beginning 354 if ( 355 a.statusReportUpdates.length === 0 && 356 b.statusReportUpdates.length === 0 357 ) 358 return 0; 359 if (a.statusReportUpdates.length === 0) return -1; 360 if (b.statusReportUpdates.length === 0) return -1; 361 return ( 362 b.statusReportUpdates[ 363 b.statusReportUpdates.length - 1 364 ].date.getTime() - 365 a.statusReportUpdates[a.statusReportUpdates.length - 1].date.getTime() 366 ); 367 }); 368 369 const maintenances = _page.maintenances.sort( 370 (a, b) => b.from.getTime() - a.from.getTime(), 371 ); 372 373 return selectPublicPageSchemaWithRelation.parse({ 374 ..._page, 375 monitors, 376 monitorGroups, 377 trackers, 378 incidents: monitors.flatMap((m) => m.incidents) ?? [], 379 statusReports, 380 maintenances, 381 workspacePlan: _page.workspace.plan, 382 status, 383 lastEvents, 384 openEvents, 385 pageComponents, 386 pageComponentGroups: _page.pageComponentGroups, 387 whiteLabel, 388 }); 389 }), 390 391 getLight: publicProcedure 392 .input(z.object({ slug: z.string().toLowerCase() })) 393 .query(async (opts) => { 394 if (!opts.input.slug) return null; 395 396 // Single query with all relations 397 const _page = await opts.ctx.db.query.page.findFirst({ 398 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 399 with: { 400 workspace: true, 401 statusReports: { 402 with: { 403 statusReportUpdates: { 404 orderBy: (reports, { desc }) => desc(reports.date), 405 }, 406 statusReportsToPageComponents: { with: { pageComponent: true } }, 407 }, 408 }, 409 maintenances: { 410 with: { 411 maintenancesToPageComponents: { with: { pageComponent: true } }, 412 }, 413 orderBy: (maintenances, { desc }) => desc(maintenances.from), 414 }, 415 pageComponents: { 416 with: { 417 monitor: { with: { incidents: true } }, 418 group: true, 419 }, 420 orderBy: (pageComponents, { asc }) => asc(pageComponents.order), 421 }, 422 pageComponentGroups: true, 423 }, 424 }); 425 426 if (!_page) return null; 427 428 // Extract monitor components for backwards compatibility 429 const monitorComponents = _page.pageComponents.filter( 430 (c) => 431 c.type === "monitor" && 432 c.monitor && 433 c.monitor.active && 434 !c.monitor.deletedAt, 435 ); 436 437 // Build legacy monitors array (sorted by order) 438 const monitors = monitorComponents 439 .map((c) => ({ 440 ...c.monitor, 441 name: c.monitor?.externalName ?? c.monitor?.name ?? "", 442 })) 443 .sort((a, b) => { 444 const aComp = monitorComponents.find((m) => m.monitor?.id === a.id); 445 const bComp = monitorComponents.find((m) => m.monitor?.id === b.id); 446 return (aComp?.order ?? 0) - (bComp?.order ?? 0); 447 }); 448 449 // Extract all incidents from monitor components 450 const incidents = monitorComponents.flatMap( 451 (c) => c.monitor?.incidents ?? [], 452 ); 453 454 return selectPublicPageLightSchemaWithRelation.parse({ 455 ..._page, 456 monitors, 457 incidents, 458 statusReports: _page.statusReports, 459 maintenances: _page.maintenances, 460 pageComponents: _page.pageComponents, 461 pageComponentGroups: _page.pageComponentGroups, 462 workspacePlan: _page.workspace.plan, 463 }); 464 }), 465 466 getMaintenance: publicProcedure 467 .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 468 .query(async (opts) => { 469 if (!opts.input.slug) return null; 470 471 const _page = await opts.ctx.db 472 .select() 473 .from(page) 474 .where( 475 sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 476 ) 477 .get(); 478 479 if (!_page) return null; 480 481 const _maintenance = await opts.ctx.db.query.maintenance.findFirst({ 482 where: and( 483 eq(maintenance.id, opts.input.id), 484 eq(maintenance.pageId, _page.id), 485 ), 486 with: { 487 maintenancesToPageComponents: { 488 with: { pageComponent: { with: { monitor: true } } }, 489 }, 490 }, 491 }); 492 493 if (!_maintenance) return null; 494 495 const props: z.infer<typeof selectMaintenancePageSchema> = _maintenance; 496 return selectMaintenancePageSchema.parse(props); 497 }), 498 499 getUptime: publicProcedure 500 .input( 501 z.object({ 502 slug: z.string().toLowerCase(), 503 pageComponentIds: z.string().array(), 504 cardType: z 505 .enum(["requests", "duration", "dominant", "manual"]) 506 .prefault("requests"), 507 barType: z 508 .enum(["absolute", "dominant", "manual"]) 509 .prefault("dominant"), 510 }), 511 ) 512 .query(async (opts) => { 513 if (!opts.input.slug) return null; 514 515 const _page = await opts.ctx.db.query.page.findFirst({ 516 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 517 with: { 518 maintenances: { 519 with: { 520 maintenancesToPageComponents: { with: { pageComponent: true } }, 521 }, 522 }, 523 statusReports: { 524 with: { 525 statusReportsToPageComponents: { with: { pageComponent: true } }, 526 statusReportUpdates: true, 527 }, 528 }, 529 pageComponents: { 530 where: inArray( 531 pageComponent.id, 532 opts.input.pageComponentIds.map(Number), 533 ), 534 with: { 535 monitor: { 536 with: { 537 incidents: true, 538 }, 539 }, 540 }, 541 }, 542 }, 543 }); 544 545 if (!_page) return null; 546 547 const pageComponents = selectPageComponentWithMonitorRelation 548 .array() 549 .parse(_page.pageComponents); 550 551 // Early return if no components to process 552 if (pageComponents.length === 0) return []; 553 554 const monitors = pageComponents.filter(isMonitorComponent); 555 556 const monitorsByType = { 557 http: monitors.filter((c) => c.monitor.jobType === "http"), 558 tcp: monitors.filter((c) => c.monitor.jobType === "tcp"), 559 dns: monitors.filter((c) => c.monitor.jobType === "dns"), 560 }; 561 562 const proceduresByType = { 563 http: getStatusProcedure("45d", "http"), 564 tcp: getStatusProcedure("45d", "tcp"), 565 dns: getStatusProcedure("45d", "dns"), 566 }; 567 568 const [statusHttp, statusTcp, statusDns] = await Promise.all( 569 Object.entries(proceduresByType).map(([type, procedure]) => { 570 const monitorIds = monitorsByType[ 571 type as keyof typeof proceduresByType 572 ].map((c) => c.monitor.id.toString()); 573 if (monitorIds.length === 0) return null; 574 // NOTE: if manual mode, don't fetch data from tinybird 575 return opts.input.barType === "manual" 576 ? null 577 : procedure({ monitorIds }); 578 }), 579 ); 580 581 const statusDataByMonitorId = new Map< 582 string, 583 | Awaited<ReturnType<(typeof proceduresByType)["http"]>>["data"] 584 | Awaited<ReturnType<(typeof proceduresByType)["tcp"]>>["data"] 585 | Awaited<ReturnType<(typeof proceduresByType)["dns"]>>["data"] 586 >(); 587 588 // Consolidate status data from all monitor types into the map 589 for (const statusResult of [statusHttp, statusTcp, statusDns]) { 590 if (statusResult?.data) { 591 statusResult.data.forEach((status) => { 592 const monitorId = status.monitorId; 593 if (!statusDataByMonitorId.has(monitorId)) { 594 statusDataByMonitorId.set(monitorId, []); 595 } 596 statusDataByMonitorId.get(monitorId)?.push(status); 597 }); 598 } 599 } 600 601 const lookbackPeriod = WORKSPACES.includes(_page.workspaceId ?? 0) 602 ? 30 603 : 45; 604 605 return pageComponents.map((c) => { 606 const events = getEvents({ 607 maintenances: _page.maintenances, 608 incidents: c.monitor?.incidents ?? [], 609 reports: _page.statusReports, 610 pageComponentId: c.id, 611 monitorId: c.monitorId ?? undefined, 612 componentType: c.type, 613 }); 614 615 // Determine whether to use real Tinybird data or synthetic data 616 const shouldUseRealData = 617 c.type === "monitor" && 618 c.monitor && 619 opts.input.barType !== "manual" && 620 process.env.NOOP_UPTIME !== "true"; 621 622 let filledData: StatusData[]; 623 if (shouldUseRealData) { 624 // Monitor components with real data: use Tinybird data 625 const monitorId = c.monitor?.id.toString() || ""; 626 const rawData = statusDataByMonitorId.get(monitorId) || []; 627 filledData = fillStatusDataFor45Days( 628 rawData, 629 monitorId, 630 lookbackPeriod, 631 ); 632 } else { 633 // Static components, manual mode, or NOOP mode: use synthetic data 634 filledData = fillStatusDataFor45DaysNoop({ 635 errorDays: [], 636 degradedDays: [], 637 lookbackPeriod, 638 }); 639 } 640 641 // Static components always use manual mode since they don't have real monitoring data 642 const effectiveBarType = 643 c.type === "static" ? "manual" : opts.input.barType; 644 const effectiveCardType = 645 c.type === "static" ? "manual" : opts.input.cardType; 646 647 const processedData = setDataByType({ 648 events, 649 data: filledData, 650 cardType: effectiveCardType, 651 barType: effectiveBarType, 652 }); 653 const uptime = getUptime({ 654 data: filledData, 655 events, 656 barType: effectiveBarType, 657 cardType: effectiveCardType, 658 }); 659 660 return { 661 id: c.id, 662 pageComponentId: c.id, 663 name: c.name, 664 description: c.description, 665 type: c.type, 666 // For monitor-type components, include monitor fields 667 ...(c.monitor ? { monitor: c.monitor } : {}), 668 data: processedData, 669 uptime, 670 }; 671 }); 672 }), 673 674 // NOTE: used for the theme store 675 getNoopUptime: publicProcedure.query(async () => { 676 const data = fillStatusDataFor45DaysNoop({ 677 errorDays: [4], 678 degradedDays: [40], 679 }); 680 const processedData = setDataByType({ 681 events: [ 682 { 683 type: "maintenance", 684 from: new Date(new Date().setDate(new Date().getDate() - 10)), 685 to: new Date(new Date().setDate(new Date().getDate() - 10)), 686 name: "DB migration", 687 id: 1, 688 status: "info", 689 }, 690 ], 691 data, 692 cardType: "requests", 693 barType: "dominant", 694 }); 695 return { 696 data: processedData, 697 uptime: "100%", 698 }; 699 }), 700 701 getReport: publicProcedure 702 .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 703 .query(async (opts) => { 704 if (!opts.input.slug) return null; 705 706 const _page = await opts.ctx.db 707 .select() 708 .from(page) 709 .where( 710 sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 711 ) 712 .get(); 713 714 if (!_page) return null; 715 716 const _report = await opts.ctx.db.query.statusReport.findFirst({ 717 where: and( 718 eq(statusReport.id, opts.input.id), 719 eq(statusReport.pageId, _page.id), 720 ), 721 with: { 722 statusReportsToPageComponents: { 723 with: { pageComponent: { with: { monitor: true } } }, 724 }, 725 statusReportUpdates: { 726 orderBy: (reports, { desc }) => desc(reports.date), 727 }, 728 }, 729 }); 730 731 if (!_report) return null; 732 733 const result: z.infer<typeof selectStatusReportPageSchema> = _report; 734 return selectStatusReportPageSchema.parse(result); 735 }), 736 737 getNoopReport: publicProcedure.query(async () => { 738 const date = new Date(new Date().setDate(new Date().getDate() - 4)); 739 740 const resolvedDate = new Date(date.setMinutes(date.getMinutes() - 81)); 741 const monitoringDate = new Date(date.setMinutes(date.getMinutes() - 54)); 742 const identifiedDate = new Date(date.setMinutes(date.getMinutes() - 32)); 743 const investigatingDate = new Date(date.setMinutes(date.getMinutes() - 4)); 744 745 const props: z.infer<typeof selectStatusReportPageSchema> = { 746 id: 1, 747 pageId: 1, 748 workspaceId: 1, 749 status: "investigating" as const, 750 title: "API Latency Issues", 751 createdAt: new Date(new Date().setDate(new Date().getDate() - 2)), 752 updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)), 753 statusReportsToPageComponents: [ 754 { 755 pageComponentId: 1, 756 statusReportId: 1, 757 pageComponent: { 758 workspaceId: 1, 759 pageId: 1, 760 id: 1, 761 name: "API Monitor", 762 type: "monitor" as const, 763 monitorId: 1, 764 order: 1, 765 groupId: null, 766 groupOrder: null, 767 description: "Main API endpoint", 768 createdAt: new Date(new Date().setDate(new Date().getDate() - 30)), 769 updatedAt: new Date(new Date().setDate(new Date().getDate() - 30)), 770 }, 771 }, 772 ], 773 statusReportUpdates: [ 774 { 775 id: 4, 776 statusReportId: 1, 777 status: "resolved" as const, 778 message: 779 "All systems are operating normally. The issue has been fully resolved.", 780 date: resolvedDate, 781 createdAt: resolvedDate, 782 updatedAt: resolvedDate, 783 }, 784 { 785 id: 3, 786 statusReportId: 1, 787 status: "monitoring" as const, 788 message: 789 "We are continuing to monitor the situation to ensure that the issue is resolved.", 790 date: monitoringDate, 791 createdAt: monitoringDate, 792 updatedAt: monitoringDate, 793 }, 794 { 795 id: 2, 796 statusReportId: 1, 797 status: "identified" as const, 798 message: "The issue has been identified and a fix is being deployed.", 799 date: identifiedDate, 800 createdAt: identifiedDate, 801 updatedAt: identifiedDate, 802 }, 803 { 804 id: 1, 805 statusReportId: 1, 806 status: "investigating" as const, 807 message: 808 "We are investigating reports of increased latency on our API endpoints.", 809 date: investigatingDate, 810 createdAt: investigatingDate, 811 updatedAt: investigatingDate, 812 }, 813 ], 814 }; 815 816 return selectStatusReportPageSchema.parse(props); 817 }), 818 819 getMonitors: publicProcedure 820 .input(z.object({ slug: z.string().toLowerCase() })) 821 .query(async (opts) => { 822 if (!opts.input.slug) return null; 823 824 // NOTE: revalidate the public monitors first 825 const _page = await opts.ctx.db.query.page.findFirst({ 826 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 827 with: { 828 pageComponents: { 829 with: { 830 monitor: true, 831 }, 832 }, 833 }, 834 }); 835 836 if (!_page) return null; 837 838 const pageComponents = selectPageComponentWithMonitorRelation 839 .array() 840 .parse(_page.pageComponents); 841 842 const publicMonitors = pageComponents 843 .filter(isMonitorComponent) 844 .filter((c) => c.monitor?.public); 845 846 const monitorsByType = { 847 http: publicMonitors.filter((c) => c.monitor.jobType === "http"), 848 tcp: publicMonitors.filter((c) => c.monitor.jobType === "tcp"), 849 dns: publicMonitors.filter((c) => c.monitor.jobType === "dns"), 850 }; 851 852 const proceduresByType = { 853 http: getMetricsLatencyMultiProcedure("1d", "http"), 854 tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), 855 dns: getMetricsLatencyMultiProcedure("1d", "dns"), 856 }; 857 858 const [ 859 metricsLatencyMultiHttp, 860 metricsLatencyMultiTcp, 861 metricsLatencyMultiDns, 862 ] = await Promise.all( 863 Object.entries(proceduresByType).map(([type, procedure]) => { 864 const monitorIds = monitorsByType[ 865 type as keyof typeof proceduresByType 866 ].map((c) => c.monitor.id.toString()); 867 if (monitorIds.length === 0) return null; 868 return procedure({ monitorIds }); 869 }), 870 ); 871 872 const metricsDataByMonitorId = new Map< 873 string, 874 | Awaited<ReturnType<(typeof proceduresByType)["http"]>>["data"] 875 | Awaited<ReturnType<(typeof proceduresByType)["tcp"]>>["data"] 876 | Awaited<ReturnType<(typeof proceduresByType)["dns"]>>["data"] 877 >(); 878 879 if (metricsLatencyMultiHttp?.data) { 880 metricsLatencyMultiHttp.data.forEach((metric) => { 881 const monitorId = metric.monitorId; 882 if (!metricsDataByMonitorId.has(monitorId)) { 883 metricsDataByMonitorId.set(monitorId, []); 884 } 885 metricsDataByMonitorId.get(monitorId)?.push(metric); 886 }); 887 } 888 889 if (metricsLatencyMultiTcp?.data) { 890 metricsLatencyMultiTcp.data.forEach((metric) => { 891 const monitorId = metric.monitorId; 892 if (!metricsDataByMonitorId.has(monitorId)) { 893 metricsDataByMonitorId.set(monitorId, []); 894 } 895 metricsDataByMonitorId.get(monitorId)?.push(metric); 896 }); 897 } 898 899 if (metricsLatencyMultiDns?.data) { 900 metricsLatencyMultiDns.data.forEach((metric) => { 901 const monitorId = metric.monitorId; 902 if (!metricsDataByMonitorId.has(monitorId)) { 903 metricsDataByMonitorId.set(monitorId, []); 904 } 905 metricsDataByMonitorId.get(monitorId)?.push(metric); 906 }); 907 } 908 909 return publicMonitors.map((c) => { 910 const monitorId = c.monitor.id.toString(); 911 const data = metricsDataByMonitorId.get(monitorId) || []; 912 913 return { 914 ...selectPublicMonitorSchema.parse(c.monitor), 915 data, 916 }; 917 }); 918 }), 919 920 getMonitor: publicProcedure 921 .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 922 .query(async (opts) => { 923 if (!opts.input.slug) return null; 924 925 const _page = await opts.ctx.db.query.page.findFirst({ 926 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 927 with: { 928 pageComponents: { 929 where: eq(pageComponent.monitorId, opts.input.id), 930 with: { 931 monitor: true, 932 }, 933 }, 934 }, 935 }); 936 937 if (!_page) return null; 938 939 const pageComponents = selectPageComponentWithMonitorRelation 940 .array() 941 .parse(_page.pageComponents); 942 943 const monitorComponents = pageComponents.filter(isMonitorComponent); 944 945 const _monitor = monitorComponents.find( 946 (c) => c.monitor.id === opts.input.id, 947 )?.monitor; 948 949 if (!_monitor) return null; 950 if (!_monitor.public) return null; 951 if (_monitor.deletedAt) return null; 952 953 const type = _monitor.jobType as "http" | "tcp"; 954 955 const proceduresByType = { 956 http: { 957 latency: getMetricsLatencyProcedure("7d", "http"), 958 regions: getMetricsRegionsProcedure("7d", "http"), 959 uptime: getUptimeProcedure("7d", "http"), 960 }, 961 tcp: { 962 latency: getMetricsLatencyProcedure("7d", "tcp"), 963 regions: getMetricsRegionsProcedure("7d", "tcp"), 964 uptime: getUptimeProcedure("7d", "tcp"), 965 }, 966 dns: { 967 latency: getMetricsLatencyProcedure("7d", "dns"), 968 regions: getMetricsRegionsProcedure("7d", "dns"), 969 uptime: getUptimeProcedure("7d", "dns"), 970 }, 971 }; 972 973 const fromDate = startOfDay(subDays(new Date(), 7)).toISOString(); 974 const toDate = endOfDay(new Date()).toISOString(); 975 976 const [latency, regions, uptime] = await Promise.all([ 977 await proceduresByType[type].latency({ 978 monitorId: _monitor.id.toString(), 979 fromDate, 980 toDate, 981 }), 982 await proceduresByType[type].regions({ 983 monitorId: _monitor.id.toString(), 984 fromDate, 985 toDate, 986 }), 987 await proceduresByType[type].uptime({ 988 monitorId: _monitor.id.toString(), 989 interval: 240, 990 fromDate, 991 toDate, 992 }), 993 ]); 994 995 return { 996 ...selectPublicMonitorSchema.parse(_monitor), 997 data: { 998 latency, 999 regions, 1000 uptime, 1001 }, 1002 }; 1003 }), 1004 1005 subscribe: publicProcedure 1006 .meta({ track: Events.SubscribePage, trackProps: ["slug", "email"] }) 1007 .input(z.object({ slug: z.string().toLowerCase(), email: z.email() })) 1008 .mutation(async (opts) => { 1009 if (!opts.input.slug) return null; 1010 1011 const _page = await opts.ctx.db.query.page.findFirst({ 1012 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 1013 with: { 1014 workspace: true, 1015 }, 1016 }); 1017 1018 if (!_page) { 1019 throw new TRPCError({ 1020 code: "NOT_FOUND", 1021 message: "Page not found", 1022 }); 1023 } 1024 1025 const workspace = selectWorkspaceSchema.safeParse(_page.workspace); 1026 1027 if (!workspace.success) { 1028 throw new TRPCError({ 1029 code: "BAD_REQUEST", 1030 message: "Workspace data is invalid", 1031 }); 1032 } 1033 1034 if (!workspace.data.limits["status-subscribers"]) { 1035 throw new TRPCError({ 1036 code: "FORBIDDEN", 1037 message: "Upgrade to use status subscribers", 1038 }); 1039 } 1040 1041 // Check for existing subscriber (active or unsubscribed) 1042 const _existingSubscriber = 1043 await opts.ctx.db.query.pageSubscriber.findFirst({ 1044 where: and( 1045 eq(pageSubscriber.pageId, _page.id), 1046 eq(pageSubscriber.email, opts.input.email), 1047 ), 1048 }); 1049 1050 // If already subscribed and verified (not unsubscribed), reject 1051 if ( 1052 _existingSubscriber?.acceptedAt && 1053 !_existingSubscriber.unsubscribedAt 1054 ) { 1055 throw new TRPCError({ 1056 code: "BAD_REQUEST", 1057 message: "Email already subscribed", 1058 }); 1059 } 1060 1061 // Handle re-subscription: clear unsubscribedAt, regenerate token, reset acceptedAt 1062 if (_existingSubscriber?.unsubscribedAt) { 1063 const updatedSubscriber = await opts.ctx.db 1064 .update(pageSubscriber) 1065 .set({ 1066 unsubscribedAt: null, 1067 acceptedAt: null, 1068 token: crypto.randomUUID(), 1069 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 1070 }) 1071 .where(eq(pageSubscriber.id, _existingSubscriber.id)) 1072 .returning() 1073 .get(); 1074 1075 return updatedSubscriber.id; 1076 } 1077 1078 // Handle pending re-subscription (not yet verified): regenerate token 1079 if (_existingSubscriber && !_existingSubscriber.acceptedAt) { 1080 const updatedSubscriber = await opts.ctx.db 1081 .update(pageSubscriber) 1082 .set({ 1083 token: crypto.randomUUID(), 1084 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 1085 }) 1086 .where(eq(pageSubscriber.id, _existingSubscriber.id)) 1087 .returning() 1088 .get(); 1089 1090 return updatedSubscriber.id; 1091 } 1092 1093 // New subscription 1094 const _pageSubscriber = await opts.ctx.db 1095 .insert(pageSubscriber) 1096 .values({ 1097 pageId: _page.id, 1098 email: opts.input.email, 1099 token: crypto.randomUUID(), 1100 expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 1101 }) 1102 .returning() 1103 .get(); 1104 1105 return _pageSubscriber.id; 1106 }), 1107 1108 validateEmailDomain: publicProcedure 1109 .meta({ track: Events.ValidateEmailDomain, trackProps: ["slug", "email"] }) 1110 .input(z.object({ slug: z.string().toLowerCase(), email: z.string() })) 1111 .query(async (opts) => { 1112 if (!opts.input.slug) return null; 1113 1114 const _page = await opts.ctx.db.query.page.findFirst({ 1115 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 1116 }); 1117 1118 if (!_page) { 1119 throw new TRPCError({ 1120 code: "NOT_FOUND", 1121 message: "Page not found", 1122 }); 1123 } 1124 1125 if (_page.accessType !== "email-domain") { 1126 throw new TRPCError({ 1127 code: "BAD_REQUEST", 1128 message: 1129 "Page is not configured to allow email domain authentication", 1130 }); 1131 } 1132 1133 const allowedDomains = _page.authEmailDomains?.split(",") ?? []; 1134 1135 if (!allowedDomains.includes(opts.input.email.split("@")[1])) { 1136 throw new TRPCError({ 1137 code: "BAD_REQUEST", 1138 message: "Invalid email domain", 1139 }); 1140 } 1141 1142 return { 1143 email: opts.input.email, 1144 slug: opts.input.slug, 1145 page: _page, 1146 }; 1147 }), 1148 1149 verifyEmail: publicProcedure 1150 .meta({ track: Events.VerifySubscribePage, trackProps: ["slug"] }) 1151 .input(z.object({ slug: z.string().toLowerCase(), token: z.string() })) 1152 .mutation(async (opts) => { 1153 if (!opts.input.slug) return null; 1154 1155 const _page = await opts.ctx.db.query.page.findFirst({ 1156 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 1157 }); 1158 1159 if (!_page) return null; 1160 1161 const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 1162 where: and( 1163 eq(pageSubscriber.token, opts.input.token), 1164 eq(pageSubscriber.pageId, _page.id), 1165 ), 1166 }); 1167 1168 if (_pageSubscriber?.acceptedAt) { 1169 throw new TRPCError({ 1170 code: "BAD_REQUEST", 1171 message: "Email already verified", 1172 }); 1173 } 1174 1175 if (!_pageSubscriber) { 1176 throw new TRPCError({ 1177 code: "NOT_FOUND", 1178 message: "Subscription not found", 1179 }); 1180 } 1181 1182 await opts.ctx.db 1183 .update(pageSubscriber) 1184 .set({ 1185 acceptedAt: new Date(), 1186 }) 1187 .where(eq(pageSubscriber.id, _pageSubscriber.id)) 1188 .execute(); 1189 1190 return _pageSubscriber; 1191 }), 1192 1193 verifyPassword: publicProcedure 1194 .input(z.object({ slug: z.string().toLowerCase(), password: z.string() })) 1195 .mutation(async (opts) => { 1196 if (!opts.input.slug) return null; 1197 1198 const _page = await opts.ctx.db.query.page.findFirst({ 1199 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 1200 }); 1201 1202 if (!_page) { 1203 throw new TRPCError({ 1204 code: "NOT_FOUND", 1205 message: "Page not found", 1206 }); 1207 } 1208 1209 if (_page.accessType !== "password") { 1210 throw new TRPCError({ 1211 code: "BAD_REQUEST", 1212 message: "Page is not configured to allow password authentication", 1213 }); 1214 } 1215 1216 if (_page.password !== opts.input.password) { 1217 throw new TRPCError({ 1218 code: "BAD_REQUEST", 1219 message: "Invalid password", 1220 }); 1221 } 1222 1223 return true; 1224 }), 1225 1226 getSubscriberByToken: publicProcedure 1227 .input( 1228 z.object({ token: z.string().uuid(), domain: z.string().toLowerCase() }), 1229 ) 1230 .query(async (opts) => { 1231 const _page = await opts.ctx.db.query.page.findFirst({ 1232 where: sql`lower(${page.slug}) = ${opts.input.domain} OR lower(${page.customDomain}) = ${opts.input.domain}`, 1233 }); 1234 1235 if (!_page) return null; 1236 1237 const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 1238 where: and( 1239 eq(pageSubscriber.token, opts.input.token), 1240 eq(pageSubscriber.pageId, _page.id), 1241 ), 1242 }); 1243 1244 // Return null if not found or already unsubscribed 1245 if (!_pageSubscriber) { 1246 return null; 1247 } 1248 1249 if (_pageSubscriber.unsubscribedAt) { 1250 return null; 1251 } 1252 1253 // Mask email: show first character, then ***, then @domain 1254 const email = _pageSubscriber.email; 1255 const [localPart, domain] = email.split("@"); 1256 const maskedEmail = 1257 localPart.length > 0 ? `${localPart[0]}***@${domain}` : `***@${domain}`; 1258 1259 return { 1260 pageName: _page.title, 1261 maskedEmail, 1262 }; 1263 }), 1264 1265 unsubscribe: publicProcedure 1266 .input( 1267 z.object({ token: z.string().uuid(), domain: z.string().toLowerCase() }), 1268 ) 1269 .mutation(async (opts) => { 1270 const _page = await opts.ctx.db.query.page.findFirst({ 1271 where: sql`lower(${page.slug}) = ${opts.input.domain} OR lower(${page.customDomain}) = ${opts.input.domain}`, 1272 }); 1273 1274 if (!_page) return null; 1275 1276 const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 1277 where: and( 1278 eq(pageSubscriber.token, opts.input.token), 1279 eq(pageSubscriber.pageId, _page.id), 1280 ), 1281 }); 1282 1283 if (!_pageSubscriber) { 1284 throw new TRPCError({ 1285 code: "NOT_FOUND", 1286 message: "Subscription not found", 1287 }); 1288 } 1289 1290 if (!_pageSubscriber.acceptedAt) { 1291 throw new TRPCError({ 1292 code: "BAD_REQUEST", 1293 message: "Subscription not yet verified", 1294 }); 1295 } 1296 1297 if (_pageSubscriber.unsubscribedAt) { 1298 throw new TRPCError({ 1299 code: "BAD_REQUEST", 1300 message: "Already unsubscribed", 1301 }); 1302 } 1303 1304 await opts.ctx.db 1305 .update(pageSubscriber) 1306 .set({ 1307 unsubscribedAt: new Date(), 1308 }) 1309 .where(eq(pageSubscriber.id, _pageSubscriber.id)) 1310 .execute(); 1311 1312 return { 1313 success: true, 1314 pageName: _page.title, 1315 }; 1316 }), 1317});