mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
8849f2cecd
commit
3f33a30415
12 files changed
+352
-44
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user