feat(concurrency): guard layout and about-me writes against a concurrent overwrite

Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.

A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 16:26:58 +02:00
1 parent df4425babf
commit ab03bc96f1
27 files changed
+1669 -352

No files matched your search

+33 -8
View File
@@ -30,10 +30,16 @@ import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsLayoutWithToken,
type InsightsSectionConfig,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
@@ -119,15 +125,21 @@ export function InsightsEditMode({
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
sections: next.sections,
tiles: next.tiles,
});
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
// was based on so an interleaved write (a Settings pill-order Save, or
// this surface open in another tab) 409s instead of clobbering.
return apiPut<InsightsLayoutWithToken>(
"/api/insights/layout",
withBaseToken(
{ version: 2, sections: next.sections, tiles: next.tiles },
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
),
);
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
// shared cache so the overview repaints in the new order, then close.
// Optimistic-style settle: write the server-resolved layout (carrying
// the advanced token) into the shared cache so the overview repaints in
// the new order, then close.
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
@@ -135,7 +147,20 @@ export function InsightsEditMode({
toast.success(t("insights.editMode.saveSuccess"));
onClose();
},
onError: () => toast.error(t("insights.editMode.saveError")),
onError: (err) => {
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means someone else
// committed since this edit was based. Refetch so the token advances,
// KEEP the draft (stay in edit mode) so the user can re-save, and nudge
// gently — nothing was lost.
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("insights.editMode.saveError"));
},
});
const resetMutation = useMutation({
+17 -2
View File
@@ -46,6 +46,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
const FALLBACK_MAX_CHARS = 4000;
const FALLBACK_FIELD_MAX_CHARS = 500;
@@ -114,8 +115,14 @@ export function AboutMeSection({
mutationKey: queryKeys.coachAboutMe(),
// Send only the two fields this panel owns. `conditions` / `coachFocus`
// are omitted so the server preserves them (they are edited under Anamnese).
// v1.32.21 (R5a) — echo the base token this edit was based on so a
// concurrent write to the same self-context (another tab, or Coach adopt)
// 409s instead of silently overwriting the newer state.
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
return apiPut<AboutMeData>("/api/coach/about-me", input);
return apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(input, query.data?.updatedAt ?? undefined),
);
},
onSuccess: (next) => {
const cleared = !next.aboutMe && !next.allergies;
@@ -130,7 +137,15 @@ export function AboutMeSection({
queryKey: queryKeys.coachAboutMeQuestions(),
});
},
onError: () => {
onError: (err) => {
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means the stored
// self-context advanced since this edit was based. Refetch so the token
// advances, KEEP the drafts so the user can re-save, nudge gently.
if (isConflict(err)) {
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("settings.ai.aboutMe.saveError"));
},
});
@@ -33,7 +33,7 @@ import {
rebuildTilesWithReorderedVitals,
} from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsLayoutWithToken,
type InsightsTileConfig,
} from "@/lib/insights-layout";
import {
@@ -43,6 +43,11 @@ import {
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -154,11 +159,15 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
s.id === "ecg" ? { ...s, visible: ecgVisibleDraft } : s,
)
: layout.sections;
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
sections,
tiles,
});
// v1.32.21 (R5a) — echo the base token so a concurrent insights write
// (the overview edit mode, or another tab) 409s instead of clobbering.
return apiPut<InsightsLayoutWithToken>(
"/api/insights/layout",
withBaseToken(
{ version: 2, sections, tiles },
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
),
);
},
onSuccess: (saved) => {
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
@@ -167,7 +176,18 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
});
toast.success(t("insights.pillOrder.saveSuccess"));
},
onError: () => toast.error(t("insights.pillOrder.saveError")),
onError: (err) => {
// v1.32.21 (R5a) — explicit-Save disposition: on a 409 refetch so the
// token advances, KEEP the draft so the user can re-save, nudge gently.
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("insights.pillOrder.saveError"));
},
});
const busy = saveMutation.isPending;