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 inArray,
10 isNull,
11 sql,
12 syncPageComponentToMonitorsToPageInsertMany,
13} from "@openstatus/db";
14import {
15 insertPageSchema,
16 monitor,
17 page,
18 pageAccessTypes,
19 pageComponent,
20 selectMaintenanceSchema,
21 selectPageComponentGroupSchema,
22 selectPageComponentSchema,
23 selectPageSchema,
24 subdomainSafeList,
25} from "@openstatus/db/src/schema";
26
27import { Events } from "@openstatus/analytics";
28import { env } from "../env";
29import { createTRPCRouter, protectedProcedure } from "../trpc";
30
31if (process.env.NODE_ENV === "test") {
32 require("../test/preload");
33}
34
35// Helper functions to reuse Vercel API logic
36async function addDomainToVercel(domain: string) {
37 const data = await fetch(
38 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`,
39 {
40 body: JSON.stringify({ name: domain }),
41 headers: {
42 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`,
43 "Content-Type": "application/json",
44 },
45 method: "POST",
46 },
47 );
48 return data.json();
49}
50
51async function removeDomainFromVercel(domain: string) {
52 const data = await fetch(
53 `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`,
54 {
55 headers: {
56 Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`,
57 },
58 method: "DELETE",
59 },
60 );
61 return data.json();
62}
63
64export const pageRouter = createTRPCRouter({
65 create: protectedProcedure
66 .meta({ track: Events.CreatePage, trackProps: ["slug"] })
67 .input(insertPageSchema)
68 .mutation(async (opts) => {
69 const { monitors, workspaceId, id, configuration, ...pageProps } =
70 opts.input;
71
72 const monitorIds = monitors?.map((item) => item.monitorId) || [];
73
74 const pageNumbers = (
75 await opts.ctx.db.query.page.findMany({
76 where: eq(page.workspaceId, opts.ctx.workspace.id),
77 })
78 ).length;
79
80 const limit = opts.ctx.workspace.limits;
81
82 // the user has reached the status page number limits
83 if (pageNumbers >= limit["status-pages"]) {
84 throw new TRPCError({
85 code: "FORBIDDEN",
86 message: "You reached your status-page limits.",
87 });
88 }
89
90 // the user is not eligible for password protection
91 if (
92 limit["password-protection"] === false &&
93 opts.input.passwordProtected === true
94 ) {
95 throw new TRPCError({
96 code: "FORBIDDEN",
97 message:
98 "Password protection is not available for your current plan.",
99 });
100 }
101
102 const newPage = await opts.ctx.db
103 .insert(page)
104 .values({
105 workspaceId: opts.ctx.workspace.id,
106 configuration: JSON.stringify(configuration),
107 ...pageProps,
108 authEmailDomains: pageProps.authEmailDomains?.join(","),
109 })
110 .returning()
111 .get();
112
113 if (monitorIds.length) {
114 // We should make sure the user has access to the monitors AND they are active
115 const allMonitors = await opts.ctx.db.query.monitor.findMany({
116 where: and(
117 inArray(monitor.id, monitorIds),
118 eq(monitor.workspaceId, opts.ctx.workspace.id),
119 eq(monitor.active, true), // Only allow active monitors
120 isNull(monitor.deletedAt),
121 ),
122 });
123
124 if (allMonitors.length !== monitorIds.length) {
125 throw new TRPCError({
126 code: "FORBIDDEN",
127 message:
128 "You don't have access to all the monitors or some monitors are inactive.",
129 });
130 }
131
132 // Build a map for quick lookup
133 const monitorMap = new Map(allMonitors.map((m) => [m.id, m]));
134
135 // Build pageComponent values (primary table)
136 const pageComponentValues = monitors
137 .map(({ monitorId }, index) => {
138 const m = monitorMap.get(monitorId);
139 if (!m || !m.workspaceId) return null;
140 return {
141 workspaceId: m.workspaceId,
142 pageId: newPage.id,
143 type: "monitor" as const,
144 monitorId,
145 name: m.externalName || m.name,
146 order: index,
147 groupId: null,
148 groupOrder: 0,
149 };
150 })
151 .filter((v): v is NonNullable<typeof v> => v !== null);
152
153 // Insert into pageComponents (primary table)
154 await opts.ctx.db
155 .insert(pageComponent)
156 .values(pageComponentValues)
157 .run();
158
159 // Build values for reverse sync to monitorsToPages
160 const monitorsToPageValues = monitors.map(({ monitorId }, index) => ({
161 pageId: newPage.id,
162 order: index,
163 monitorId,
164 }));
165
166 // Reverse sync to monitorsToPages (for backwards compatibility)
167 await syncPageComponentToMonitorsToPageInsertMany(
168 opts.ctx.db,
169 monitorsToPageValues,
170 );
171 }
172
173 return newPage;
174 }),
175
176 delete: protectedProcedure
177 .meta({ track: Events.DeletePage })
178 .input(z.object({ id: z.number() }))
179 .mutation(async (opts) => {
180 const whereConditions: SQL[] = [
181 eq(page.id, opts.input.id),
182 eq(page.workspaceId, opts.ctx.workspace.id),
183 ];
184
185 await opts.ctx.db
186 .delete(page)
187 .where(and(...whereConditions))
188 .run();
189 }),
190
191 getSlugUniqueness: protectedProcedure
192 .input(z.object({ slug: z.string().toLowerCase() }))
193 .query(async (opts) => {
194 // had filter on some words we want to keep for us
195 if (subdomainSafeList.includes(opts.input.slug)) {
196 return false;
197 }
198 const result = await opts.ctx.db.query.page.findMany({
199 where: sql`lower(${page.slug}) = ${opts.input.slug}`,
200 });
201 return !(result?.length > 0);
202 }),
203
204 addCustomDomain: protectedProcedure
205 .input(
206 z.object({ customDomain: z.string().toLowerCase(), pageId: z.number() }),
207 )
208 .mutation(async (opts) => {
209 if (opts.input.customDomain.toLowerCase().includes("openstatus")) {
210 throw new TRPCError({
211 code: "BAD_REQUEST",
212 message: "Domain cannot contain 'openstatus'",
213 });
214 }
215
216 // TODO Add some check ?
217 await opts.ctx.db
218 .update(page)
219 .set({ customDomain: opts.input.customDomain, updatedAt: new Date() })
220 .where(eq(page.id, opts.input.pageId))
221 .returning()
222 .get();
223 }),
224
225 list: protectedProcedure
226 .input(
227 z
228 .object({
229 order: z.enum(["asc", "desc"]).optional(),
230 })
231 .optional(),
232 )
233 .query(async (opts) => {
234 const whereConditions: SQL[] = [
235 eq(page.workspaceId, opts.ctx.workspace.id),
236 ];
237
238 const result = await opts.ctx.db.query.page.findMany({
239 where: and(...whereConditions),
240 with: {
241 statusReports: true,
242 },
243 orderBy: (pages, { asc }) => [
244 opts.input?.order === "asc"
245 ? asc(pages.createdAt)
246 : desc(pages.createdAt),
247 ],
248 });
249
250 return result;
251 }),
252
253 get: protectedProcedure
254 .input(z.object({ id: z.number() }))
255 .query(async (opts) => {
256 const whereConditions: SQL[] = [
257 eq(page.workspaceId, opts.ctx.workspace.id),
258 eq(page.id, opts.input.id),
259 ];
260
261 const data = await opts.ctx.db.query.page.findFirst({
262 where: and(...whereConditions),
263 with: {
264 maintenances: true,
265 pageComponents: true,
266 pageComponentGroups: true,
267 },
268 });
269
270 return selectPageSchema
271 .extend({
272 pageComponentGroups: z
273 .array(selectPageComponentGroupSchema)
274 .prefault([]),
275 maintenances: z.array(selectMaintenanceSchema).prefault([]),
276 pageComponents: z.array(selectPageComponentSchema).prefault([]),
277 })
278 .parse({
279 ...data,
280 pageComponentGroups: data?.pageComponentGroups ?? [],
281 maintenances: data?.maintenances,
282 pageComponents: data?.pageComponents,
283 });
284 }),
285
286 // TODO: rename to create
287 new: protectedProcedure
288 .meta({ track: Events.CreatePage, trackProps: ["slug"] })
289 .input(
290 z.object({
291 title: z.string(),
292 slug: z.string().toLowerCase(),
293 icon: z.string().nullish(),
294 description: z.string().nullish(),
295 }),
296 )
297 .mutation(async (opts) => {
298 const pageNumbers = (
299 await opts.ctx.db.query.page.findMany({
300 where: eq(page.workspaceId, opts.ctx.workspace.id),
301 })
302 ).length;
303
304 const limit = opts.ctx.workspace.limits;
305
306 // the user has reached the status page number limits
307 if (pageNumbers >= limit["status-pages"]) {
308 throw new TRPCError({
309 code: "FORBIDDEN",
310 message: "You reached your status-page limits.",
311 });
312 }
313
314 const result = await opts.ctx.db.query.page.findMany({
315 where: sql`lower(${page.slug}) = ${opts.input.slug}`,
316 });
317
318 if (subdomainSafeList.includes(opts.input.slug) || result?.length > 0) {
319 throw new TRPCError({
320 code: "BAD_REQUEST",
321 message: "This slug is already taken. Please choose another one.",
322 });
323 }
324
325 // REMINDER: default config from legacy page
326 const defaultConfiguration = {
327 type: "absolute",
328 value: "requests",
329 uptime: true,
330 theme: "default-rounded",
331 } satisfies Record<string, string | boolean | undefined>;
332
333 const newPage = await opts.ctx.db
334 .insert(page)
335 .values({
336 workspaceId: opts.ctx.workspace.id,
337 title: opts.input.title,
338 slug: opts.input.slug,
339 description: opts.input.description ?? "",
340 icon: opts.input.icon ?? "",
341 legacyPage: false,
342 configuration: defaultConfiguration,
343 customDomain: "", // TODO: make nullable
344 })
345 .returning()
346 .get();
347
348 return newPage;
349 }),
350
351 updateGeneral: protectedProcedure
352 .meta({ track: Events.UpdatePage })
353 .input(
354 z.object({
355 id: z.number(),
356 title: z.string(),
357 slug: z.string().toLowerCase(),
358 description: z.string().nullish(),
359 icon: z.string().nullish(),
360 }),
361 )
362 .mutation(async (opts) => {
363 const whereConditions: SQL[] = [
364 eq(page.workspaceId, opts.ctx.workspace.id),
365 eq(page.id, opts.input.id),
366 ];
367
368 const result = await opts.ctx.db.query.page.findMany({
369 where: sql`lower(${page.slug}) = ${opts.input.slug}`,
370 });
371
372 const oldSlug = await opts.ctx.db.query.page.findFirst({
373 where: and(...whereConditions),
374 });
375
376 if (
377 subdomainSafeList.includes(opts.input.slug) ||
378 (oldSlug?.slug !== opts.input.slug && result?.length > 0)
379 ) {
380 throw new TRPCError({
381 code: "BAD_REQUEST",
382 message: "This slug is already taken. Please choose another one.",
383 });
384 }
385
386 await opts.ctx.db
387 .update(page)
388 .set({
389 title: opts.input.title,
390 slug: opts.input.slug,
391 description: opts.input.description ?? "",
392 icon: opts.input.icon ?? "",
393 updatedAt: new Date(),
394 })
395 .where(and(...whereConditions))
396 .run();
397 }),
398
399 updateCustomDomain: protectedProcedure
400 .meta({ track: Events.UpdatePageDomain, trackProps: ["customDomain"] })
401 .input(z.object({ id: z.number(), customDomain: z.string().toLowerCase() }))
402 .mutation(async (opts) => {
403 const whereConditions: SQL[] = [
404 eq(page.workspaceId, opts.ctx.workspace.id),
405 eq(page.id, opts.input.id),
406 ];
407
408 if (opts.input.customDomain.includes("openstatus")) {
409 throw new TRPCError({
410 code: "BAD_REQUEST",
411 message: "Domain cannot contain 'openstatus'",
412 });
413 }
414
415 // Get the current page to check the existing custom domain
416 const currentPage = await opts.ctx.db.query.page.findFirst({
417 where: and(...whereConditions),
418 });
419
420 if (!currentPage) {
421 throw new TRPCError({
422 code: "NOT_FOUND",
423 message: "Page not found",
424 });
425 }
426
427 const oldDomain = currentPage.customDomain;
428 const newDomain = opts.input.customDomain;
429
430 try {
431 // Handle domain changes
432 if (newDomain && !oldDomain) {
433 // Adding a new domain
434 await opts.ctx.db
435 .update(page)
436 .set({ customDomain: newDomain, updatedAt: new Date() })
437 .where(and(...whereConditions))
438 .run();
439
440 // Add domain to Vercel using the domain router logic
441 await addDomainToVercel(newDomain);
442 } else if (oldDomain && newDomain !== oldDomain) {
443 // Changing domain - remove old and add new
444 await opts.ctx.db
445 .update(page)
446 .set({ customDomain: newDomain, updatedAt: new Date() })
447 .where(and(...whereConditions))
448 .run();
449
450 // Remove old domain from Vercel
451 await removeDomainFromVercel(oldDomain);
452
453 // Add new domain to Vercel
454 if (newDomain) {
455 await addDomainToVercel(newDomain);
456 }
457 } else if (oldDomain && newDomain === "") {
458 // Removing domain
459 await opts.ctx.db
460 .update(page)
461 .set({ customDomain: "", updatedAt: new Date() })
462 .where(and(...whereConditions))
463 .run();
464
465 // Remove domain from Vercel
466 await removeDomainFromVercel(oldDomain);
467 } else {
468 // No change needed, just update the database
469 await opts.ctx.db
470 .update(page)
471 .set({ customDomain: newDomain, updatedAt: new Date() })
472 .where(and(...whereConditions))
473 .run();
474 }
475 } catch (error) {
476 // If Vercel operations fail, we should rollback the database change
477 // For now, we'll just throw the error
478 console.error("Error updating custom domain:", error);
479 throw new TRPCError({
480 code: "INTERNAL_SERVER_ERROR",
481 message: "Failed to update custom domain",
482 });
483 }
484 }),
485
486 updatePasswordProtection: protectedProcedure
487 .meta({ track: Events.UpdatePage })
488 .input(
489 z.object({
490 id: z.number(),
491 accessType: z.enum(pageAccessTypes),
492 authEmailDomains: z.array(z.string()).nullish(),
493 password: z.string().nullish(),
494 }),
495 )
496 .mutation(async (opts) => {
497 const whereConditions: SQL[] = [
498 eq(page.workspaceId, opts.ctx.workspace.id),
499 eq(page.id, opts.input.id),
500 ];
501
502 const limit = opts.ctx.workspace.limits;
503
504 // the user is not eligible for password protection
505 if (
506 limit["password-protection"] === false &&
507 opts.input.accessType === "password"
508 ) {
509 throw new TRPCError({
510 code: "FORBIDDEN",
511 message:
512 "Password protection is not available for your current plan.",
513 });
514 }
515
516 if (
517 limit["email-domain-protection"] === false &&
518 opts.input.accessType === "email-domain"
519 ) {
520 throw new TRPCError({
521 code: "FORBIDDEN",
522 message:
523 "Email domain protection is not available for your current plan.",
524 });
525 }
526
527 await opts.ctx.db
528 .update(page)
529 .set({
530 accessType: opts.input.accessType,
531 authEmailDomains: opts.input.authEmailDomains?.join(","),
532 password: opts.input.password,
533 updatedAt: new Date(),
534 })
535 .where(and(...whereConditions))
536 .run();
537 }),
538
539 updateAppearance: protectedProcedure
540 .meta({ track: Events.UpdatePage })
541 .input(
542 z.object({
543 id: z.number(),
544 forceTheme: z.enum(["light", "dark", "system"]),
545 configuration: z.object({
546 theme: z.string(),
547 }),
548 }),
549 )
550 .mutation(async (opts) => {
551 const whereConditions: SQL[] = [
552 eq(page.workspaceId, opts.ctx.workspace.id),
553 eq(page.id, opts.input.id),
554 ];
555
556 const _page = await opts.ctx.db.query.page.findFirst({
557 where: and(...whereConditions),
558 });
559
560 if (!_page) {
561 throw new TRPCError({
562 code: "NOT_FOUND",
563 message: "Page not found",
564 });
565 }
566
567 const currentConfiguration =
568 (typeof _page.configuration === "object" &&
569 _page.configuration !== null &&
570 _page.configuration) ||
571 {};
572 const updatedConfiguration = {
573 ...currentConfiguration,
574 theme: opts.input.configuration.theme,
575 };
576
577 await opts.ctx.db
578 .update(page)
579 .set({
580 forceTheme: opts.input.forceTheme,
581 configuration: updatedConfiguration,
582 updatedAt: new Date(),
583 })
584 .where(and(...whereConditions))
585 .run();
586 }),
587
588 updateLinks: protectedProcedure
589 .meta({ track: Events.UpdatePage })
590 .input(
591 z.object({
592 id: z.number(),
593 homepageUrl: z.string().nullish(),
594 contactUrl: z.string().nullish(),
595 }),
596 )
597 .mutation(async (opts) => {
598 const whereConditions: SQL[] = [
599 eq(page.workspaceId, opts.ctx.workspace.id),
600 eq(page.id, opts.input.id),
601 ];
602
603 await opts.ctx.db
604 .update(page)
605 .set({
606 homepageUrl: opts.input.homepageUrl,
607 contactUrl: opts.input.contactUrl,
608 updatedAt: new Date(),
609 })
610 .where(and(...whereConditions))
611 .run();
612 }),
613
614 updatePageConfiguration: protectedProcedure
615 .meta({ track: Events.UpdatePage })
616 .input(
617 z.object({
618 id: z.number(),
619 configuration: z
620 .record(z.string(), z.string().or(z.boolean()).optional())
621 .nullish(),
622 }),
623 )
624 .mutation(async (opts) => {
625 const whereConditions: SQL[] = [
626 eq(page.workspaceId, opts.ctx.workspace.id),
627 eq(page.id, opts.input.id),
628 ];
629
630 const _page = await opts.ctx.db.query.page.findFirst({
631 where: and(...whereConditions),
632 });
633
634 if (!_page) {
635 throw new TRPCError({
636 code: "NOT_FOUND",
637 message: "Page not found",
638 });
639 }
640
641 const currentConfiguration =
642 (typeof _page.configuration === "object" &&
643 _page.configuration !== null &&
644 _page.configuration) ||
645 {};
646 const updatedConfiguration = {
647 ...currentConfiguration,
648 ...opts.input.configuration,
649 };
650
651 await opts.ctx.db
652 .update(page)
653 .set({
654 configuration: updatedConfiguration,
655 updatedAt: new Date(),
656 })
657 .where(and(...whereConditions))
658 .run();
659 }),
660});