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