From cf75579470e90f9d856d2a674b46f816d6fb1392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 11:03:03 +0200 Subject: [PATCH] fix(dashboard): wrap chart-overlay-prefs PUT in Serializable transaction Two concurrent toggles on different charts (user opens two tabs, flips BP "Target range" in one and Weight "Trend" in the other within ~50ms) could drop one of the updates. Both reads landed on the same dashboard-layout snapshot, then each wrote its merged blob back, with the second write clobbering the first chart's change. Wrap the read-modify-write in a $transaction with Serializable isolation so the second writer either sees the first writer's result or aborts. Same pattern any future widget-prefs route should follow until we move the JSON to a jsonb_set-based atomic merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/chart-overlay-prefs/route.ts | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 87600f441..3f75f3f8e 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -49,31 +49,40 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiError(parsed.error.issues[0].message, 422); } - // Read the existing layout, merge the new chart's prefs, and write - // it back. Resolver normalises legacy / missing fields, so layouts - // saved before v1.4.18 pick up the new field with default-empty - // prefs without a one-off migration. - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); + // Read-modify-write inside a Serializable transaction so two + // concurrent toggles (e.g. user opens two tabs and flips overlays + // on different charts) can't drop one another's update by reading + // the same layout snapshot and clobbering each other on write. + // Resolver normalises legacy / missing fields, so layouts saved + // before v1.4.18 pick up the new field with default-empty prefs + // without a one-off migration. + await prisma.$transaction( + async (tx) => { + const row = await tx.user.findUnique({ + where: { id: user.id }, + select: { dashboardWidgetsJson: true }, + }); - const current = resolveDashboardLayout(row?.dashboardWidgetsJson); - const next: DashboardLayout = { - ...current, - chartOverlayPrefs: { - ...(current.chartOverlayPrefs ?? {}), - [parsed.data.chartKey]: parsed.data.prefs, - }, - }; - const normalized = serializeDashboardLayout(next); + const current = resolveDashboardLayout(row?.dashboardWidgetsJson); + const next: DashboardLayout = { + ...current, + chartOverlayPrefs: { + ...(current.chartOverlayPrefs ?? {}), + [parsed.data.chartKey]: parsed.data.prefs, + }, + }; + const normalized = serializeDashboardLayout(next); - await prisma.user.update({ - where: { id: user.id }, - data: { - dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue, + await tx.user.update({ + where: { id: user.id }, + data: { + dashboardWidgetsJson: + normalized as unknown as Prisma.InputJsonValue, + }, + }); }, - }); + { isolationLevel: "Serializable" }, + ); annotate({ action: { name: "dashboard.chartOverlayPrefs.update" },