Openstatus
www.openstatus.dev
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});