diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 39657b92a..8406992f4 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -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( + "/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 { diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index e51ba034d..08739aeba 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -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( + "/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( + "/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); diff --git a/src/components/mood/manage/use-mood-tag-manage.ts b/src/components/mood/manage/use-mood-tag-manage.ts index cd71232ae..0d0529a3d 100644 --- a/src/components/mood/manage/use-mood-tag-manage.ts +++ b/src/components/mood/manage/use-mood-tag-manage.ts @@ -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; + updatedAt?: string; +} + export function useMoodTagManage(enabled: boolean) { return useQuery({ queryKey: queryKeys.moodTagManage(), diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index ce062a7d9..3318d6fe9 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -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")); diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index b694698ae..deba30815 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -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) { diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index de7ef0a72..de3aeca32 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -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"); diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 7d1f85f9d..c0b7ea2c4 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -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>; + updatedAt?: string; +}; + /** Neutral Lucide glyph per toggleable module. */ const MODULE_ICONS: Record = { 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( + "/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")); }, }); diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index d9b9defd3..64316a9db 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -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")); diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts index d360e335f..db3d3907b 100644 --- a/src/hooks/use-coach-prefs.ts +++ b/src/hooks/use-coach-prefs.ts @@ -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({ + return useQuery({ 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("/api/auth/me/coach-prefs", next), + apiPut( + "/api/auth/me/coach-prefs", + withBaseToken( + next, + readUpdatedAtToken(queryClient, queryKeys.coachPrefs()), + ), + ), onSuccess: (data) => { - queryClient.setQueryData(queryKeys.coachPrefs(), data); + queryClient.setQueryData( + 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); + }, }); } diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index df36341bb..4cdcdf468 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -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 = { "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 = { }, }, }, + ...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 776f77b28..1dba5d131 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 = { "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 = { 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 = { }, 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 = { }, }, }, + ...conflictResponse409("Module preferences", "modules_conflict"), ...stdResponses, }, }, @@ -737,11 +776,11 @@ export const profilePaths: NonNullable = { "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 = { }, 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, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index f97415211..090d510c4 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -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