Openstatus www.openstatus.dev
at main 394 lines 14 kB view raw
1import { z } from "zod"; 2 3import { type SQL, and, asc, desc, eq, inArray, sql } from "@openstatus/db"; 4import { 5 pageComponent, 6 pageComponentGroup, 7 selectMaintenanceSchema, 8 selectMonitorSchema, 9 selectPageComponentGroupSchema, 10 selectPageComponentSchema, 11 selectStatusReportSchema, 12} from "@openstatus/db/src/schema"; 13 14import { Events } from "@openstatus/analytics"; 15import { TRPCError } from "@trpc/server"; 16import { createTRPCRouter, protectedProcedure } from "../trpc"; 17 18export const pageComponentRouter = createTRPCRouter({ 19 list: protectedProcedure 20 .input( 21 z 22 .object({ 23 pageId: z.number().optional(), 24 order: z.enum(["asc", "desc"]).optional(), 25 }) 26 .optional(), 27 ) 28 .query(async (opts) => { 29 const whereConditions: SQL[] = [ 30 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 31 ]; 32 33 if (opts.input?.pageId) { 34 whereConditions.push(eq(pageComponent.pageId, opts.input.pageId)); 35 } 36 37 const result = await opts.ctx.db.query.pageComponent.findMany({ 38 where: and(...whereConditions), 39 orderBy: 40 opts.input?.order === "desc" 41 ? desc(pageComponent.order) 42 : asc(pageComponent.order), 43 with: { 44 monitor: true, 45 group: true, 46 statusReportsToPageComponents: { 47 with: { 48 statusReport: true, 49 }, 50 orderBy: (statusReportsToPageComponents, { desc }) => 51 desc(statusReportsToPageComponents.createdAt), 52 }, 53 maintenancesToPageComponents: { 54 with: { 55 maintenance: true, 56 }, 57 orderBy: (maintenancesToPageComponents, { desc }) => 58 desc(maintenancesToPageComponents.createdAt), 59 }, 60 }, 61 }); 62 63 // Transform and parse the result to flatten the junction tables 64 return selectPageComponentSchema 65 .extend({ 66 monitor: selectMonitorSchema.nullish(), 67 group: selectPageComponentGroupSchema.nullish(), 68 statusReports: z.array(selectStatusReportSchema).default([]), 69 maintenances: z.array(selectMaintenanceSchema).default([]), 70 }) 71 .array() 72 .parse( 73 result.map((component) => ({ 74 ...component, 75 statusReports: 76 component.statusReportsToPageComponents?.map( 77 (sr) => sr.statusReport, 78 ) ?? [], 79 maintenances: 80 component.maintenancesToPageComponents?.map( 81 (m) => m.maintenance, 82 ) ?? [], 83 })), 84 ); 85 }), 86 87 delete: protectedProcedure 88 .meta({ track: Events.DeletePageComponent, trackProps: ["id"] }) 89 .input(z.object({ id: z.number() })) 90 .mutation(async (opts) => { 91 return await opts.ctx.db 92 .delete(pageComponent) 93 .where( 94 and( 95 eq(pageComponent.id, opts.input.id), 96 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 97 ), 98 ) 99 .returning(); 100 }), 101 102 updateOrder: protectedProcedure 103 .meta({ track: Events.UpdatePageComponentOrder, trackProps: ["pageId"] }) 104 .input( 105 z.object({ 106 pageId: z.number(), 107 components: z.array( 108 z.object({ 109 id: z.number().optional(), // Optional for new components 110 monitorId: z.number().nullish(), 111 order: z.number(), 112 name: z.string(), 113 description: z.string().nullish(), 114 type: z.enum(["monitor", "static"]), 115 }), 116 ), 117 groups: z.array( 118 z.object({ 119 order: z.number(), 120 name: z.string(), 121 components: z.array( 122 z.object({ 123 id: z.number().optional(), // Optional for new components 124 monitorId: z.number().nullish(), 125 order: z.number(), 126 name: z.string(), 127 description: z.string().nullish(), 128 type: z.enum(["monitor", "static"]), 129 }), 130 ), 131 }), 132 ), 133 }), 134 ) 135 .mutation(async (opts) => { 136 await opts.ctx.db.transaction(async (tx) => { 137 const pageComponentLimit = opts.ctx.workspace.limits["page-components"]; 138 139 // Get existing state 140 const existingComponents = await tx 141 .select() 142 .from(pageComponent) 143 .where( 144 and( 145 eq(pageComponent.pageId, opts.input.pageId), 146 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 147 ), 148 ) 149 .all(); 150 151 if (existingComponents.length >= pageComponentLimit) { 152 throw new TRPCError({ 153 code: "FORBIDDEN", 154 message: "You reached your page component limits.", 155 }); 156 } 157 158 const existingGroups = await tx 159 .select() 160 .from(pageComponentGroup) 161 .where( 162 and( 163 eq(pageComponentGroup.pageId, opts.input.pageId), 164 eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), 165 ), 166 ) 167 .all(); 168 169 const existingGroupIds = existingGroups.map((g) => g.id); 170 171 // Collect all monitorIds from input (for monitor-type components) 172 const inputMonitorIds = [ 173 ...opts.input.components 174 .filter((c) => c.type === "monitor" && c.monitorId) 175 .map((c) => c.monitorId), 176 ...opts.input.groups.flatMap((g) => 177 g.components 178 .filter((c) => c.type === "monitor" && c.monitorId) 179 .map((c) => c.monitorId), 180 ), 181 ] as number[]; 182 183 // Collect IDs for static components that have IDs in input 184 const inputStaticComponentIds = [ 185 ...opts.input.components 186 .filter((c) => c.type === "static" && c.id) 187 .map((c) => c.id), 188 ...opts.input.groups.flatMap((g) => 189 g.components 190 .filter((c) => c.type === "static" && c.id) 191 .map((c) => c.id), 192 ), 193 ] as number[]; 194 195 // Find components that are being removed 196 // For monitor components: those with monitorIds not in the input 197 // For static components with IDs: those with IDs not in the input 198 // For static components without IDs in input: delete all existing static components 199 const removedMonitorComponents = existingComponents.filter( 200 (c) => 201 c.type === "monitor" && 202 c.monitorId && 203 !inputMonitorIds.includes(c.monitorId), 204 ); 205 206 const hasStaticComponentsInInput = 207 opts.input.components.some((c) => c.type === "static") || 208 opts.input.groups.some((g) => 209 g.components.some((c) => c.type === "static"), 210 ); 211 212 // If input has static components but they don't have IDs, we need to delete old ones 213 // If input has static components with IDs, only delete those not in input 214 const removedStaticComponents = existingComponents.filter((c) => { 215 if (c.type !== "static") return false; 216 // If we have static components in input 217 if (hasStaticComponentsInInput) { 218 // If the input has IDs, only remove those not in the list 219 if (inputStaticComponentIds.length > 0) { 220 return !inputStaticComponentIds.includes(c.id); 221 } 222 // If input doesn't have IDs, remove all existing static components 223 return true; 224 } 225 // If no static components in input at all, remove existing ones 226 return true; 227 }); 228 229 const removedComponentIds = [ 230 ...removedMonitorComponents.map((c) => c.id), 231 ...removedStaticComponents.map((c) => c.id), 232 ]; 233 234 // Delete removed components 235 if (removedComponentIds.length > 0) { 236 await tx 237 .delete(pageComponent) 238 .where( 239 and( 240 eq(pageComponent.pageId, opts.input.pageId), 241 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 242 inArray(pageComponent.id, removedComponentIds), 243 ), 244 ); 245 } 246 247 // Clear groupId from all components before deleting groups 248 // This prevents foreign key constraint errors 249 if (existingGroupIds.length > 0) { 250 await tx 251 .update(pageComponent) 252 .set({ groupId: null }) 253 .where( 254 and( 255 eq(pageComponent.pageId, opts.input.pageId), 256 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 257 inArray(pageComponent.groupId, existingGroupIds), 258 ), 259 ); 260 } 261 262 // Delete old groups and create new ones 263 if (existingGroupIds.length > 0) { 264 await tx 265 .delete(pageComponentGroup) 266 .where( 267 and( 268 eq(pageComponentGroup.pageId, opts.input.pageId), 269 eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), 270 ), 271 ); 272 } 273 274 // Create new groups 275 const newGroups: Array<{ id: number; name: string }> = []; 276 if (opts.input.groups.length > 0) { 277 const createdGroups = await tx 278 .insert(pageComponentGroup) 279 .values( 280 opts.input.groups.map((g) => ({ 281 pageId: opts.input.pageId, 282 workspaceId: opts.ctx.workspace.id, 283 name: g.name, 284 })), 285 ) 286 .returning(); 287 newGroups.push(...createdGroups); 288 } 289 290 // Prepare values for upsert - both grouped and ungrouped components 291 const groupComponentValues = opts.input.groups.flatMap((g, i) => 292 g.components.map((c) => ({ 293 id: c.id, // Will be undefined for new components 294 pageId: opts.input.pageId, 295 workspaceId: opts.ctx.workspace.id, 296 name: c.name, 297 description: c.description, 298 type: c.type, 299 monitorId: c.monitorId, 300 order: g.order, 301 groupId: newGroups[i].id, 302 groupOrder: c.order, 303 })), 304 ); 305 306 const standaloneComponentValues = opts.input.components.map((c) => ({ 307 id: c.id, // Will be undefined for new components 308 pageId: opts.input.pageId, 309 workspaceId: opts.ctx.workspace.id, 310 name: c.name, 311 description: c.description, 312 type: c.type, 313 monitorId: c.monitorId, 314 order: c.order, 315 groupId: null as number | null, 316 groupOrder: null as number | null, 317 })); 318 319 const allComponentValues = [ 320 ...groupComponentValues, 321 ...standaloneComponentValues, 322 ]; 323 324 // Separate monitor and static components for different upsert strategies 325 const monitorComponents = allComponentValues.filter( 326 (c) => c.type === "monitor" && c.monitorId, 327 ); 328 const staticComponents = allComponentValues.filter( 329 (c) => c.type === "static", 330 ); 331 332 // Upsert monitor components using SQL-level conflict resolution 333 // This uses the (pageId, monitorId) unique constraint to preserve component IDs 334 if (monitorComponents.length > 0) { 335 await tx 336 .insert(pageComponent) 337 .values(monitorComponents) 338 .onConflictDoUpdate({ 339 target: [pageComponent.pageId, pageComponent.monitorId], 340 set: { 341 name: sql.raw("excluded.`name`"), 342 description: sql.raw("excluded.`description`"), 343 order: sql.raw("excluded.`order`"), 344 groupId: sql.raw("excluded.`group_id`"), 345 groupOrder: sql.raw("excluded.`group_order`"), 346 updatedAt: sql`(strftime('%s', 'now'))`, 347 }, 348 }); 349 } 350 351 // Handle static components 352 // If they have IDs, update them; otherwise insert new ones 353 for (const componentValue of staticComponents) { 354 if (componentValue.id) { 355 // Update existing static component (preserves ID and relationships) 356 await tx 357 .update(pageComponent) 358 .set({ 359 name: componentValue.name, 360 description: componentValue.description, 361 type: componentValue.type, 362 monitorId: componentValue.monitorId, 363 order: componentValue.order, 364 groupId: componentValue.groupId, 365 groupOrder: componentValue.groupOrder, 366 updatedAt: new Date(), 367 }) 368 .where( 369 and( 370 eq(pageComponent.id, componentValue.id), 371 eq(pageComponent.pageId, opts.input.pageId), 372 eq(pageComponent.workspaceId, opts.ctx.workspace.id), 373 ), 374 ); 375 } else { 376 // Insert new static component 377 await tx.insert(pageComponent).values({ 378 pageId: componentValue.pageId, 379 workspaceId: componentValue.workspaceId, 380 name: componentValue.name, 381 description: componentValue.description, 382 type: componentValue.type, 383 monitorId: componentValue.monitorId, 384 order: componentValue.order, 385 groupId: componentValue.groupId, 386 groupOrder: componentValue.groupOrder, 387 }); 388 } 389 } 390 }); 391 392 return { success: true }; 393 }), 394});