@@ -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+
111155export 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