Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 605 lines 18 kB view raw
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});