Openstatus
www.openstatus.dev
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});