diff --git a/messages/de.json b/messages/de.json index 4eccd9736..1b2333180 100644 --- a/messages/de.json +++ b/messages/de.json @@ -454,6 +454,7 @@ "layoutReset": "Auf Defaults zurücksetzen", "layoutSaveSuccess": "Dashboard-Layout gespeichert", "layoutSaveError": "Layout konnte nicht gespeichert werden", + "layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.", "layoutResetSuccess": "Auf Standard-Layout zurückgesetzt", "layoutUsingDefaults": "Standard-Layout aktiv", "layoutCustomized": "Eigenes Layout aktiv", diff --git a/messages/en.json b/messages/en.json index d15eec67f..e55ba886d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -444,6 +444,7 @@ "layoutReset": "Reset to defaults", "layoutSaveSuccess": "Dashboard layout saved", "layoutSaveError": "Could not save dashboard layout", + "layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.", "layoutResetSuccess": "Reset to default layout", "layoutUsingDefaults": "Using default layout", "layoutCustomized": "Custom layout active", diff --git a/messages/es.json b/messages/es.json index 846f043ad..1bebc82c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -456,6 +456,7 @@ "layoutReset": "Restablecer valores por defecto", "layoutSaveSuccess": "Diseño del panel guardado", "layoutSaveError": "No se ha podido guardar el diseño", + "layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.", "layoutResetSuccess": "Restablecido al diseño por defecto", "layoutUsingDefaults": "Diseño por defecto activo", "layoutCustomized": "Diseño personalizado activo", diff --git a/messages/fr.json b/messages/fr.json index 0f4af6e6e..3e584e0dc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -458,6 +458,7 @@ "layoutReset": "Rétablir les valeurs par défaut", "layoutSaveSuccess": "Mise en page enregistrée", "layoutSaveError": "Impossible d’enregistrer la mise en page", + "layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.", "layoutResetSuccess": "Mise en page par défaut restaurée", "layoutUsingDefaults": "Mise en page par défaut active", "layoutCustomized": "Mise en page personnalisée active", diff --git a/messages/it.json b/messages/it.json index 28a474487..5116cb64e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -448,6 +448,7 @@ "layoutReset": "Ripristina i valori predefiniti", "layoutSaveSuccess": "Layout della dashboard salvato", "layoutSaveError": "Impossibile salvare il layout", + "layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.", "layoutResetSuccess": "Layout predefinito ripristinato", "layoutUsingDefaults": "Layout predefinito attivo", "layoutCustomized": "Layout personalizzato attivo", diff --git a/messages/pl.json b/messages/pl.json index d3cc56145..1b0352e1a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -442,6 +442,7 @@ "layoutReset": "Przywróć wartości domyślne", "layoutSaveSuccess": "Układ panelu zapisany", "layoutSaveError": "Nie udało się zapisać układu", + "layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.", "layoutResetSuccess": "Przywrócono układ domyślny", "layoutUsingDefaults": "Aktywny układ domyślny", "layoutCustomized": "Aktywny układ niestandardowy", diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index d7c8c97f6..ae08bc950 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -8,6 +8,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, + apiError, buildPayloadDiagnostic, safeJson, returnAllZodIssues, @@ -29,6 +30,7 @@ import { layoutNeedsPreserveRead, mergePreservedLayoutFields, type DashboardLayout, + type DashboardLayoutWithToken, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -150,14 +152,27 @@ const layoutSchema = z.object({ .array(z.enum(HERO_RING_IDS)) .max(MAX_HERO_RING_ORDER) .optional(), + // v1.32.16 (issue #581) — optimistic-concurrency base token. The client + // sends the ISO `updatedAt` it read the layout at; the write is then + // guarded on the stored row still carrying it (else 409, no write). + // Optional so older web builds and the native client keep the prior + // unconditional write. Not a layout field — stripped before serialize. + baseUpdatedAt: z.string().optional(), }); -async function buildDashboardLayout(userId: string): Promise { +async function buildDashboardLayout( + userId: string, +): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, - select: { dashboardWidgetsJson: true }, + // v1.32.16 (issue #581) — `updatedAt` rides the cached layout so GET + // hands the client the optimistic-concurrency token alongside the + // layout it applies to. Cached together (and busted on every write) + // so the (layout, token) pair a client reads is always consistent. + select: { dashboardWidgetsJson: true, updatedAt: true }, }); - return resolveDashboardLayout(row?.dashboardWidgetsJson); + const layout = resolveDashboardLayout(row?.dashboardWidgetsJson); + return { ...layout, updatedAt: row?.updatedAt?.toISOString() }; } export const GET = apiHandler(async () => { @@ -167,7 +182,7 @@ export const GET = apiHandler(async () => { // hits the Settings → Dashboard save button, which invalidates this // cache via `invalidateUserDashboardWidgets()`. const layout = await cached( - caches.dashboardWidgets as ServerCache, + caches.dashboardWidgets as ServerCache, user.id, () => buildDashboardLayout(user.id), annotate, @@ -310,7 +325,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { // `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the // serializer fills the gap, so the assertion is safe here exactly as it was // on the previous per-field cast. - let toSerialize = parsed.data as Partial; + // v1.32.16 (issue #581) — the concurrency token is a request-only field, + // not part of the layout; split it out before the serialize pipeline so + // it can never leak into the stored blob. + const { baseUpdatedAt, ...layoutData } = parsed.data; + let toSerialize = layoutData as Partial; const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs; const needsRangePointsPreserve = incomingChartOverlayPrefs !== undefined && @@ -358,12 +377,49 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const normalized = serializeDashboardLayout(toSerialize as DashboardLayout); - await prisma.user.update({ - where: { id: user.id }, - data: { - dashboardWidgetsJson: toJson(normalized), - }, - }); + // v1.32.16 (issue #581) — optimistic concurrency. When the client sends + // the `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on + // the stored row still carrying that token: `updateMany` matches zero + // rows once any other request has advanced `updatedAt`, so a Save that + // already committed can never be silently reverted by a later request + // (e.g. an instant hero-ring PUT) that started from the pre-Save + // snapshot. A stale base returns 409 and writes NOTHING. Clients that + // omit the token (older web builds, the native client) keep the prior + // unconditional write — backward-compatible, still preserve-when-absent. + let freshUpdatedAt: Date; + if (baseUpdatedAt !== undefined) { + const base = new Date(baseUpdatedAt); + if (Number.isNaN(base.getTime())) { + return apiError("Invalid base token", 422, { + errorCode: "invalid_base_updated_at", + }); + } + const guarded = await prisma.user.updateMany({ + where: { id: user.id, updatedAt: base }, + data: { dashboardWidgetsJson: toJson(normalized) }, + }); + if (guarded.count === 0) { + annotate({ + action: { name: "dashboard.widgets.conflict" }, + meta: { base_updated_at: baseUpdatedAt }, + }); + return apiError("Dashboard layout changed since it was loaded", 409, { + errorCode: "dashboard_layout_conflict", + }); + } + const fresh = await prisma.user.findUnique({ + where: { id: user.id }, + select: { updatedAt: true }, + }); + freshUpdatedAt = fresh?.updatedAt ?? new Date(); + } else { + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { dashboardWidgetsJson: toJson(normalized) }, + select: { updatedAt: true }, + }); + freshUpdatedAt = updated?.updatedAt ?? new Date(); + } annotate({ action: { name: "dashboard.widgets.update" }, @@ -374,7 +430,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { // next dashboard mount paints the new layout. invalidateUserDashboardWidgets(user.id); - return apiSuccess(normalized); + // Echo the advanced token so the client can base its next write on it. + return apiSuccess({ + ...normalized, + updatedAt: freshUpdatedAt.toISOString(), + } satisfies DashboardLayoutWithToken); }); export const DELETE = apiHandler(async () => { diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index ddaf3bd5e..99f9a80d7 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -48,6 +48,8 @@ import { HEALTH_SCORE_RING_ID, resolveHeroRingOrder, buildRingMutationPayload, + widgetWritesBusy, + type DashboardLayoutWithToken, } from "@/lib/dashboard-layout"; import { Select, @@ -56,7 +58,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -127,6 +129,16 @@ function mergeReorderIntoLayout( }); } +/** + * v1.32.16 (issue #581) — the normal layout Save and the instant hero-ring + * PUT both write the whole `/api/dashboard/widgets` layout. Sharing one + * TanStack mutation `scope` serialises them: a second write in this scope + * waits for the first to settle, so the two PUTs can never overlap at the + * server and a committed Save cannot be reverted by a ring write that + * started from the pre-Save snapshot. + */ +const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const; + const WIDGET_LABEL_KEYS: Record = { weight: "dashboard.weight", bp: "dashboard.bloodPressure", @@ -224,10 +236,21 @@ export function DashboardLayoutSection({ id }: { id: string }) { const { data: remote, isLoading } = useQuery({ queryKey: queryKeys.dashboardWidgets(), queryFn: async () => { - return apiGet("/api/dashboard/widgets"); + return apiGet("/api/dashboard/widgets"); }, }); + /** + * v1.32.16 (issue #581) — the freshest optimistic-concurrency token the + * client knows. Read straight from the widgets query cache at mutate time + * (not a render snapshot) so it reflects whatever the last settled write + * or refetch advanced it to. Every write sends it as `baseUpdatedAt`. + */ + const readBaseToken = () => + queryClient.getQueryData( + queryKeys.dashboardWidgets(), + )?.updatedAt; + // Local draft state — null means "use server copy". User edits create the // draft so reordering/toggling doesn't fire a network call per click; Save // flushes it, Cancel clears it. Avoids a setState-in-effect (eslint @@ -236,8 +259,15 @@ export function DashboardLayoutSection({ id }: { id: string }) { const layout = draft ?? remote ?? null; const saveMutation = useMutation({ + // v1.32.16 (issue #581) — shared scope serialises this against the + // instant ring PUT so they can never overlap at the server. + scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, mutationFn: async (next: DashboardLayout) => { - return apiPut("/api/dashboard/widgets", next); + return apiPut("/api/dashboard/widgets", { + ...next, + // Guard the write on the token this edit was based on. + baseUpdatedAt: readBaseToken(), + }); }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); @@ -264,12 +294,28 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft(null); toast.success(t("dashboard.layoutSaveSuccess")); }, - onError: () => toast.error(t("dashboard.layoutSaveError")), + onError: (err) => { + // v1.32.16 (issue #581) — a 409 means someone else (another tab, or + // an interleaved ring write) committed since this edit was based. + // Refetch the winning layout so the base token advances, KEEP the + // draft so the user can re-apply, and nudge gently instead of a hard + // error — nothing was lost, the change just needs re-saving. + if (err instanceof ApiError && err.status === 409) { + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardWidgets(), + }); + toast.message(t("dashboard.layoutConflictReloaded")); + return; + } + toast.error(t("dashboard.layoutSaveError")); + }, }); const resetMutation = useMutation({ + // v1.32.16 — same scope so a reset can't race the layout / ring writes. + scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, mutationFn: async () => { - return apiDelete("/api/dashboard/widgets"); + return apiDelete("/api/dashboard/widgets"); }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); @@ -348,13 +394,19 @@ export function DashboardLayoutSection({ id }: { id: string }) { * Save no matter which one lands last. */ const ringMutation = useMutation({ + // v1.32.16 (issue #581) — shared scope serialises this against the full + // layout Save so the two writes can never overlap at the server. + scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }, mutationFn: async (next: { selectedScoreRings: ScoreRingId[]; heroRingOrder: HeroRingId[]; }) => { - return apiPut( + return apiPut( "/api/dashboard/widgets", - buildRingMutationPayload(next), + // Carries ONLY the ring fields (never `widgets`) plus the base + // token, so it can neither clobber a saved layout nor win a stale + // race — a conflict 409s instead. + buildRingMutationPayload({ ...next, baseUpdatedAt: readBaseToken() }), ); }, onMutate: async (next) => { @@ -373,13 +425,24 @@ export function DashboardLayoutSection({ id }: { id: string }) { } return { previous }; }, - onError: (_err, _next, context) => { + onError: (err, _next, context) => { if (context?.previous) { queryClient.setQueryData( queryKeys.dashboardWidgets(), context.previous, ); } + // v1.32.16 (issue #581) — a 409 means a concurrent write landed + // first. Roll back the optimistic ring change (above), refetch the + // winning layout so the token advances, drop this now-stale delta, + // and nudge gently so the user can re-toggle against fresh state. + if (err instanceof ApiError && err.status === 409) { + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardWidgets(), + }); + toast.message(t("dashboard.layoutConflictReloaded")); + return; + } toast.error(t("dashboard.layoutSaveError")); }, onSuccess: (saved) => { @@ -400,7 +463,10 @@ export function DashboardLayoutSection({ id }: { id: string }) { * the server resolver and the hero read one consistent sequence. */ function applyHeroRingOrder(next: HeroRingId[]) { - if (!remote) return; + // v1.32.16 (issue #581) — gate on the freshest layout (`draft ?? remote`) + // the rest of the section uses, not the `remote` server snapshot alone, + // so an open draft's edits stay the source of truth. + if (!layout) return; const selectedScoreRings = next.filter( (id): id is ScoreRingId => id !== HEALTH_SCORE_RING_ID, ); @@ -643,7 +709,12 @@ export function DashboardLayoutSection({ id }: { id: string }) { (ringId) => !heroOrder.includes(ringId), ); const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS; - const busy = ringMutation.isPending; + // v1.32.16 (issue #581) — ring controls also lock while a Save + // is in flight, so the two writes can never overlap. + const busy = widgetWritesBusy( + saveMutation.isPending, + ringMutation.isPending, + ); return (
setDraft(null)} - disabled={saveMutation.isPending} + disabled={widgetWritesBusy( + saveMutation.isPending, + ringMutation.isPending, + )} > {t("common.cancel")} @@ -839,7 +913,12 @@ export function DashboardLayoutSection({ id }: { id: string }) { size="sm" className="min-h-11 sm:min-h-9" onClick={() => layout && saveMutation.mutate(layout)} - disabled={saveMutation.isPending} + // v1.32.16 (issue #581) — disabled while EITHER write is in + // flight so a Save can't be issued mid ring-write and vice versa. + disabled={widgetWritesBusy( + saveMutation.isPending, + ringMutation.isPending, + )} > {saveMutation.isPending && ( diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 4c4e790a4..1b6849bd3 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -680,17 +680,65 @@ export function mergePreservedLayoutFields( * carries forward whatever is CURRENTLY stored, so this payload can no * longer race a concurrent Save no matter which request lands last. */ +/** + * v1.32.16 (issue #581) — a `/api/dashboard/widgets` write payload plus the + * optimistic-concurrency base token. `baseUpdatedAt` is the ISO + * `User.updatedAt` the client read the layout at; the route writes only when + * the stored row still carries it (else 409). It is request-only and never + * lands in the persisted `dashboardWidgetsJson` blob. + */ +export type DashboardLayoutWritePayload = Partial & { + baseUpdatedAt?: string; +}; + +/** + * v1.32.16 — the shape every GET / PUT `/api/dashboard/widgets` response + * carries: the resolved layout plus the server-stamped `updatedAt` + * concurrency token (mirrors `User.updatedAt`, never persisted inside the + * blob). The client sends it back as `baseUpdatedAt` on the next write. + */ +export type DashboardLayoutWithToken = DashboardLayout & { + updatedAt?: string; +}; + export function buildRingMutationPayload(next: { selectedScoreRings: ScoreRingId[]; heroRingOrder: HeroRingId[]; -}): Partial { + /** + * v1.32.16 (issue #581) — the base token the ring change was made + * against. Included when known so the guarded write can 409 rather than + * silently overwrite a Save that committed first. Omitted (not sent as + * `undefined`) when the client hasn't got a token yet. + */ + baseUpdatedAt?: string; +}): DashboardLayoutWritePayload { return { version: DASHBOARD_LAYOUT_VERSION, selectedScoreRings: next.selectedScoreRings, heroRingOrder: next.heroRingOrder, + ...(next.baseUpdatedAt !== undefined + ? { baseUpdatedAt: next.baseUpdatedAt } + : {}), }; } +/** + * v1.32.16 (issue #581) — the Save button and the instant hero-ring controls + * both PUT the whole `/api/dashboard/widgets` layout. While EITHER write is in + * flight the other must be disabled, so the two PUTs can never overlap at the + * server and a committed Save cannot be reverted by a ring write that started + * from the pre-Save snapshot. Belt-and-braces with the shared TanStack + * mutation `scope` (which already serialises the two) and the server's + * optimistic-concurrency token. Exported as a pure predicate so the JSX binds + * one source of truth and a unit test can pin the gate. + */ +export function widgetWritesBusy( + saving: boolean, + ringPending: boolean, +): boolean { + return saving || ringPending; +} + const DASHBOARD_LAYOUT_VERSION = 1; /**