Openstatus
www.openstatus.dev
1import { TRPCError } from "@trpc/server";
2import { z } from "zod";
3
4import {
5 type Assertion,
6 DnsRecordAssertion,
7 HeaderAssertion,
8 StatusAssertion,
9 TextBodyAssertion,
10 headerAssertion,
11 jsonBodyAssertion,
12 recordAssertion,
13 serialize,
14 statusAssertion,
15 textBodyAssertion,
16} from "@openstatus/assertions";
17import {
18 type SQL,
19 and,
20 count,
21 eq,
22 inArray,
23 isNull,
24 sql,
25 syncMaintenanceToMonitorDeleteByMonitors,
26 syncMonitorsToPageDelete,
27 syncMonitorsToPageDeleteByMonitors,
28 syncMonitorsToPageInsertMany,
29 syncStatusReportToMonitorDeleteByMonitors,
30} from "@openstatus/db";
31import {
32 insertMonitorSchema,
33 maintenancesToMonitors,
34 monitor,
35 monitorJobTypes,
36 monitorMethods,
37 monitorTag,
38 monitorTagsToMonitors,
39 monitorsToPages,
40 monitorsToStatusReport,
41 notification,
42 notificationsToMonitors,
43 page,
44 privateLocationToMonitors,
45 selectIncidentSchema,
46 selectMaintenanceSchema,
47 selectMonitorSchema,
48 selectMonitorTagSchema,
49 selectNotificationSchema,
50 selectPageSchema,
51 selectPrivateLocationSchema,
52 selectPublicMonitorSchema,
53} from "@openstatus/db/src/schema";
54
55import { Events } from "@openstatus/analytics";
56import {
57 freeFlyRegions,
58 monitorPeriodicity,
59 monitorRegions,
60} from "@openstatus/db/src/schema/constants";
61import { regionDict } from "@openstatus/regions";
62import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
63import { testDns, testHttp, testTcp } from "./checker";
64
65export const monitorRouter = createTRPCRouter({
66 create: protectedProcedure
67 .meta({ track: Events.CreateMonitor, trackProps: ["url", "jobType"] })
68 .input(insertMonitorSchema)
69 .output(selectMonitorSchema)
70 .mutation(async (opts) => {
71 const monitorLimit = opts.ctx.workspace.limits.monitors;
72 const periodicityLimit = opts.ctx.workspace.limits.periodicity;
73 const regionsLimit = opts.ctx.workspace.limits.regions;
74
75 const monitorNumbers = (
76 await opts.ctx.db.query.monitor.findMany({
77 where: and(
78 eq(monitor.workspaceId, opts.ctx.workspace.id),
79 isNull(monitor.deletedAt),
80 ),
81 })
82 ).length;
83
84 // the user has reached the limits
85 if (monitorNumbers >= monitorLimit) {
86 throw new TRPCError({
87 code: "FORBIDDEN",
88 message: "You reached your monitor limits.",
89 });
90 }
91
92 // the user is not allowed to use the cron job
93 if (
94 opts.input.periodicity &&
95 !periodicityLimit.includes(opts.input.periodicity)
96 ) {
97 throw new TRPCError({
98 code: "FORBIDDEN",
99 message: "You reached your cron job limits.",
100 });
101 }
102
103 if (
104 opts.input.regions !== undefined &&
105 opts.input.regions?.length !== 0
106 ) {
107 for (const region of opts.input.regions) {
108 if (!regionsLimit.includes(region)) {
109 throw new TRPCError({
110 code: "FORBIDDEN",
111 message: "You don't have access to this region.",
112 });
113 }
114 }
115 }
116
117 // FIXME: this is a hotfix
118 const {
119 regions,
120 headers,
121 notifications,
122 pages,
123 tags,
124 statusAssertions,
125 headerAssertions,
126 textBodyAssertions,
127 otelHeaders,
128 ...data
129 } = opts.input;
130
131 const assertions: Assertion[] = [];
132 for (const a of statusAssertions ?? []) {
133 assertions.push(new StatusAssertion(a));
134 }
135 for (const a of headerAssertions ?? []) {
136 assertions.push(new HeaderAssertion(a));
137 }
138 for (const a of textBodyAssertions ?? []) {
139 assertions.push(new TextBodyAssertion(a));
140 }
141
142 const newMonitor = await opts.ctx.db
143 .insert(monitor)
144 .values({
145 // REMINDER: We should explicitly pass the corresponding attributes
146 // otherwise, unexpected attributes will be passed
147 ...data,
148 workspaceId: opts.ctx.workspace.id,
149 regions: regions?.join(","),
150 headers: headers ? JSON.stringify(headers) : undefined,
151 otelHeaders: otelHeaders ? JSON.stringify(otelHeaders) : undefined,
152 assertions: assertions.length > 0 ? serialize(assertions) : undefined,
153 })
154 .returning()
155 .get();
156
157 if (notifications.length > 0) {
158 const allNotifications = await opts.ctx.db.query.notification.findMany({
159 where: and(
160 eq(notification.workspaceId, opts.ctx.workspace.id),
161 inArray(notification.id, notifications),
162 ),
163 });
164
165 const values = allNotifications.map((notification) => ({
166 monitorId: newMonitor.id,
167 notificationId: notification.id,
168 }));
169
170 await opts.ctx.db.insert(notificationsToMonitors).values(values).run();
171 }
172
173 if (tags.length > 0) {
174 const allTags = await opts.ctx.db.query.monitorTag.findMany({
175 where: and(
176 eq(monitorTag.workspaceId, opts.ctx.workspace.id),
177 inArray(monitorTag.id, tags),
178 ),
179 });
180
181 const values = allTags.map((monitorTag) => ({
182 monitorId: newMonitor.id,
183 monitorTagId: monitorTag.id,
184 }));
185
186 await opts.ctx.db.insert(monitorTagsToMonitors).values(values).run();
187 }
188
189 if (pages.length > 0) {
190 const allPages = await opts.ctx.db.query.page.findMany({
191 where: and(
192 eq(page.workspaceId, opts.ctx.workspace.id),
193 inArray(page.id, pages),
194 ),
195 });
196
197 const values = allPages.map((page) => ({
198 monitorId: newMonitor.id,
199 pageId: page.id,
200 }));
201
202 await opts.ctx.db.insert(monitorsToPages).values(values).run();
203 // Sync to page components
204 await syncMonitorsToPageInsertMany(opts.ctx.db, values);
205 }
206
207 return selectMonitorSchema.parse(newMonitor);
208 }),
209
210 getMonitorById: protectedProcedure
211 .input(z.object({ id: z.number() }))
212 .query(async (opts) => {
213 const _monitor = await opts.ctx.db.query.monitor.findFirst({
214 where: and(
215 eq(monitor.id, opts.input.id),
216 eq(monitor.workspaceId, opts.ctx.workspace.id),
217 isNull(monitor.deletedAt),
218 ),
219 with: {
220 monitorTagsToMonitors: { with: { monitorTag: true } },
221 maintenancesToMonitors: {
222 with: { maintenance: true },
223 where: eq(maintenancesToMonitors.monitorId, opts.input.id),
224 },
225 monitorsToNotifications: { with: { notification: true } },
226 },
227 });
228
229 const parsedMonitor = selectMonitorSchema
230 .extend({
231 monitorTagsToMonitors: z
232 .object({
233 monitorTag: selectMonitorTagSchema,
234 })
235 .array(),
236 maintenance: z.boolean().prefault(false).optional(),
237 monitorsToNotifications: z
238 .object({
239 notification: selectNotificationSchema,
240 })
241 .array(),
242 })
243 .safeParse({
244 ..._monitor,
245 maintenance: _monitor?.maintenancesToMonitors.some(
246 (item) =>
247 item.maintenance.from.getTime() <= Date.now() &&
248 item.maintenance.to.getTime() >= Date.now(),
249 ),
250 });
251
252 if (!parsedMonitor.success) {
253 throw new TRPCError({
254 code: "UNAUTHORIZED",
255 message: "You are not allowed to access the monitor.",
256 });
257 }
258 return parsedMonitor.data;
259 }),
260
261 getPublicMonitorById: publicProcedure
262 // REMINDER: if on status page, we should check if the monitor is associated with the page
263 // otherwise, using `/public` we don't need to check
264 .input(z.object({ id: z.number(), slug: z.string().optional() }))
265 .query(async (opts) => {
266 const _monitor = await opts.ctx.db
267 .select()
268 .from(monitor)
269 .where(
270 and(
271 eq(monitor.id, opts.input.id),
272 isNull(monitor.deletedAt),
273 eq(monitor.public, true),
274 ),
275 )
276 .get();
277
278 if (!_monitor) return undefined;
279
280 if (opts.input.slug) {
281 const _page = await opts.ctx.db.query.page.findFirst({
282 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
283 with: { monitorsToPages: true },
284 });
285
286 const hasPageRelation = _page?.monitorsToPages.find(
287 ({ monitorId }) => _monitor.id === monitorId,
288 );
289
290 if (!hasPageRelation) return undefined;
291 }
292
293 return selectPublicMonitorSchema.parse(_monitor);
294 }),
295
296 update: protectedProcedure
297 .meta({ track: Events.UpdateMonitor })
298 .input(insertMonitorSchema)
299 .mutation(async (opts) => {
300 if (!opts.input.id) return;
301
302 const periodicityLimit = opts.ctx.workspace.limits.periodicity;
303
304 const regionsLimit = opts.ctx.workspace.limits.regions;
305
306 // the user is not allowed to use the cron job
307 if (
308 opts.input?.periodicity &&
309 !periodicityLimit.includes(opts.input?.periodicity)
310 ) {
311 throw new TRPCError({
312 code: "FORBIDDEN",
313 message: "You reached your cron job limits.",
314 });
315 }
316
317 if (
318 opts.input.regions !== undefined &&
319 opts.input.regions?.length !== 0
320 ) {
321 for (const region of opts.input.regions) {
322 if (!regionsLimit.includes(region)) {
323 throw new TRPCError({
324 code: "FORBIDDEN",
325 message: "You don't have access to this region.",
326 });
327 }
328 }
329 }
330
331 const {
332 regions,
333 headers,
334 notifications,
335 pages,
336 tags,
337 statusAssertions,
338 headerAssertions,
339 textBodyAssertions,
340 otelHeaders,
341
342 ...data
343 } = opts.input;
344
345 const assertions: Assertion[] = [];
346 for (const a of statusAssertions ?? []) {
347 assertions.push(new StatusAssertion(a));
348 }
349 for (const a of headerAssertions ?? []) {
350 assertions.push(new HeaderAssertion(a));
351 }
352 for (const a of textBodyAssertions ?? []) {
353 assertions.push(new TextBodyAssertion(a));
354 }
355
356 const currentMonitor = await opts.ctx.db
357 .update(monitor)
358 .set({
359 ...data,
360 regions: regions?.join(","),
361 updatedAt: new Date(),
362 headers: headers ? JSON.stringify(headers) : undefined,
363 otelHeaders: otelHeaders ? JSON.stringify(otelHeaders) : undefined,
364 assertions: serialize(assertions),
365 })
366 .where(
367 and(
368 eq(monitor.id, opts.input.id),
369 eq(monitor.workspaceId, opts.ctx.workspace.id),
370 isNull(monitor.deletedAt),
371 ),
372 )
373 .returning()
374 .get();
375
376 console.log({
377 currentMonitor,
378 id: opts.input.id,
379 workspaceId: opts.ctx.workspace.id,
380 });
381
382 const currentMonitorNotifications = await opts.ctx.db
383 .select()
384 .from(notificationsToMonitors)
385 .where(eq(notificationsToMonitors.monitorId, currentMonitor.id))
386 .all();
387
388 const addedNotifications = notifications.filter(
389 (x) =>
390 !currentMonitorNotifications
391 .map(({ notificationId }) => notificationId)
392 ?.includes(x),
393 );
394
395 if (addedNotifications.length > 0) {
396 const values = addedNotifications.map((notificationId) => ({
397 monitorId: currentMonitor.id,
398 notificationId,
399 }));
400
401 await opts.ctx.db.insert(notificationsToMonitors).values(values).run();
402 }
403
404 const removedNotifications = currentMonitorNotifications
405 .map(({ notificationId }) => notificationId)
406 .filter((x) => !notifications?.includes(x));
407
408 if (removedNotifications.length > 0) {
409 await opts.ctx.db
410 .delete(notificationsToMonitors)
411 .where(
412 and(
413 eq(notificationsToMonitors.monitorId, currentMonitor.id),
414 inArray(
415 notificationsToMonitors.notificationId,
416 removedNotifications,
417 ),
418 ),
419 )
420 .run();
421 }
422
423 const currentMonitorTags = await opts.ctx.db
424 .select()
425 .from(monitorTagsToMonitors)
426 .where(eq(monitorTagsToMonitors.monitorId, currentMonitor.id))
427 .all();
428
429 const addedTags = tags.filter(
430 (x) =>
431 !currentMonitorTags
432 .map(({ monitorTagId }) => monitorTagId)
433 ?.includes(x),
434 );
435
436 if (addedTags.length > 0) {
437 const values = addedTags.map((monitorTagId) => ({
438 monitorId: currentMonitor.id,
439 monitorTagId,
440 }));
441
442 await opts.ctx.db.insert(monitorTagsToMonitors).values(values).run();
443 }
444
445 const removedTags = currentMonitorTags
446 .map(({ monitorTagId }) => monitorTagId)
447 .filter((x) => !tags?.includes(x));
448
449 if (removedTags.length > 0) {
450 await opts.ctx.db
451 .delete(monitorTagsToMonitors)
452 .where(
453 and(
454 eq(monitorTagsToMonitors.monitorId, currentMonitor.id),
455 inArray(monitorTagsToMonitors.monitorTagId, removedTags),
456 ),
457 )
458 .run();
459 }
460
461 const currentMonitorPages = await opts.ctx.db
462 .select()
463 .from(monitorsToPages)
464 .where(eq(monitorsToPages.monitorId, currentMonitor.id))
465 .all();
466
467 const addedPages = pages.filter(
468 (x) => !currentMonitorPages.map(({ pageId }) => pageId)?.includes(x),
469 );
470
471 if (addedPages.length > 0) {
472 const values = addedPages.map((pageId) => ({
473 monitorId: currentMonitor.id,
474 pageId,
475 }));
476
477 await opts.ctx.db.insert(monitorsToPages).values(values).run();
478 // Sync to page components
479 await syncMonitorsToPageInsertMany(opts.ctx.db, values);
480 }
481
482 const removedPages = currentMonitorPages
483 .map(({ pageId }) => pageId)
484 .filter((x) => !pages?.includes(x));
485
486 if (removedPages.length > 0) {
487 await opts.ctx.db
488 .delete(monitorsToPages)
489 .where(
490 and(
491 eq(monitorsToPages.monitorId, currentMonitor.id),
492 inArray(monitorsToPages.pageId, removedPages),
493 ),
494 )
495 .run();
496 // Sync delete to page components
497 for (const pageId of removedPages) {
498 await syncMonitorsToPageDelete(opts.ctx.db, {
499 monitorId: currentMonitor.id,
500 pageId,
501 });
502 }
503 }
504 }),
505
506 updateMonitors: protectedProcedure
507 .input(
508 insertMonitorSchema
509 .pick({ public: true, active: true })
510 .partial() // batched updates
511 .extend({ ids: z.number().array() }), // array of monitor ids to update
512 )
513 .mutation(async (opts) => {
514 const _monitors = await opts.ctx.db
515 .update(monitor)
516 .set(opts.input)
517 .where(
518 and(
519 inArray(monitor.id, opts.input.ids),
520 eq(monitor.workspaceId, opts.ctx.workspace.id),
521 isNull(monitor.deletedAt),
522 ),
523 );
524 }),
525
526 updateMonitorsTag: protectedProcedure
527 .input(
528 z.object({
529 ids: z.number().array(),
530 tagId: z.number(),
531 action: z.enum(["add", "remove"]),
532 }),
533 )
534 .mutation(async (opts) => {
535 const _monitorTag = await opts.ctx.db.query.monitorTag.findFirst({
536 where: and(
537 eq(monitorTag.workspaceId, opts.ctx.workspace.id),
538 eq(monitorTag.id, opts.input.tagId),
539 ),
540 });
541
542 const _monitors = await opts.ctx.db.query.monitor.findMany({
543 where: and(
544 eq(monitor.workspaceId, opts.ctx.workspace.id),
545 inArray(monitor.id, opts.input.ids),
546 ),
547 });
548
549 if (!_monitorTag || _monitors.length !== opts.input.ids.length) {
550 throw new TRPCError({
551 code: "BAD_REQUEST",
552 message: "Invalid tag",
553 });
554 }
555
556 if (opts.input.action === "add") {
557 await opts.ctx.db
558 .insert(monitorTagsToMonitors)
559 .values(
560 opts.input.ids.map((id) => ({
561 monitorId: id,
562 monitorTagId: opts.input.tagId,
563 })),
564 )
565 .onConflictDoNothing()
566 .run();
567 }
568
569 if (opts.input.action === "remove") {
570 await opts.ctx.db
571 .delete(monitorTagsToMonitors)
572 .where(
573 and(
574 inArray(monitorTagsToMonitors.monitorId, opts.input.ids),
575 eq(monitorTagsToMonitors.monitorTagId, opts.input.tagId),
576 ),
577 )
578 .run();
579 }
580 }),
581
582 delete: protectedProcedure
583 .meta({ track: Events.DeleteMonitor })
584 .input(z.object({ id: z.number() }))
585 .mutation(async (opts) => {
586 const monitorToDelete = await opts.ctx.db
587 .select()
588 .from(monitor)
589 .where(
590 and(
591 eq(monitor.id, opts.input.id),
592 eq(monitor.workspaceId, opts.ctx.workspace.id),
593 ),
594 )
595 .get();
596 if (!monitorToDelete) return;
597
598 await opts.ctx.db
599 .update(monitor)
600 .set({ deletedAt: new Date(), active: false })
601 .where(eq(monitor.id, monitorToDelete.id))
602 .run();
603
604 await opts.ctx.db.transaction(async (tx) => {
605 await tx
606 .delete(monitorsToPages)
607 .where(eq(monitorsToPages.monitorId, monitorToDelete.id));
608 await tx
609 .delete(monitorTagsToMonitors)
610 .where(eq(monitorTagsToMonitors.monitorId, monitorToDelete.id));
611 await tx
612 .delete(monitorsToStatusReport)
613 .where(eq(monitorsToStatusReport.monitorId, monitorToDelete.id));
614 await tx
615 .delete(notificationsToMonitors)
616 .where(eq(notificationsToMonitors.monitorId, monitorToDelete.id));
617 await tx
618 .delete(maintenancesToMonitors)
619 .where(eq(maintenancesToMonitors.monitorId, monitorToDelete.id));
620 // Sync deletes to page components
621 await syncMonitorsToPageDeleteByMonitors(tx, [monitorToDelete.id]);
622 await syncStatusReportToMonitorDeleteByMonitors(tx, [
623 monitorToDelete.id,
624 ]);
625 await syncMaintenanceToMonitorDeleteByMonitors(tx, [
626 monitorToDelete.id,
627 ]);
628 });
629 }),
630
631 deleteMonitors: protectedProcedure
632 .input(z.object({ ids: z.number().array() }))
633 .mutation(async (opts) => {
634 const _monitors = await opts.ctx.db
635 .select()
636 .from(monitor)
637 .where(
638 and(
639 inArray(monitor.id, opts.input.ids),
640 eq(monitor.workspaceId, opts.ctx.workspace.id),
641 ),
642 )
643 .all();
644
645 if (_monitors.length !== opts.input.ids.length) {
646 throw new TRPCError({
647 code: "NOT_FOUND",
648 message: "Monitor not found.",
649 });
650 }
651
652 await opts.ctx.db
653 .update(monitor)
654 .set({ deletedAt: new Date(), active: false })
655 .where(inArray(monitor.id, opts.input.ids))
656 .run();
657
658 await opts.ctx.db.transaction(async (tx) => {
659 await tx
660 .delete(monitorsToPages)
661 .where(inArray(monitorsToPages.monitorId, opts.input.ids));
662 await tx
663 .delete(monitorTagsToMonitors)
664 .where(inArray(monitorTagsToMonitors.monitorId, opts.input.ids));
665 await tx
666 .delete(monitorsToStatusReport)
667 .where(inArray(monitorsToStatusReport.monitorId, opts.input.ids));
668 await tx
669 .delete(notificationsToMonitors)
670 .where(inArray(notificationsToMonitors.monitorId, opts.input.ids));
671 await tx
672 .delete(maintenancesToMonitors)
673 .where(inArray(maintenancesToMonitors.monitorId, opts.input.ids));
674 // Sync deletes to page components
675 await syncMonitorsToPageDeleteByMonitors(tx, opts.input.ids);
676 await syncStatusReportToMonitorDeleteByMonitors(tx, opts.input.ids);
677 await syncMaintenanceToMonitorDeleteByMonitors(tx, opts.input.ids);
678 });
679 }),
680
681 getMonitorsByWorkspace: protectedProcedure.query(async (opts) => {
682 const monitors = await opts.ctx.db.query.monitor.findMany({
683 where: and(
684 eq(monitor.workspaceId, opts.ctx.workspace.id),
685 isNull(monitor.deletedAt),
686 ),
687 with: {
688 monitorTagsToMonitors: { with: { monitorTag: true } },
689 },
690 orderBy: (monitor, { desc }) => [desc(monitor.active)],
691 });
692
693 return z
694 .array(
695 selectMonitorSchema.extend({
696 monitorTagsToMonitors: z
697 .array(z.object({ monitorTag: selectMonitorTagSchema }))
698 .prefault([]),
699 }),
700 )
701 .parse(monitors);
702 }),
703
704 getMonitorsByPageId: protectedProcedure
705 .input(z.object({ id: z.number() }))
706 .query(async (opts) => {
707 const _page = await opts.ctx.db.query.page.findFirst({
708 where: and(
709 eq(page.id, opts.input.id),
710 eq(page.workspaceId, opts.ctx.workspace.id),
711 ),
712 });
713
714 if (!_page) return undefined;
715
716 const monitors = await opts.ctx.db.query.monitor.findMany({
717 where: and(
718 eq(monitor.workspaceId, opts.ctx.workspace.id),
719 isNull(monitor.deletedAt),
720 ),
721 with: {
722 monitorTagsToMonitors: { with: { monitorTag: true } },
723 monitorsToPages: {
724 where: eq(monitorsToPages.pageId, _page.id),
725 },
726 },
727 });
728
729 return z
730 .array(
731 selectMonitorSchema.extend({
732 monitorTagsToMonitors: z
733 .array(z.object({ monitorTag: selectMonitorTagSchema }))
734 .prefault([]),
735 }),
736 )
737 .parse(
738 monitors.filter((monitor) =>
739 monitor.monitorsToPages
740 .map(({ pageId }) => pageId)
741 .includes(_page.id),
742 ),
743 );
744 }),
745
746 toggleMonitorActive: protectedProcedure
747 .input(z.object({ id: z.number() }))
748 .mutation(async (opts) => {
749 const monitorToUpdate = await opts.ctx.db
750 .select()
751 .from(monitor)
752 .where(
753 and(
754 eq(monitor.id, opts.input.id),
755 eq(monitor.workspaceId, opts.ctx.workspace.id),
756 isNull(monitor.deletedAt),
757 ),
758 )
759 .get();
760
761 if (!monitorToUpdate) {
762 throw new TRPCError({
763 code: "NOT_FOUND",
764 message: "Monitor not found.",
765 });
766 }
767
768 await opts.ctx.db
769 .update(monitor)
770 .set({
771 active: !monitorToUpdate.active,
772 })
773 .where(
774 and(
775 eq(monitor.id, opts.input.id),
776 eq(monitor.workspaceId, opts.ctx.workspace.id),
777 ),
778 )
779 .run();
780 }),
781
782 // rename to getActiveMonitorsCount
783 getTotalActiveMonitors: publicProcedure.query(async (opts) => {
784 const monitors = await opts.ctx.db
785 .select({ count: sql<number>`count(*)` })
786 .from(monitor)
787 .where(eq(monitor.active, true))
788 .all();
789 if (monitors.length === 0) return 0;
790 return monitors[0].count;
791 }),
792
793 // TODO: return the notifications inside of the `getMonitorById` like we do for the monitors on a status page
794 getAllNotificationsForMonitor: protectedProcedure
795 .input(z.object({ id: z.number() }))
796 // .output(selectMonitorSchema)
797 .query(async (opts) => {
798 const data = await opts.ctx.db
799 .select()
800 .from(notificationsToMonitors)
801 .innerJoin(
802 notification,
803 and(
804 eq(notificationsToMonitors.notificationId, notification.id),
805 eq(notification.workspaceId, opts.ctx.workspace.id),
806 ),
807 )
808 .where(eq(notificationsToMonitors.monitorId, opts.input.id))
809 .all();
810 return data.map((d) => selectNotificationSchema.parse(d.notification));
811 }),
812
813 isMonitorLimitReached: protectedProcedure.query(async (opts) => {
814 const monitorLimit = opts.ctx.workspace.limits.monitors;
815 const monitorNumbers = (
816 await opts.ctx.db.query.monitor.findMany({
817 where: and(
818 eq(monitor.workspaceId, opts.ctx.workspace.id),
819 isNull(monitor.deletedAt),
820 ),
821 })
822 ).length;
823
824 return monitorNumbers >= monitorLimit;
825 }),
826 getMonitorRelationsById: protectedProcedure
827 .input(z.object({ id: z.number() }))
828 .query(async (opts) => {
829 const _monitor = await opts.ctx.db.query.monitor.findFirst({
830 where: and(
831 eq(monitor.id, opts.input.id),
832 eq(monitor.workspaceId, opts.ctx.workspace.id),
833 isNull(monitor.deletedAt),
834 ),
835 with: {
836 monitorTagsToMonitors: true,
837 monitorsToNotifications: true,
838 monitorsToPages: true,
839 },
840 });
841
842 const parsedMonitorNotification = _monitor?.monitorsToNotifications.map(
843 ({ notificationId }) => notificationId,
844 );
845 const parsedPages = _monitor?.monitorsToPages.map((val) => val.pageId);
846 const parsedTags = _monitor?.monitorTagsToMonitors.map(
847 ({ monitorTagId }) => monitorTagId,
848 );
849
850 return {
851 notificationIds: parsedMonitorNotification,
852 pageIds: parsedPages,
853 monitorTagIds: parsedTags,
854 };
855 }),
856
857 // DASHBOARD
858
859 list: protectedProcedure
860 .input(
861 z
862 .object({
863 order: z.enum(["asc", "desc"]).optional(),
864 })
865 .optional(),
866 )
867 .query(async (opts) => {
868 const whereConditions: SQL[] = [
869 eq(monitor.workspaceId, opts.ctx.workspace.id),
870 isNull(monitor.deletedAt),
871 ];
872
873 const result = await opts.ctx.db.query.monitor.findMany({
874 where: and(...whereConditions),
875 with: {
876 monitorTagsToMonitors: {
877 with: { monitorTag: true },
878 },
879 incidents: {
880 orderBy: (incident, { desc }) => [desc(incident.createdAt)],
881 },
882 },
883 orderBy: (monitor, { asc, desc }) =>
884 opts.input?.order === "asc"
885 ? [asc(monitor.active), asc(monitor.createdAt)]
886 : [desc(monitor.active), desc(monitor.createdAt)],
887 });
888
889 return z
890 .array(
891 selectMonitorSchema.extend({
892 tags: z.array(selectMonitorTagSchema).prefault([]),
893 incidents: z.array(selectIncidentSchema).prefault([]),
894 }),
895 )
896 .parse(
897 result.map((data) => ({
898 ...data,
899 tags: data.monitorTagsToMonitors.map((t) => t.monitorTag),
900 })),
901 );
902 }),
903
904 get: protectedProcedure
905 .input(z.object({ id: z.coerce.number() }))
906 .query(async ({ ctx, input }) => {
907 const whereConditions: SQL[] = [
908 eq(monitor.id, input.id),
909 eq(monitor.workspaceId, ctx.workspace.id),
910 isNull(monitor.deletedAt),
911 ];
912
913 const data = await ctx.db.query.monitor.findFirst({
914 where: and(...whereConditions),
915 with: {
916 monitorsToNotifications: {
917 with: { notification: true },
918 },
919 monitorsToPages: {
920 with: { page: true },
921 },
922 monitorTagsToMonitors: {
923 with: { monitorTag: true },
924 },
925 maintenancesToMonitors: {
926 with: { maintenance: true },
927 },
928 incidents: true,
929 privateLocationToMonitors: {
930 with: { privateLocation: true },
931 },
932 },
933 });
934
935 if (!data) return null;
936
937 return selectMonitorSchema
938 .extend({
939 notifications: z.array(selectNotificationSchema).prefault([]),
940 pages: z.array(selectPageSchema).prefault([]),
941 tags: z.array(selectMonitorTagSchema).prefault([]),
942 maintenances: z.array(selectMaintenanceSchema).prefault([]),
943 incidents: z.array(selectIncidentSchema).prefault([]),
944 privateLocations: z.array(selectPrivateLocationSchema).prefault([]),
945 })
946 .parse({
947 ...data,
948 notifications: data.monitorsToNotifications.map(
949 (m) => m.notification,
950 ),
951 pages: data.monitorsToPages.map((p) => p.page),
952 tags: data.monitorTagsToMonitors.map((t) => t.monitorTag),
953 maintenances: data.maintenancesToMonitors.map((m) => m.maintenance),
954 incidents: data.incidents,
955 privateLocations: data.privateLocationToMonitors.map(
956 (p) => p.privateLocation,
957 ),
958 });
959 }),
960
961 clone: protectedProcedure
962 .meta({ track: Events.CloneMonitor })
963 .input(z.object({ id: z.number() }))
964 .mutation(async ({ ctx, input }) => {
965 const whereConditions: SQL[] = [
966 eq(monitor.id, input.id),
967 eq(monitor.workspaceId, ctx.workspace.id),
968 isNull(monitor.deletedAt),
969 ];
970
971 const _monitors = await ctx.db.query.monitor.findMany({
972 where: and(
973 eq(monitor.workspaceId, ctx.workspace.id),
974 isNull(monitor.deletedAt),
975 ),
976 });
977
978 if (_monitors.length >= ctx.workspace.limits.monitors) {
979 throw new TRPCError({
980 code: "FORBIDDEN",
981 message: "You have reached the maximum number of monitors.",
982 });
983 }
984
985 const data = await ctx.db.query.monitor.findFirst({
986 where: and(...whereConditions),
987 });
988
989 if (!data) {
990 throw new TRPCError({
991 code: "NOT_FOUND",
992 message: "Monitor not found.",
993 });
994 }
995
996 const [newMonitor] = await ctx.db
997 .insert(monitor)
998 .values({
999 ...data,
1000 id: undefined, // let the db generate the id
1001 name: `${data.name} (Copy)`,
1002 createdAt: new Date(),
1003 updatedAt: new Date(),
1004 })
1005 .returning();
1006
1007 if (!newMonitor) {
1008 throw new TRPCError({
1009 code: "INTERNAL_SERVER_ERROR",
1010 message: "Failed to clone monitor.",
1011 });
1012 }
1013
1014 return newMonitor;
1015 }),
1016
1017 updateRetry: protectedProcedure
1018 .meta({ track: Events.UpdateMonitor })
1019 .input(z.object({ id: z.number(), retry: z.number() }))
1020 .mutation(async ({ ctx, input }) => {
1021 const whereConditions: SQL[] = [
1022 eq(monitor.id, input.id),
1023 eq(monitor.workspaceId, ctx.workspace.id),
1024 isNull(monitor.deletedAt),
1025 ];
1026
1027 await ctx.db
1028 .update(monitor)
1029 .set({ retry: input.retry, updatedAt: new Date() })
1030 .where(and(...whereConditions))
1031 .run();
1032 }),
1033
1034 updateFollowRedirects: protectedProcedure
1035 .meta({ track: Events.UpdateMonitor })
1036 .input(z.object({ id: z.number(), followRedirects: z.boolean() }))
1037 .mutation(async ({ ctx, input }) => {
1038 const whereConditions: SQL[] = [
1039 eq(monitor.id, input.id),
1040 eq(monitor.workspaceId, ctx.workspace.id),
1041 isNull(monitor.deletedAt),
1042 ];
1043
1044 await ctx.db
1045 .update(monitor)
1046 .set({
1047 followRedirects: input.followRedirects,
1048 updatedAt: new Date(),
1049 })
1050 .where(and(...whereConditions))
1051 .run();
1052 }),
1053
1054 updateOtel: protectedProcedure
1055 .meta({ track: Events.UpdateMonitor })
1056 .input(
1057 z.object({
1058 id: z.number(),
1059 otelEndpoint: z.string(),
1060 otelHeaders: z
1061 .array(z.object({ key: z.string(), value: z.string() }))
1062 .optional(),
1063 }),
1064 )
1065 .mutation(async ({ ctx, input }) => {
1066 const whereConditions: SQL[] = [
1067 eq(monitor.id, input.id),
1068 eq(monitor.workspaceId, ctx.workspace.id),
1069 isNull(monitor.deletedAt),
1070 ];
1071
1072 await ctx.db
1073 .update(monitor)
1074 .set({
1075 otelEndpoint: input.otelEndpoint,
1076 otelHeaders: input.otelHeaders
1077 ? JSON.stringify(input.otelHeaders)
1078 : undefined,
1079 updatedAt: new Date(),
1080 })
1081 .where(and(...whereConditions))
1082 .run();
1083 }),
1084
1085 updatePublic: protectedProcedure
1086 .meta({ track: Events.UpdateMonitor })
1087 .input(z.object({ id: z.number(), public: z.boolean() }))
1088 .mutation(async ({ ctx, input }) => {
1089 const whereConditions: SQL[] = [
1090 eq(monitor.id, input.id),
1091 eq(monitor.workspaceId, ctx.workspace.id),
1092 isNull(monitor.deletedAt),
1093 ];
1094
1095 await ctx.db
1096 .update(monitor)
1097 .set({ public: input.public, updatedAt: new Date() })
1098 .where(and(...whereConditions))
1099 .run();
1100 }),
1101
1102 updateSchedulingRegions: protectedProcedure
1103 .meta({ track: Events.UpdateMonitor })
1104 .input(
1105 z.object({
1106 id: z.number(),
1107 regions: z.array(z.string()),
1108 periodicity: z.enum(monitorPeriodicity),
1109 privateLocations: z.array(z.number()),
1110 }),
1111 )
1112 .mutation(async ({ ctx, input }) => {
1113 const whereConditions: SQL[] = [
1114 eq(monitor.id, input.id),
1115 eq(monitor.workspaceId, ctx.workspace.id),
1116 isNull(monitor.deletedAt),
1117 ];
1118
1119 const limits = ctx.workspace.limits;
1120
1121 if (!limits.periodicity.includes(input.periodicity)) {
1122 throw new TRPCError({
1123 code: "FORBIDDEN",
1124 message: "Upgrade to check more often.",
1125 });
1126 }
1127
1128 if (limits["max-regions"] < input.regions.length) {
1129 throw new TRPCError({
1130 code: "FORBIDDEN",
1131 message: "You have reached the maximum number of regions.",
1132 });
1133 }
1134
1135 if (
1136 input.regions.length > 0 &&
1137 !input.regions.every((r) =>
1138 limits.regions.includes(r as (typeof limits)["regions"][number]),
1139 )
1140 ) {
1141 throw new TRPCError({
1142 code: "FORBIDDEN",
1143 message: "You don't have access to this region.",
1144 });
1145 }
1146
1147 await ctx.db.transaction(async (tx) => {
1148 await tx
1149 .update(monitor)
1150 .set({
1151 regions: input.regions.join(","),
1152 periodicity: input.periodicity,
1153 updatedAt: new Date(),
1154 })
1155 .where(and(...whereConditions))
1156 .run();
1157
1158 await tx
1159 .delete(privateLocationToMonitors)
1160 .where(eq(privateLocationToMonitors.monitorId, input.id));
1161
1162 if (input.privateLocations && input.privateLocations.length > 0) {
1163 await tx.insert(privateLocationToMonitors).values(
1164 input.privateLocations.map((privateLocationId) => ({
1165 monitorId: input.id,
1166 privateLocationId,
1167 })),
1168 );
1169 }
1170 });
1171 }),
1172
1173 updateResponseTime: protectedProcedure
1174 .meta({ track: Events.UpdateMonitor })
1175 .input(
1176 z.object({
1177 id: z.number(),
1178 timeout: z.number(),
1179 degradedAfter: z.number().nullish(),
1180 }),
1181 )
1182 .mutation(async ({ ctx, input }) => {
1183 const whereConditions: SQL[] = [
1184 eq(monitor.id, input.id),
1185 eq(monitor.workspaceId, ctx.workspace.id),
1186 isNull(monitor.deletedAt),
1187 ];
1188
1189 await ctx.db
1190 .update(monitor)
1191 .set({
1192 timeout: input.timeout,
1193 degradedAfter: input.degradedAfter,
1194 updatedAt: new Date(),
1195 })
1196 .where(and(...whereConditions))
1197 .run();
1198 }),
1199
1200 updateTags: protectedProcedure
1201 .meta({ track: Events.UpdateMonitor })
1202 .input(z.object({ id: z.number(), tags: z.array(z.number()) }))
1203 .mutation(async ({ ctx, input }) => {
1204 const allTags = await ctx.db.query.monitorTag.findMany({
1205 where: and(
1206 eq(monitorTag.workspaceId, ctx.workspace.id),
1207 inArray(monitorTag.id, input.tags),
1208 ),
1209 });
1210
1211 if (allTags.length !== input.tags.length) {
1212 throw new TRPCError({
1213 code: "FORBIDDEN",
1214 message: "You don't have access to this tag.",
1215 });
1216 }
1217
1218 await ctx.db.transaction(async (tx) => {
1219 await tx
1220 .delete(monitorTagsToMonitors)
1221 .where(and(eq(monitorTagsToMonitors.monitorId, input.id)));
1222
1223 if (input.tags.length > 0) {
1224 await tx.insert(monitorTagsToMonitors).values(
1225 input.tags.map((tagId) => ({
1226 monitorId: input.id,
1227 monitorTagId: tagId,
1228 })),
1229 );
1230 }
1231 });
1232 }),
1233
1234 updateStatusPages: protectedProcedure
1235 .meta({ track: Events.UpdateMonitor })
1236 .input(
1237 z.object({
1238 id: z.number(),
1239 statusPages: z.array(z.number()),
1240 description: z.string().optional(),
1241 externalName: z.string().optional(),
1242 }),
1243 )
1244 .mutation(async ({ ctx, input }) => {
1245 const allPages = await ctx.db.query.page.findMany({
1246 where: and(
1247 eq(page.workspaceId, ctx.workspace.id),
1248 inArray(page.id, input.statusPages),
1249 ),
1250 });
1251
1252 if (allPages.length !== input.statusPages.length) {
1253 throw new TRPCError({
1254 code: "FORBIDDEN",
1255 message: "You don't have access to this status page.",
1256 });
1257 }
1258
1259 await ctx.db.transaction(async (tx) => {
1260 // REMINDER: why do we need to do this complex logic instead of just deleting and inserting?
1261 // Because we need to preserve the group information when updating the status pages.
1262
1263 const existingEntries = await tx.query.monitorsToPages.findMany({
1264 where: eq(monitorsToPages.monitorId, input.id),
1265 });
1266
1267 const existingPageIds = new Set(
1268 existingEntries.map((entry) => entry.pageId),
1269 );
1270 const inputPageIds = new Set(input.statusPages);
1271
1272 const pageIdsToDelete = existingEntries
1273 .filter((entry) => !inputPageIds.has(entry.pageId))
1274 .map((entry) => entry.pageId);
1275
1276 const pageIdsToInsert = input.statusPages.filter(
1277 (pageId) => !existingPageIds.has(pageId),
1278 );
1279
1280 if (pageIdsToDelete.length > 0) {
1281 await tx
1282 .delete(monitorsToPages)
1283 .where(
1284 and(
1285 eq(monitorsToPages.monitorId, input.id),
1286 inArray(monitorsToPages.pageId, pageIdsToDelete),
1287 ),
1288 );
1289 // Sync delete to page components
1290 for (const pageId of pageIdsToDelete) {
1291 await syncMonitorsToPageDelete(tx, { monitorId: input.id, pageId });
1292 }
1293 }
1294
1295 if (pageIdsToInsert.length > 0) {
1296 const values = pageIdsToInsert.map((pageId) => ({
1297 monitorId: input.id,
1298 pageId,
1299 }));
1300 await tx.insert(monitorsToPages).values(values);
1301 // Sync to page components
1302 await syncMonitorsToPageInsertMany(tx, values);
1303 }
1304
1305 await tx
1306 .update(monitor)
1307 .set({
1308 description: input.description,
1309 externalName: input.externalName,
1310 updatedAt: new Date(),
1311 })
1312 .where(and(eq(monitor.id, input.id)));
1313 });
1314 }),
1315
1316 updateGeneral: protectedProcedure
1317 .meta({ track: Events.UpdateMonitor })
1318 .input(
1319 z.object({
1320 id: z.number(),
1321 jobType: z.enum(monitorJobTypes),
1322 url: z.string(),
1323 method: z.enum(monitorMethods),
1324 headers: z.array(z.object({ key: z.string(), value: z.string() })),
1325 body: z.string().optional(),
1326 name: z.string(),
1327 assertions: z.array(
1328 z.discriminatedUnion("type", [
1329 statusAssertion,
1330 headerAssertion,
1331 textBodyAssertion,
1332 jsonBodyAssertion,
1333 recordAssertion,
1334 ]),
1335 ),
1336 active: z.boolean().prefault(true),
1337 // skip the test check if assertions are OK
1338 skipCheck: z.boolean().prefault(true),
1339 // save check in db (iff success? -> e.g. onboarding to get a first ping)
1340 saveCheck: z.boolean().prefault(false),
1341 }),
1342 )
1343 .mutation(async ({ ctx, input }) => {
1344 const whereConditions: SQL[] = [
1345 eq(monitor.id, input.id),
1346 eq(monitor.workspaceId, ctx.workspace.id),
1347 isNull(monitor.deletedAt),
1348 ];
1349
1350 const assertions: Assertion[] = [];
1351 for (const a of input.assertions ?? []) {
1352 if (a.type === "status") {
1353 assertions.push(new StatusAssertion(a));
1354 }
1355 if (a.type === "header") {
1356 assertions.push(new HeaderAssertion(a));
1357 }
1358 if (a.type === "textBody") {
1359 assertions.push(new TextBodyAssertion(a));
1360 }
1361 if (a.type === "dnsRecord") {
1362 assertions.push(new DnsRecordAssertion(a));
1363 }
1364 }
1365
1366 // NOTE: we are checking the endpoint before saving
1367 if (!input.skipCheck && input.active) {
1368 if (input.jobType === "http") {
1369 await testHttp({
1370 url: input.url,
1371 method: input.method,
1372 headers: input.headers,
1373 body: input.body,
1374 // Filter out DNS record assertions as they can't be validated via HTTP
1375 assertions: input.assertions.filter((a) => a.type !== "dnsRecord"),
1376 region: "ams",
1377 });
1378 } else if (input.jobType === "tcp") {
1379 await testTcp({
1380 url: input.url,
1381 region: "ams",
1382 });
1383 } else if (input.jobType === "dns") {
1384 await testDns({
1385 url: input.url,
1386 region: "ams",
1387 assertions: input.assertions.filter((a) => a.type === "dnsRecord"),
1388 });
1389 }
1390 }
1391
1392 await ctx.db
1393 .update(monitor)
1394 .set({
1395 name: input.name,
1396 jobType: input.jobType,
1397 url: input.url,
1398 method: input.method,
1399 headers: input.headers ? JSON.stringify(input.headers) : undefined,
1400 body: input.body,
1401 active: input.active,
1402 assertions: serialize(assertions),
1403 updatedAt: new Date(),
1404 })
1405 .where(and(...whereConditions))
1406 .run();
1407 }),
1408
1409 updateNotifiers: protectedProcedure
1410 .meta({ track: Events.UpdateMonitor })
1411 .input(z.object({ id: z.number(), notifiers: z.array(z.number()) }))
1412 .mutation(async ({ ctx, input }) => {
1413 const allNotifiers = await ctx.db.query.notification.findMany({
1414 where: and(
1415 eq(notification.workspaceId, ctx.workspace.id),
1416 inArray(notification.id, input.notifiers),
1417 ),
1418 });
1419
1420 if (allNotifiers.length !== input.notifiers.length) {
1421 throw new TRPCError({
1422 code: "FORBIDDEN",
1423 message: "You don't have access to this notifier.",
1424 });
1425 }
1426
1427 await ctx.db.transaction(async (tx) => {
1428 await tx
1429 .delete(notificationsToMonitors)
1430 .where(and(eq(notificationsToMonitors.monitorId, input.id)));
1431
1432 if (input.notifiers.length > 0) {
1433 await tx.insert(notificationsToMonitors).values(
1434 input.notifiers.map((notifierId) => ({
1435 monitorId: input.id,
1436 notificationId: notifierId,
1437 })),
1438 );
1439 }
1440 });
1441 }),
1442
1443 new: protectedProcedure
1444 .meta({ track: Events.CreateMonitor, trackProps: ["url", "jobType"] })
1445 .input(
1446 z.object({
1447 name: z.string(),
1448 jobType: z.enum(monitorJobTypes),
1449 url: z.string(),
1450 method: z.enum(monitorMethods),
1451 headers: z.array(z.object({ key: z.string(), value: z.string() })),
1452 body: z.string().optional(),
1453 assertions: z.array(
1454 z.discriminatedUnion("type", [
1455 statusAssertion,
1456 headerAssertion,
1457 textBodyAssertion,
1458 jsonBodyAssertion,
1459 recordAssertion,
1460 ]),
1461 ),
1462 active: z.boolean().prefault(false),
1463 saveCheck: z.boolean().prefault(false),
1464 skipCheck: z.boolean().prefault(false),
1465 }),
1466 )
1467 .mutation(async ({ ctx, input }) => {
1468 const limits = ctx.workspace.limits;
1469
1470 const res = await ctx.db
1471 .select({ count: count() })
1472 .from(monitor)
1473 .where(
1474 and(
1475 eq(monitor.workspaceId, ctx.workspace.id),
1476 isNull(monitor.deletedAt),
1477 ),
1478 )
1479 .get();
1480
1481 // the user has reached the limits
1482 if (res && res.count >= limits.monitors) {
1483 throw new TRPCError({
1484 code: "FORBIDDEN",
1485 message: "You reached your monitor limits.",
1486 });
1487 }
1488
1489 const assertions: Assertion[] = [];
1490 for (const a of input.assertions ?? []) {
1491 if (a.type === "status") {
1492 assertions.push(new StatusAssertion(a));
1493 }
1494 if (a.type === "header") {
1495 assertions.push(new HeaderAssertion(a));
1496 }
1497 if (a.type === "textBody") {
1498 assertions.push(new TextBodyAssertion(a));
1499 }
1500 if (a.type === "dnsRecord") {
1501 assertions.push(new DnsRecordAssertion(a));
1502 }
1503 }
1504
1505 // NOTE: we are checking the endpoint before saving
1506 if (!input.skipCheck) {
1507 if (input.jobType === "http") {
1508 await testHttp({
1509 url: input.url,
1510 method: input.method,
1511 headers: input.headers,
1512 body: input.body,
1513 // Filter out DNS record assertions as they can't be validated via HTTP
1514 assertions: input.assertions.filter((a) => a.type !== "dnsRecord"),
1515 region: "ams",
1516 });
1517 } else if (input.jobType === "tcp") {
1518 await testTcp({
1519 url: input.url,
1520 region: "ams",
1521 });
1522 } else if (input.jobType === "dns") {
1523 await testDns({
1524 url: input.url,
1525 region: "ams",
1526 assertions: input.assertions.filter((a) => a.type === "dnsRecord"),
1527 });
1528 }
1529 }
1530
1531 const selectableRegions =
1532 ctx.workspace.plan === "free" ? freeFlyRegions : monitorRegions;
1533 const randomRegions = ctx.workspace.plan === "free" ? 4 : 6;
1534
1535 const regions = [...selectableRegions]
1536 // NOTE: make sure we don't use deprecated regions
1537 .filter((r) => {
1538 const deprecated = regionDict[r].deprecated;
1539 if (!deprecated) return true;
1540 return false;
1541 })
1542 .sort(() => 0.5 - Math.random())
1543 .slice(0, randomRegions);
1544
1545 const newMonitor = await ctx.db
1546 .insert(monitor)
1547 .values({
1548 name: input.name,
1549 jobType: input.jobType,
1550 url: input.url,
1551 method: input.method,
1552 headers: input.headers ? JSON.stringify(input.headers) : undefined,
1553 body: input.body,
1554 active: input.active,
1555 workspaceId: ctx.workspace.id,
1556 periodicity: ctx.workspace.plan === "free" ? "30m" : "1m",
1557 regions: regions.join(","),
1558 assertions: serialize(assertions),
1559 updatedAt: new Date(),
1560 })
1561 .returning()
1562 .get();
1563
1564 return newMonitor;
1565 }),
1566});