Openstatus
www.openstatus.dev
1import { z } from "zod";
2
3import {
4 type SQL,
5 and,
6 asc,
7 desc,
8 eq,
9 gte,
10 inArray,
11 sql,
12 syncStatusReportToMonitorDeleteByStatusReport,
13 syncStatusReportToMonitorInsertMany,
14} from "@openstatus/db";
15import {
16 insertStatusReportSchema,
17 insertStatusReportUpdateSchema,
18 monitorsToStatusReport,
19 page,
20 selectMonitorSchema,
21 selectPageSchema,
22 selectPublicStatusReportSchemaWithRelation,
23 selectStatusReportSchema,
24 selectStatusReportUpdateSchema,
25 statusReport,
26 statusReportStatus,
27 statusReportStatusSchema,
28 statusReportUpdate,
29} from "@openstatus/db/src/schema";
30
31import { Events } from "@openstatus/analytics";
32import { TRPCError } from "@trpc/server";
33import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
34
35export const statusReportRouter = createTRPCRouter({
36 createStatusReport: protectedProcedure
37 .meta({ track: Events.CreateReport })
38 .input(insertStatusReportSchema)
39 .mutation(async (opts) => {
40 const { id, monitors, date, message, ...statusReportInput } = opts.input;
41
42 const newStatusReport = await opts.ctx.db
43 .insert(statusReport)
44 .values({
45 workspaceId: opts.ctx.workspace.id,
46 ...statusReportInput,
47 })
48 .returning()
49 .get();
50
51 if (monitors.length > 0) {
52 await opts.ctx.db
53 .insert(monitorsToStatusReport)
54 .values(
55 monitors.map((monitor) => ({
56 monitorId: monitor,
57 statusReportId: newStatusReport.id,
58 })),
59 )
60 .returning()
61 .get();
62 // Sync to page components
63 await syncStatusReportToMonitorInsertMany(
64 opts.ctx.db,
65 newStatusReport.id,
66 monitors,
67 );
68 }
69
70 return newStatusReport;
71 }),
72
73 createStatusReportUpdate: protectedProcedure
74 .meta({ track: Events.CreateReportUpdate })
75 .input(
76 insertStatusReportUpdateSchema.extend({
77 notifySubscribers: z.boolean().nullish(),
78 }),
79 )
80 .mutation(async (opts) => {
81 // update parent status report with latest status
82 const _statusReport = await opts.ctx.db
83 .update(statusReport)
84 .set({ status: opts.input.status, updatedAt: new Date() })
85 .where(
86 and(
87 eq(statusReport.id, opts.input.statusReportId),
88 eq(statusReport.workspaceId, opts.ctx.workspace.id),
89 ),
90 )
91 .returning()
92 .get();
93
94 if (!_statusReport) return;
95
96 const { id, ...statusReportUpdateInput } = opts.input;
97
98 const updatedValue = await opts.ctx.db
99 .insert(statusReportUpdate)
100 .values(statusReportUpdateInput)
101 .returning()
102 .get();
103
104 return {
105 ...selectStatusReportUpdateSchema.parse(updatedValue),
106 notifySubscribers: opts.input.notifySubscribers,
107 };
108 }),
109
110 updateStatusReport: protectedProcedure
111 .meta({ track: Events.UpdateReport })
112 .input(insertStatusReportSchema)
113 .mutation(async (opts) => {
114 const { monitors, ...statusReportInput } = opts.input;
115
116 if (!statusReportInput.id) return;
117
118 const { title, status } = statusReportInput;
119
120 const currentStatusReport = await opts.ctx.db
121 .update(statusReport)
122 .set({ title, status, updatedAt: new Date() })
123 .where(
124 and(
125 eq(statusReport.id, statusReportInput.id),
126 eq(statusReport.workspaceId, opts.ctx.workspace.id),
127 ),
128 )
129 .returning()
130 .get();
131
132 const currentMonitorsToStatusReport = await opts.ctx.db
133 .select()
134 .from(monitorsToStatusReport)
135 .where(
136 eq(monitorsToStatusReport.statusReportId, currentStatusReport.id),
137 )
138 .all();
139
140 const addedMonitors = monitors.filter(
141 (x) =>
142 !currentMonitorsToStatusReport
143 .map(({ monitorId }) => monitorId)
144 .includes(x),
145 );
146
147 if (addedMonitors.length) {
148 const values = addedMonitors.map((monitorId) => ({
149 monitorId: monitorId,
150 statusReportId: currentStatusReport.id,
151 }));
152
153 await opts.ctx.db.insert(monitorsToStatusReport).values(values).run();
154 // Sync to page components
155 await syncStatusReportToMonitorInsertMany(
156 opts.ctx.db,
157 currentStatusReport.id,
158 addedMonitors,
159 );
160 }
161
162 const removedMonitors = currentMonitorsToStatusReport
163 .map(({ monitorId }) => monitorId)
164 .filter((x) => !monitors?.includes(x));
165
166 if (removedMonitors.length) {
167 await opts.ctx.db
168 .delete(monitorsToStatusReport)
169 .where(
170 and(
171 eq(monitorsToStatusReport.statusReportId, currentStatusReport.id),
172 inArray(monitorsToStatusReport.monitorId, removedMonitors),
173 ),
174 )
175 .run();
176 // Sync delete is handled by cascade on page_component deletion
177 }
178
179 return currentStatusReport;
180 }),
181
182 updateStatusReportUpdate: protectedProcedure
183 .meta({ track: Events.UpdateReportUpdate })
184 .input(insertStatusReportUpdateSchema)
185 .mutation(async (opts) => {
186 const statusReportUpdateInput = opts.input;
187
188 if (!statusReportUpdateInput.id) return;
189
190 const currentStatusReportUpdate = await opts.ctx.db
191 .update(statusReportUpdate)
192 .set({ ...statusReportUpdateInput, updatedAt: new Date() })
193 .where(eq(statusReportUpdate.id, statusReportUpdateInput.id))
194 .returning()
195 .get();
196
197 return selectStatusReportUpdateSchema.parse(currentStatusReportUpdate);
198 }),
199
200 deleteStatusReport: protectedProcedure
201 .meta({ track: Events.DeleteReport })
202 .input(z.object({ id: z.number() }))
203 .mutation(async (opts) => {
204 const statusReportToDelete = await opts.ctx.db
205 .select()
206 .from(statusReport)
207 .where(
208 and(
209 eq(statusReport.id, opts.input.id),
210 eq(statusReport.workspaceId, opts.ctx.workspace.id),
211 ),
212 )
213 .get();
214 if (!statusReportToDelete) return;
215
216 await opts.ctx.db
217 .delete(statusReport)
218 .where(eq(statusReport.id, statusReportToDelete.id))
219 .run();
220 }),
221
222 deleteStatusReportUpdate: protectedProcedure
223 .meta({ track: Events.DeleteReportUpdate })
224 .input(z.object({ id: z.number() }))
225 .mutation(async (opts) => {
226 const statusReportUpdateToDelete = await opts.ctx.db
227 .select()
228 .from(statusReportUpdate)
229 .where(and(eq(statusReportUpdate.id, opts.input.id)))
230 .get();
231
232 if (!statusReportUpdateToDelete) return;
233
234 await opts.ctx.db
235 .delete(statusReportUpdate)
236 .where(eq(statusReportUpdate.id, opts.input.id))
237 .run();
238 }),
239
240 getStatusReportById: protectedProcedure
241 .input(z.object({ id: z.number(), pageId: z.number().optional() }))
242 .query(async (opts) => {
243 const selectPublicStatusReportSchemaWithRelation =
244 selectStatusReportSchema.extend({
245 status: statusReportStatusSchema.prefault("investigating"), // TODO: remove!
246 monitorsToStatusReports: z
247 .array(
248 z.object({
249 statusReportId: z.number(),
250 monitorId: z.number(),
251 monitor: selectMonitorSchema,
252 }),
253 )
254 .prefault([]),
255 statusReportUpdates: z.array(selectStatusReportUpdateSchema),
256 date: z.date().prefault(new Date()),
257 });
258
259 const data = await opts.ctx.db.query.statusReport.findFirst({
260 where: and(
261 eq(statusReport.id, opts.input.id),
262 eq(statusReport.workspaceId, opts.ctx.workspace.id),
263 // only allow to fetch status report if it belongs to the page
264 opts.input.pageId
265 ? eq(statusReport.pageId, opts.input.pageId)
266 : undefined,
267 ),
268 with: {
269 monitorsToStatusReports: { with: { monitor: true } },
270 statusReportUpdates: {
271 orderBy: (statusReportUpdate, { desc }) => [
272 desc(statusReportUpdate.createdAt),
273 ],
274 },
275 },
276 });
277
278 return selectPublicStatusReportSchemaWithRelation.parse(data);
279 }),
280
281 getStatusReportUpdateById: protectedProcedure
282 .input(z.object({ id: z.number() }))
283 .query(async (opts) => {
284 const data = await opts.ctx.db.query.statusReportUpdate.findFirst({
285 where: and(eq(statusReportUpdate.id, opts.input.id)),
286 });
287 return selectStatusReportUpdateSchema.parse(data);
288 }),
289
290 getStatusReportByWorkspace: protectedProcedure.query(async (opts) => {
291 // FIXME: can we get rid of that?
292 const selectStatusSchemaWithRelation = selectStatusReportSchema.extend({
293 status: statusReportStatusSchema.prefault("investigating"), // TODO: remove!
294 monitorsToStatusReports: z
295 .array(
296 z.object({
297 statusReportId: z.number(),
298 monitorId: z.number(),
299 monitor: selectMonitorSchema,
300 }),
301 )
302 .prefault([]),
303 statusReportUpdates: z.array(selectStatusReportUpdateSchema),
304 });
305
306 const result = await opts.ctx.db.query.statusReport.findMany({
307 where: eq(statusReport.workspaceId, opts.ctx.workspace.id),
308 with: {
309 monitorsToStatusReports: { with: { monitor: true } },
310 statusReportUpdates: {
311 orderBy: (statusReportUpdate, { desc }) => [
312 desc(statusReportUpdate.createdAt),
313 ],
314 },
315 },
316 orderBy: (statusReport, { desc }) => [desc(statusReport.updatedAt)],
317 });
318 return z.array(selectStatusSchemaWithRelation).parse(result);
319 }),
320
321 getStatusReportByPageId: protectedProcedure
322 .input(z.object({ id: z.number() }))
323 .query(async (opts) => {
324 // FIXME: can we get rid of that?
325 const selectStatusSchemaWithRelation = selectStatusReportSchema.extend({
326 status: statusReportStatusSchema.prefault("investigating"), // TODO: remove!
327 monitorsToStatusReports: z
328 .array(
329 z.object({
330 statusReportId: z.number(),
331 monitorId: z.number(),
332 monitor: selectMonitorSchema,
333 }),
334 )
335 .prefault([]),
336 statusReportUpdates: z.array(selectStatusReportUpdateSchema),
337 });
338
339 const result = await opts.ctx.db.query.statusReport.findMany({
340 where: and(
341 eq(statusReport.workspaceId, opts.ctx.workspace.id),
342 eq(statusReport.pageId, opts.input.id),
343 ),
344 with: {
345 monitorsToStatusReports: { with: { monitor: true } },
346 statusReportUpdates: {
347 orderBy: (statusReportUpdate, { desc }) => [
348 desc(statusReportUpdate.createdAt),
349 ],
350 },
351 },
352 orderBy: (statusReport, { desc }) => [desc(statusReport.updatedAt)],
353 });
354 return z.array(selectStatusSchemaWithRelation).parse(result);
355 }),
356
357 getPublicStatusReportById: publicProcedure
358 .input(z.object({ slug: z.string().toLowerCase(), id: z.number() }))
359 .query(async (opts) => {
360 const result = await opts.ctx.db.query.page.findFirst({
361 where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`,
362 });
363
364 if (!result) return;
365
366 const _statusReport = await opts.ctx.db.query.statusReport.findFirst({
367 where: and(
368 eq(statusReport.id, opts.input.id),
369 eq(statusReport.pageId, result.id),
370 eq(statusReport.workspaceId, result.workspaceId),
371 ),
372 with: {
373 monitorsToStatusReports: { with: { monitor: true } },
374 statusReportUpdates: {
375 orderBy: (reports, { desc }) => desc(reports.date),
376 },
377 },
378 });
379
380 if (!_statusReport) return;
381
382 return selectPublicStatusReportSchemaWithRelation.parse(_statusReport);
383 }),
384
385 // DASHBOARD
386
387 list: protectedProcedure
388 .input(
389 z.object({
390 createdAt: z
391 .object({
392 gte: z.date().optional(),
393 })
394 .optional(),
395 order: z.enum(["asc", "desc"]).optional(),
396 pageId: z.number().optional(),
397 }),
398 )
399 .query(async (opts) => {
400 const whereConditions: SQL[] = [
401 eq(statusReport.workspaceId, opts.ctx.workspace.id),
402 ];
403
404 if (opts.input?.createdAt?.gte) {
405 whereConditions.push(
406 gte(statusReport.createdAt, opts.input.createdAt.gte),
407 );
408 }
409
410 if (opts.input?.pageId) {
411 whereConditions.push(eq(statusReport.pageId, opts.input.pageId));
412 }
413
414 const result = await opts.ctx.db.query.statusReport.findMany({
415 where: and(...whereConditions),
416 with: {
417 statusReportUpdates: true,
418 monitorsToStatusReports: { with: { monitor: true } },
419 page: true,
420 },
421 orderBy: (statusReport) => [
422 opts.input.order === "asc"
423 ? asc(statusReport.createdAt)
424 : desc(statusReport.createdAt),
425 ],
426 });
427
428 return selectStatusReportSchema
429 .extend({
430 updates: z.array(selectStatusReportUpdateSchema).prefault([]),
431 monitors: z.array(selectMonitorSchema).prefault([]),
432 page: selectPageSchema,
433 })
434 .array()
435 .parse(
436 result.map((report) => ({
437 ...report,
438 updates: report.statusReportUpdates,
439 monitors: report.monitorsToStatusReports.map(
440 ({ monitor }) => monitor,
441 ),
442 })),
443 );
444 }),
445
446 create: protectedProcedure
447 .meta({ track: Events.CreateReport })
448 .input(
449 z.object({
450 title: z.string(),
451 status: z.enum(statusReportStatus),
452 pageId: z.number(),
453 monitors: z.array(z.number()),
454 date: z.coerce.date(),
455 message: z.string(),
456 notifySubscribers: z.boolean().nullish(),
457 }),
458 )
459 .mutation(async (opts) => {
460 return opts.ctx.db.transaction(async (tx) => {
461 const newStatusReport = await tx
462 .insert(statusReport)
463 .values({
464 workspaceId: opts.ctx.workspace.id,
465 title: opts.input.title,
466 status: opts.input.status,
467 pageId: opts.input.pageId,
468 })
469 .returning()
470 .get();
471
472 const newStatusReportUpdate = await tx
473 .insert(statusReportUpdate)
474 .values({
475 statusReportId: newStatusReport.id,
476 status: opts.input.status,
477 date: opts.input.date,
478 message: opts.input.message,
479 })
480 .returning()
481 .get();
482
483 if (opts.input.monitors.length > 0) {
484 await tx
485 .insert(monitorsToStatusReport)
486 .values(
487 opts.input.monitors.map((monitor) => ({
488 monitorId: monitor,
489 statusReportId: newStatusReport.id,
490 })),
491 )
492 .returning()
493 .get();
494 // Sync to page components
495 await syncStatusReportToMonitorInsertMany(
496 tx,
497 newStatusReport.id,
498 opts.input.monitors,
499 );
500 }
501
502 return {
503 ...newStatusReportUpdate,
504 notifySubscribers: opts.input.notifySubscribers,
505 };
506 });
507 }),
508
509 updateStatus: protectedProcedure
510 .meta({ track: Events.UpdateReport })
511 .input(
512 z.object({
513 id: z.number(),
514 monitors: z.array(z.number()),
515 title: z.string(),
516 status: z.enum(statusReportStatus),
517 }),
518 )
519 .mutation(async (opts) => {
520 await opts.ctx.db.transaction(async (tx) => {
521 await tx
522 .update(statusReport)
523 .set({
524 title: opts.input.title,
525 status: opts.input.status,
526 updatedAt: new Date(),
527 })
528 .where(
529 and(
530 eq(statusReport.id, opts.input.id),
531 eq(statusReport.workspaceId, opts.ctx.workspace.id),
532 ),
533 )
534 .run();
535
536 await tx
537 .delete(monitorsToStatusReport)
538 .where(eq(monitorsToStatusReport.statusReportId, opts.input.id))
539 .run();
540 // Sync delete to page components
541 await syncStatusReportToMonitorDeleteByStatusReport(tx, opts.input.id);
542
543 if (opts.input.monitors.length > 0) {
544 await tx
545 .insert(monitorsToStatusReport)
546 .values(
547 opts.input.monitors.map((monitor) => ({
548 monitorId: monitor,
549 statusReportId: opts.input.id,
550 })),
551 )
552 .run();
553 // Sync to page components
554 await syncStatusReportToMonitorInsertMany(
555 tx,
556 opts.input.id,
557 opts.input.monitors,
558 );
559 }
560 });
561 }),
562
563 delete: protectedProcedure
564 .meta({ track: Events.DeleteReport })
565 .input(z.object({ id: z.number() }))
566 .mutation(async (opts) => {
567 const whereConditions: SQL[] = [
568 eq(statusReport.id, opts.input.id),
569 eq(statusReport.workspaceId, opts.ctx.workspace.id),
570 ];
571
572 await opts.ctx.db.transaction(async (tx) => {
573 await tx
574 .delete(statusReport)
575 .where(and(...whereConditions))
576 .run();
577 });
578 }),
579
580 deleteUpdate: protectedProcedure
581 .meta({ track: Events.DeleteReportUpdate })
582 .input(z.object({ id: z.number() }))
583 .mutation(async (opts) => {
584 await opts.ctx.db.transaction(async (tx) => {
585 const update = await tx.query.statusReportUpdate.findFirst({
586 where: eq(statusReportUpdate.id, opts.input.id),
587 with: {
588 statusReport: true,
589 },
590 });
591
592 if (update?.statusReport.workspaceId !== opts.ctx.workspace.id) {
593 throw new TRPCError({
594 code: "FORBIDDEN",
595 message: "You are not allowed to delete this update",
596 });
597 }
598
599 await tx
600 .delete(statusReportUpdate)
601 .where(eq(statusReportUpdate.id, opts.input.id))
602 .run();
603 });
604 }),
605});