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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 11:03:03 +02:00
1 parent 194ec2f9da
commit cf75579470
1 file changed
+31 -22
@@ -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" },