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

+28 -1
View File
@@ -59,6 +59,12 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -72,6 +78,7 @@ import {
snapshotManageCache,
updateManageCache,
type ManageCatalog,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -141,11 +148,31 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
reorderGroups(current, nextOrder),
);
try {
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
// v1.32.22 (R5b) — echo the base token this order was based on. The two
// cards on this page write DIFFERENT fields of the same blob (group order
// vs tag placements); a stale merge would resurrect the other card's
// overwritten field, which the token now 409s instead. Seed the token
// from the response so the next write on this surface is guarded too.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ groupOrder: nextOrder },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
// Drop the stale token so a deliberate retry writes against the tree
// the invalidation below reloads; nudge — the optimistic delta rolled
// back and nothing was clobbered.
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -47,6 +47,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,6 +71,7 @@ import {
type ManageCatalog,
type ManageCategory,
type ManageTag,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -139,13 +146,26 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
try {
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(nextCatalog),
});
// v1.32.22 (R5b) — echo + seed the layout token (see `putGroupOrder` in
// the groups card): the sibling group-order card writes the same blob, so
// a stale placements merge is exactly the overwrite the token 409s.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(nextCatalog) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -237,12 +257,24 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
}
// Both kinds pin the resulting per-group order in the layout blob
// so the resolved tree matches what the user just saw happen.
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(moved),
});
// v1.32.22 (R5b) — echo + seed the layout token so a concurrent write to
// the same blob 409s instead of clobbering.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(moved) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("common.saved"));
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -62,6 +62,19 @@ export interface ManageCatalog {
categories: ManageCategory[];
}
/**
* v1.32.22 (R5b) — the resolved `GET/PUT /api/mood/tags/layout` response.
* `updatedAt` is the optimistic-concurrency token the two management cards
* echo on their next write; no active query reads this key (it is only ever
* invalidated), so the token is seeded straight from each write response into
* `queryKeys.moodTagLayout()` and read back at mutate time.
*/
export interface MoodTagLayoutResolved {
groupOrder: string[];
placements: Record<string, string[]>;
updatedAt?: string;
}
export function useMoodTagManage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagManage(),
+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"));
+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);
},
});
}
+24 -4
View File
@@ -24,7 +24,14 @@ import {
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/moodlog";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
stdResponses,
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
} from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
@@ -154,11 +161,23 @@ const moodTagLayoutResolved = z
.object({
groupOrder: z.array(z.string()),
placements: z.record(z.string(), z.array(z.string())),
// v1.32.22 (M2) — optimistic-concurrency token echoed on GET / PUT.
updatedAt: updatedAtTokenField,
})
.meta({
id: "MoodTagLayoutResolved",
description:
"The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored.",
"The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored. `updatedAt` is the optimistic-concurrency token — echo it as `baseUpdatedAt` on the next PUT.",
});
// v1.32.22 (M2) — the PUT request extends the layout schema with the base
// token (stripped pre-Zod at runtime).
const moodTagLayoutPutRequest = moodTagLayoutSchema
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "MoodTagLayoutPutRequest",
description:
"Mood-tag layout to merge (preserve-when-absent), plus the optional optimistic-concurrency `baseUpdatedAt` token. The two manage cards write different fields of the same blob; echo the token to have a stale write refused with 409 instead of resurrecting the other card's overwritten field. Omit it for the legacy unconditional write.",
});
const notFoundResponse = {
@@ -531,11 +550,11 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout.",
requestBody: {
required: true,
content: { "application/json": { schema: moodTagLayoutSchema } },
content: { "application/json": { schema: moodTagLayoutPutRequest } },
},
responses: {
"200": {
description: "Merged + resolved layout.",
description: "Merged + resolved layout with the fresh token.",
content: {
"application/json": {
schema: dataEnvelope(
@@ -545,6 +564,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"),
...stdResponses,
},
},
+67 -23
View File
@@ -12,7 +12,14 @@ import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
stdResponses,
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
// validator (`modulePrefsPatchSchema` — strict over the toggleable key
@@ -22,11 +29,17 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// `disableCoach` + operator master flag, the rest read the per-user
// disabled-allowlist. The map is the single thing a client needs to gate
// every secondary domain end-to-end.
const modulePrefsPatchRequest = modulePrefsPatchSchema.meta({
id: "ModulePrefsPatchRequest",
description:
'Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag).',
});
// v1.32.22 (M3) — the doc request extends the strict runtime schema with the
// optimistic-concurrency `baseUpdatedAt` token. The runtime strips it pre-Zod
// (`takeBaseToken`) BEFORE the strict parse, so the runtime schema alone would
// under-document the wire; documenting it here keeps the contract complete.
const modulePrefsPatchRequest = modulePrefsPatchSchema
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "ModulePrefsPatchRequest",
description:
'Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag). `baseUpdatedAt` is the optional optimistic-concurrency token — omit it for the legacy unconditional write.',
});
const moduleMapResolved = z
.object(
@@ -46,7 +59,7 @@ const moduleMapResolved = z
});
const moduleMapEnvelopeInner = z
.object({ modules: moduleMapResolved })
.object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField })
.meta({ id: "ModulesResponse" });
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
@@ -85,11 +98,15 @@ const diabetesFlag = z
// is the REAL runtime validator (`notificationPrefsSchema` — every
// category and field optional, deep-merged server-side); the response
// is the fully-resolved shape with the documented defaults filled in.
const notificationPrefsPatchRequest = notificationPrefsSchema.meta({
id: "NotificationPrefsPatchRequest",
description:
"Partial per-user notification preferences. Every category and field is optional; the server deep-merges the supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — `null` switches the alert off.",
});
// v1.32.22 (M1) — the doc request extends the runtime validator with the
// optimistic-concurrency `baseUpdatedAt` token (stripped pre-Zod at runtime).
const notificationPrefsPatchRequest = notificationPrefsSchema
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "NotificationPrefsPatchRequest",
description:
"Partial per-user notification preferences. Every category and field is optional; the server deep-merges the supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — `null` switches the alert off. `baseUpdatedAt` is the optional optimistic-concurrency token: echo it to have the server refuse (409) a write that would silently revert a category another writer set from a fresher read (e.g. iOS `medication.clientManaged`). Omit it for the legacy unconditional write.",
});
const notificationPrefsResolved = z
.object({
@@ -163,6 +180,25 @@ const notificationPrefsResolved = z
"Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row resolves to the documented defaults.",
});
// v1.32.22 (M1) — GET / PATCH responses carry the optimistic-concurrency token
// as an additive sibling key next to the resolved categories.
const notificationPrefsResponse = notificationPrefsResolved
.extend({ updatedAt: updatedAtTokenField })
.meta({ id: "NotificationPrefsResponse" });
// v1.32.22 (M4) — coach-prefs GET / PUT responses carry the token; the PUT
// request extends the runtime validator with the base token.
const coachPrefsResponse = coachPrefsSchema
.extend({ updatedAt: updatedAtTokenField })
.meta({ id: "CoachPrefsResponse" });
const coachPrefsPutRequest = coachPrefsSchema
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "PutCoachPrefsRequest",
description:
"Coach prompt-tuning preferences to persist, plus the optional optimistic-concurrency `baseUpdatedAt` token. The guarded write covers both `coachPrefsJson` and the mirrored `insightsExcludeMetrics` column atomically. Omit the token for the legacy unconditional write.",
});
// v1.8.6 — profile read/write surface for the native client. The
// runtime validation lives in `src/lib/validations/auth.ts`
// (`profileSchema`, whose transforms normalise empty → null and run the
@@ -376,10 +412,10 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.",
responses: {
"200": {
description: "Resolved preferences.",
description: "Resolved preferences plus the `updatedAt` token.",
content: {
"application/json": {
schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"),
schema: dataEnvelope(coachPrefsResponse, "GetCoachPrefsResponse"),
},
},
},
@@ -390,20 +426,21 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Replace per-user Coach prompt-tuning preferences",
description:
"Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.",
"Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults. Supports optimistic concurrency: echo the `updatedAt` from the last read as `baseUpdatedAt` and the write is refused with 409 if the row changed since; omit it for the legacy unconditional write.",
requestBody: {
required: true,
content: { "application/json": { schema: coachPrefsSchema } },
content: { "application/json": { schema: coachPrefsPutRequest } },
},
responses: {
"200": {
description: "Saved preferences echoed back.",
description: "Saved preferences echoed back with the fresh token.",
content: {
"application/json": {
schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"),
schema: dataEnvelope(coachPrefsResponse, "PutCoachPrefsResponse"),
},
},
},
...conflictResponse409("Coach preferences", "coach_prefs_conflict"),
...stdResponses,
},
},
@@ -527,7 +564,8 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
"200": {
description: "Resolved next-state module map echoed back.",
description:
"Resolved next-state module map echoed back with the fresh `updatedAt` token.",
content: {
"application/json": {
schema: dataEnvelope(
@@ -537,6 +575,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...conflictResponse409("Module preferences", "modules_conflict"),
...stdResponses,
},
},
@@ -737,11 +776,11 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach nudges on).",
responses: {
"200": {
description: "Resolved preferences.",
description: "Resolved preferences plus the `updatedAt` token.",
content: {
"application/json": {
schema: dataEnvelope(
notificationPrefsResolved,
notificationPrefsResponse,
"GetNotificationPrefsResponse",
),
},
@@ -763,16 +802,21 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
"200": {
description: "Resolved next-state preferences echoed back.",
description:
"Resolved next-state preferences echoed back with the fresh `updatedAt` token.",
content: {
"application/json": {
schema: dataEnvelope(
notificationPrefsResolved,
notificationPrefsResponse,
"PatchNotificationPrefsResponse",
),
},
},
},
...conflictResponse409(
"Notification preferences",
"notification_prefs_conflict",
),
...stdResponses,
},
},
+12
View File
@@ -112,6 +112,18 @@ export const baseUpdatedAtField = z.iso
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
);
/**
* Optional response field echoing the fresh optimistic-concurrency token.
* Every guarded GET / write response carries the stored row's `updatedAt`; the
* client echoes it back as `baseUpdatedAt` on the next write.
*/
export const updatedAtTokenField = z.iso
.datetime({ offset: true })
.optional()
.describe(
"Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it.",
);
/**
* The 409 the guarded write returns when the base token is stale. `errorCode`
* is per-endpoint; the caller passes the resource noun + the concrete