Skip to content

Commit 15c686b

Browse files
committed
Protect against multiple updates to a dashboard at once
1 parent 193015d commit 15c686b

1 file changed

Lines changed: 58 additions & 62 deletions

File tree

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx

Lines changed: 58 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,50 @@ const ParamsSchema = EnvironmentParamSchema.extend({
108108
dashboardId: z.string(),
109109
});
110110

111+
// Check widget limit for add/duplicate actions (title widgets don't count)
112+
async function checkWidgetLimit(
113+
existingLayout: z.infer<typeof DashboardLayout>,
114+
organizationId: string
115+
) {
116+
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
117+
(w) => w.display.type !== "title"
118+
).length;
119+
const plan = await getCurrentPlan(organizationId);
120+
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
121+
?.metricWidgetsPerDashboard;
122+
const widgetLimit =
123+
typeof metricWidgetsLimitValue === "number"
124+
? metricWidgetsLimitValue
125+
: (metricWidgetsLimitValue?.number ?? 16);
126+
127+
if (currentWidgetCount >= widgetLimit) {
128+
throw new Response("Widget limit reached", { status: 403 });
129+
}
130+
}
131+
132+
// Optimistic concurrency save: uses updateMany so we can include updatedAt
133+
// in the where clause. If another request modified the dashboard between our
134+
// read and this write, updatedAt won't match and count will be 0.
135+
async function saveDashboardLayout(
136+
dashboardId: string,
137+
expectedUpdatedAt: Date,
138+
updatedLayout: z.infer<typeof DashboardLayout>
139+
) {
140+
const result = await prisma.metricsDashboard.updateMany({
141+
where: { id: dashboardId, updatedAt: expectedUpdatedAt },
142+
data: {
143+
layout: JSON.stringify(updatedLayout),
144+
},
145+
});
146+
147+
if (result.count === 0) {
148+
throw new Response(
149+
"Dashboard was modified by another request. Please refresh and try again.",
150+
{ status: 409 }
151+
);
152+
}
153+
}
154+
111155
export const action = async ({ request, params }: ActionFunctionArgs) => {
112156
const userId = await requireUserId(request);
113157
const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params);
@@ -169,24 +213,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
169213
}
170214
}
171215

172-
// Check widget limit for add/duplicate actions (title widgets don't count)
173-
async function checkWidgetLimit() {
174-
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
175-
(w) => w.display.type !== "title"
176-
).length;
177-
const plan = await getCurrentPlan(project!.organizationId);
178-
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
179-
?.metricWidgetsPerDashboard;
180-
const widgetLimit =
181-
typeof metricWidgetsLimitValue === "number"
182-
? metricWidgetsLimitValue
183-
: (metricWidgetsLimitValue?.number ?? 16);
184-
185-
if (currentWidgetCount >= widgetLimit) {
186-
throw new Response("Widget limit reached", { status: 403 });
187-
}
188-
}
189-
190216
switch (action) {
191217
case "add": {
192218
const rawData = {
@@ -210,7 +236,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
210236

211237
// Title widgets don't count against the limit
212238
if (config.type !== "title") {
213-
await checkWidgetLimit();
239+
await checkWidgetLimit(existingLayout, project.organizationId);
214240
}
215241

216242
// Use client-provided widget ID if available, otherwise generate one
@@ -252,13 +278,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
252278
},
253279
};
254280

255-
// Save to database
256-
await prisma.metricsDashboard.update({
257-
where: { id: dashboard.id },
258-
data: {
259-
layout: JSON.stringify(updatedLayout),
260-
},
261-
});
281+
// Save to database (with optimistic concurrency check)
282+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
262283

263284
return typedjson({ success: true, widgetId });
264285
}
@@ -299,13 +320,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
299320
},
300321
};
301322

302-
// Save to database
303-
await prisma.metricsDashboard.update({
304-
where: { id: dashboard.id },
305-
data: {
306-
layout: JSON.stringify(updatedLayout),
307-
},
308-
});
323+
// Save to database (with optimistic concurrency check)
324+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
309325

310326
return typedjson({ success: true, updatedTitle: title });
311327
}
@@ -340,13 +356,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
340356
},
341357
};
342358

343-
// Save to database
344-
await prisma.metricsDashboard.update({
345-
where: { id: dashboard.id },
346-
data: {
347-
layout: JSON.stringify(updatedLayout),
348-
},
349-
});
359+
// Save to database (with optimistic concurrency check)
360+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
350361

351362
return typedjson({ success: true, renamedTitle: title });
352363
}
@@ -376,19 +387,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
376387
),
377388
};
378389

379-
// Save to database
380-
await prisma.metricsDashboard.update({
381-
where: { id: dashboard.id },
382-
data: {
383-
layout: JSON.stringify(updatedLayout),
384-
},
385-
});
390+
// Save to database (with optimistic concurrency check)
391+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
386392

387393
return typedjson({ success: true, deletedTitle: widgetTitle });
388394
}
389395

390396
case "duplicate": {
391-
await checkWidgetLimit();
397+
await checkWidgetLimit(existingLayout, project.organizationId);
392398

393399
const rawData = {
394400
widgetId: formData.get("widgetId"),
@@ -452,13 +458,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
452458
},
453459
};
454460

455-
// Save to database
456-
await prisma.metricsDashboard.update({
457-
where: { id: dashboard.id },
458-
data: {
459-
layout: JSON.stringify(updatedLayout),
460-
},
461-
});
461+
// Save to database (with optimistic concurrency check)
462+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
462463

463464
return typedjson({ success: true, duplicatedTitle: originalWidget.title });
464465
}
@@ -478,13 +479,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
478479
layout: result.data.layout,
479480
};
480481

481-
// Save to database
482-
await prisma.metricsDashboard.update({
483-
where: { id: dashboard.id },
484-
data: {
485-
layout: JSON.stringify(updatedLayout),
486-
},
487-
});
482+
// Save to database (with optimistic concurrency check)
483+
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
488484

489485
return typedjson({ success: true });
490486
}

0 commit comments

Comments
 (0)