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

+51 -5
View File
@@ -4,11 +4,24 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import {
DEFAULT_COACH_PREFS,
type CoachPrefs,
} from "@/lib/validations/coach-prefs";
/**
* v1.32.22 (R5b) — the cached Coach-prefs row carries the optimistic-
* concurrency token (`User.updatedAt`) alongside its payload so the writer
* can echo it. The read query and the save mutation both speak this shape;
* the token is opaque (the client only ever echoes a server-returned value).
*/
type CoachPrefsWithToken = CoachPrefs & { updatedAt?: string };
/**
* v1.4.23 W6 (S-03) — shared accessor for the per-user Coach
* preferences row. Both the Coach drawer's settings sheet and the
@@ -25,14 +38,14 @@ import {
* row (message thread) leave it unset (defaults to true).
*/
export function useCoachPrefs(opts?: { enabled?: boolean }) {
return useQuery<CoachPrefs>({
return useQuery<CoachPrefsWithToken>({
queryKey: queryKeys.coachPrefs(),
queryFn: async () => {
// `apiFetchRaw` (no .ok throw) — a non-OK read soft-fails to the
// defaults instead of surfacing an error state.
const res = await apiFetchRaw("/api/auth/me/coach-prefs");
if (!res.ok) return DEFAULT_COACH_PREFS;
const env = (await res.json()) as { data: CoachPrefs };
const env = (await res.json()) as { data: CoachPrefsWithToken };
return env.data;
},
enabled: opts?.enabled,
@@ -48,15 +61,48 @@ export function useCoachPrefs(opts?: { enabled?: boolean }) {
* every surface that reads the hook re-renders against one source of
* truth — the rail and the cog can never drift.
*/
export function useSaveCoachPrefs(opts?: { onSuccess?: () => void }) {
export function useSaveCoachPrefs(opts?: {
onSuccess?: () => void;
/** Fired when a write 409s because the row advanced since it was loaded. */
onConflict?: () => void;
/** Fired for every non-conflict write error, if the caller wants to surface it. */
onError?: (err: unknown) => void;
}) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.coachPrefs(),
// v1.32.22 (R5b) — echo the base token this edit was based on so a
// concurrent write to the same row (another tab, the chat sources rail)
// 409s instead of silently overwriting the newer state. Read straight from
// the cache at mutate time so it reflects the latest settled write.
mutationFn: async (next: CoachPrefs) =>
apiPut<CoachPrefs>("/api/auth/me/coach-prefs", next),
apiPut<CoachPrefsWithToken>(
"/api/auth/me/coach-prefs",
withBaseToken(
next,
readUpdatedAtToken(queryClient, queryKeys.coachPrefs()),
),
),
onSuccess: (data) => {
queryClient.setQueryData<CoachPrefs>(queryKeys.coachPrefs(), data);
queryClient.setQueryData<CoachPrefsWithToken>(
queryKeys.coachPrefs(),
data,
);
opts?.onSuccess?.();
},
onError: (err) => {
// v1.32.22 (R5b) — a 409 means the stored prefs advanced since this edit
// was based. Refetch so the token advances and the form re-syncs to the
// fresh server state, then let the caller nudge gently — nothing was
// clobbered. Every other error is handed back to the caller.
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.coachPrefs(),
});
opts?.onConflict?.();
return;
}
opts?.onError?.(err);
},
});
}