feat(concurrency): echo the base token from the prefs write clients and document it

Wire the notification-prefs cards, the module toggles, the coach-preferences
save, and the two mood-tag manage cards to echo the optimistic-concurrency
token they last read and to reload on a 409 instead of resaving over a newer
change. Document the token and the 409 on the four write endpoints in the
OpenAPI registry via the shared base-token field and conflict-response
fragments.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 18:07:43 +02:00
1 parent 8849f2cecd
commit 3f33a30415
12 files changed
+352 -44

No files matched your search

+24 -1
View File
@@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
interface CoachPrefsShape {
nudgesEnabled: boolean;
@@ -39,6 +40,9 @@ interface CoachPrefsShape {
interface NotificationPrefsShape {
coach: CoachPrefsShape;
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on every PATCH from this card.
updatedAt?: string;
}
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
@@ -140,10 +144,18 @@ export function CoachNudgeCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coach: partial }),
body: JSON.stringify(
withBaseToken(
{ coach: partial },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -156,6 +168,17 @@ export function CoachNudgeCard({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic({});
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic knob
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimistic({});
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
@@ -93,6 +93,12 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
},
// v1.32.22 (R5b) — a concurrent write advanced the row since this edit was
// based. The hook already refetched (so the form re-syncs to server truth
// and the token advances); nudge the user to re-apply and save again.
onConflict: () => {
toast.message(t("common.conflictReloaded"));
},
});
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
+22 -1
View File
@@ -12,6 +12,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
@@ -26,6 +27,9 @@ import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NotificationPrefsShape {
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on every PATCH from this card.
updatedAt?: string;
}
const DEFAULT_DAYS = 7;
@@ -116,10 +120,18 @@ export function LowStockCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(
withBaseToken(
body,
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -133,6 +145,15 @@ export function LowStockCard({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Refetch (the active query
// reseeds a fresh token) and nudge — the caller reverts its optimistic
// value, so the card snaps back to server truth for a re-apply.
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
+43 -2
View File
@@ -65,6 +65,11 @@ import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_REGISTRY,
@@ -73,6 +78,12 @@ import {
type ModuleKey,
} from "@/lib/modules/registry";
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
type ModulesPatchResult = {
modules: Partial<Record<ModuleKey, boolean>>;
updatedAt?: string;
};
/** Neutral Lucide glyph per toggleable module. */
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
cycle: CalendarHeart,
@@ -130,7 +141,26 @@ export function ModulesSection() {
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
}
// DISABLED allowlist: send only the single key the user flipped.
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
//
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
// clobbering. The token rides on `User.updatedAt`; the module map itself
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
// seeded from each write response below and read back here at mutate
// time. The first write of a session is tokenless — the server's
// backward-compatible unconditional arm.
const result = await apiPatch<ModulesPatchResult>(
"/api/auth/me/modules",
withBaseToken(
{ [vars.key]: vars.enabled },
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
),
);
queryClient.setQueryData<{ updatedAt?: string }>(
queryKeys.modulesPrefs(),
{ updatedAt: result.updatedAt },
);
return result;
},
onSuccess: (_data, vars) => {
// Re-gate every surface that reads the module map off /auth/me: nav,
@@ -146,7 +176,18 @@ export function ModulesSection() {
}
toast.success(t("settings.sections.modules.saved"));
},
onError: () => {
onError: (err) => {
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
// advanced since this switch was based (another tab / the iOS client
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
// so the switch snaps back to server truth, then nudge — nothing was
// clobbered. A retry re-seeds a fresh token from its own response.
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("settings.sections.modules.error"));
},
});
+24 -1
View File
@@ -11,6 +11,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
interface ProfileShape {
moodReminderEnabled: boolean;
@@ -18,6 +19,9 @@ interface ProfileShape {
interface NotificationPrefsShape {
mood: { reminderHour: number };
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
// resolved prefs response, echoed on the reminder-hour PATCH.
updatedAt?: string;
}
export function MoodReminderCard({
@@ -132,10 +136,18 @@ export function MoodReminderCard({
clearTimerRef.current = null;
}
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
// write (the iOS client's `clientManaged` flag, another web card) 409s
// instead of silently reverting a sibling category through the deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood: { reminderHour: next } }),
body: JSON.stringify(
withBaseToken(
{ mood: { reminderHour: next } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
if (res.ok) {
@@ -146,6 +158,17 @@ export function MoodReminderCard({
});
setOptimisticHour(null);
scheduleClear();
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic hour
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimisticHour(null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimisticHour(null);
setMsg(t("notifications.moodReminder.saveError"));