mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
df4425babf
commit
ab03bc96f1
27 files changed
+1669
-352
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user