Merge pull request #624 from MBombeck/release/v1.32.22

release/v1.32.22 — concurrent-edit guard for prefs records, plus the inventory and privacy races
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-24 18:41:56 +02:00
commit cf755d32af
40 files changed
+2053 -520

No files matched your search

+34
View File
@@ -2,6 +2,40 @@
## [Unreleased]
## [1.32.22] — 2026-07-24
The concurrent-edit protection that reached the layout surfaces now covers the
rest of the preference records too. Notification preferences, module toggles,
coach preferences, and the mood-tag layout all get the same guard, so a save
based on an older read stops and reloads instead of quietly reverting a change
another writer just made. Two more races are closed on the server side: a stock
correction can no longer overwrite a dose consumption that ran at the same
moment, and a privacy-mode change is now respected by an insight generation that
was already running. Older clients that do not send the new marker keep working
exactly as before.
- **Notification preferences no longer clobber a concurrent change.** The
server merges each category into one stored record. A save based on an older
read now reloads instead of writing the whole record back without the change
another device just made. This is what closes the case where a web save could
silently switch off the app-managed medication reminder flag and bring back a
duplicate reminder.
- **Module toggles, coach preferences, and the mood-tag layout get the same
guard.** Each is one stored record that more than one surface can write; a
stale save is now stopped and reloaded rather than reverting a newer one. The
coach-preferences save covers both the tuning options and the excluded-metric
list together, so a conflict leaves both untouched.
- **A stock correction can no longer race a dose consumption.** Correcting the
remaining count on a container now runs under the same per-medication lock a
logged dose takes, so the two serialize instead of one overwriting the other.
- **A privacy-mode change is respected by an in-flight insight generation.** A
briefing that was already generating when you switch the privacy mode no
longer writes its old-scope output over the cache the switch cleared. It stops
short of the write and the next run regenerates under the new mode.
- **Older clients are unaffected.** A request that does not carry the new base
marker keeps the previous save behavior, so nothing needs updating to keep
working. The new contract is documented in the published API spec.
## [1.32.21] — 2026-07-24
Insights and medications layout saves, and the coach about-me note, are now
+327 -314
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.21
version: 1.32.22
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4408,7 +4408,7 @@ paths:
v1.4.22 defaults.
responses:
"200":
description: Resolved preferences.
description: Resolved preferences plus the `updatedAt` token.
content:
application/json:
schema:
@@ -4420,104 +4420,31 @@ 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.
description: "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:
type: object
properties:
tone:
default: warm
type: string
enum:
- warm
- neutral
- concise
verbosity:
default: default
type: string
enum:
- brief
- default
- detailed
excludeMetrics:
default: []
maxItems: 11
type: array
items:
type: string
enum:
- bp
- weight
- pulse
- mood
- compliance
- hrv
- sleep
- resting_hr
- steps
- medications
- anthropometrics
showEvidenceByDefault:
default: false
type: boolean
defaultWindow:
default: allTime
type: string
enum:
- last7days
- last30days
- last90days
- allTime
dataClusters:
maxItems: 10
type: array
items:
type: string
enum:
- cardio
- body
- activity
- workouts
- sleep
- mood
- glucose
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
$ref: "#/components/schemas/PutCoachPrefsRequest"
responses:
"200":
description: Saved preferences echoed back.
description: Saved preferences echoed back with the fresh token.
content:
application/json:
schema:
$ref: "#/components/schemas/PutCoachPrefsResponse"
"401": *a1
"409":
description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
the resource, re-apply the user's change against the fresh state, and resend with the new token.
`meta.errorCode` = `coach_prefs_conflict`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/auth/me/disable-coach:
@@ -4637,12 +4564,20 @@ paths:
$ref: "#/components/schemas/ModulePrefsPatchRequest"
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:
$ref: "#/components/schemas/PatchModulesResponse"
"401": *a1
"409":
description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened.
Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token.
`meta.errorCode` = `modules_conflict`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/auth/me/source-priority:
@@ -5630,7 +5565,7 @@ paths:
nudges on).
responses:
"200":
description: Resolved preferences.
description: Resolved preferences plus the `updatedAt` token.
content:
application/json:
schema:
@@ -5655,12 +5590,20 @@ paths:
$ref: "#/components/schemas/NotificationPrefsPatchRequest"
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:
$ref: "#/components/schemas/PatchNotificationPrefsResponse"
"401": *a1
"409":
description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened.
Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token.
`meta.errorCode` = `notification_prefs_conflict`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/insights/chat:
@@ -7944,35 +7887,23 @@ paths:
content:
application/json:
schema:
type: object
properties:
groupOrder:
maxItems: 50
type: array
items:
type: string
minLength: 1
maxLength: 80
placements:
type: object
propertyNames:
type: string
minLength: 1
maxLength: 80
additionalProperties:
type: array
items:
type: string
minLength: 1
maxLength: 80
$ref: "#/components/schemas/MoodTagLayoutPutRequest"
responses:
"200":
description: Merged + resolved layout.
description: Merged + resolved layout with the fresh token.
content:
application/json:
schema:
$ref: "#/components/schemas/MoodTagLayoutPutEnvelope"
"401": *a1
"409":
description: Mood-tag layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
the resource, re-apply the user's change against the fresh state, and resend with the new token.
`meta.errorCode` = `mood_tag_layout_conflict`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/settings/reminder-thresholds:
@@ -13054,6 +12985,101 @@ components:
UpdateScheduleRevisionRequest:
description: "Corrected schedule-era payload: replacement era bounds plus the daily dose times that were live during the era."
$ref: "#/components/schemas/CreateScheduleRevisionRequest"
PutCoachPrefsRequest:
type: object
properties:
tone:
default: warm
type: string
enum:
- warm
- neutral
- concise
verbosity:
default: default
type: string
enum:
- brief
- default
- detailed
excludeMetrics:
default: []
maxItems: 11
type: array
items:
type: string
enum:
- bp
- weight
- pulse
- mood
- compliance
- hrv
- sleep
- resting_hr
- steps
- medications
- anthropometrics
showEvidenceByDefault:
default: false
type: boolean
defaultWindow:
default: allTime
type: string
enum:
- last7days
- last30days
- last90days
- allTime
dataClusters:
maxItems: 10
type: array
items:
type: string
enum:
- cardio
- body
- activity
- workouts
- sleep
- mood
- glucose
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
baseUpdatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
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.
DisableCoachFlag:
type: object
properties:
@@ -13107,12 +13133,21 @@ components:
type: boolean
nutrients:
type: boolean
baseUpdatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
additionalProperties: false
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.
ProfileUpdateRequest:
type: object
properties:
@@ -13257,10 +13292,20 @@ components:
properties:
clientManaged:
type: boolean
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.
baseUpdatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
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.
`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."
FencedCoachChatRequest:
type: object
properties:
@@ -13625,6 +13670,40 @@ components:
- mood
- moodLoggedAt
description: One bulk mood entry (UPSERT keyed by `externalId`).
MoodTagLayoutPutRequest:
type: object
properties:
groupOrder:
maxItems: 50
type: array
items:
type: string
minLength: 1
maxLength: 80
placements:
type: object
propertyNames:
type: string
minLength: 1
maxLength: 80
additionalProperties:
type: array
items:
type: string
minLength: 1
maxLength: 80
baseUpdatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
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.
MeasurementReminderCreate:
type: object
properties:
@@ -21778,102 +21857,7 @@ components:
type: object
properties:
data:
type: object
properties:
tone:
default: warm
type: string
enum:
- warm
- neutral
- concise
verbosity:
default: default
type: string
enum:
- brief
- default
- detailed
excludeMetrics:
default: []
maxItems: 11
type: array
items:
type: string
enum:
- bp
- weight
- pulse
- mood
- compliance
- hrv
- sleep
- resting_hr
- steps
- medications
- anthropometrics
showEvidenceByDefault:
default: false
type: boolean
defaultWindow:
default: allTime
type: string
enum:
- last7days
- last30days
- last90days
- allTime
dataClusters:
maxItems: 10
type: array
items:
type: string
enum:
- cardio
- body
- activity
- workouts
- sleep
- mood
- glucose
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
required:
- enabled
- stopped
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
required:
- tone
- verbosity
- excludeMetrics
- showEvidenceByDefault
- defaultWindow
additionalProperties: false
$ref: "#/components/schemas/CoachPrefsResponse"
error:
type: "null"
meta:
@@ -21886,106 +21870,115 @@ components:
- data
- error
additionalProperties: false
CoachPrefsResponse:
type: object
properties:
tone:
default: warm
type: string
enum:
- warm
- neutral
- concise
verbosity:
default: default
type: string
enum:
- brief
- default
- detailed
excludeMetrics:
default: []
maxItems: 11
type: array
items:
type: string
enum:
- bp
- weight
- pulse
- mood
- compliance
- hrv
- sleep
- resting_hr
- steps
- medications
- anthropometrics
showEvidenceByDefault:
default: false
type: boolean
defaultWindow:
default: allTime
type: string
enum:
- last7days
- last30days
- last90days
- allTime
dataClusters:
maxItems: 10
type: array
items:
type: string
enum:
- cardio
- body
- activity
- workouts
- sleep
- mood
- glucose
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
required:
- enabled
- stopped
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
updatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- tone
- verbosity
- excludeMetrics
- showEvidenceByDefault
- defaultWindow
additionalProperties: false
PutCoachPrefsResponse:
type: object
properties:
data:
type: object
properties:
tone:
default: warm
type: string
enum:
- warm
- neutral
- concise
verbosity:
default: default
type: string
enum:
- brief
- default
- detailed
excludeMetrics:
default: []
maxItems: 11
type: array
items:
type: string
enum:
- bp
- weight
- pulse
- mood
- compliance
- hrv
- sleep
- resting_hr
- steps
- medications
- anthropometrics
showEvidenceByDefault:
default: false
type: boolean
defaultWindow:
default: allTime
type: string
enum:
- last7days
- last30days
- last90days
- allTime
dataClusters:
maxItems: 10
type: array
items:
type: string
enum:
- cardio
- body
- activity
- workouts
- sleep
- mood
- glucose
- medication
- mobility
- environment
reminderSuggestions:
type: object
properties:
enabled:
default: true
type: boolean
stopped:
default: false
type: boolean
dismissedCadences:
default: []
maxItems: 32
type: array
items:
type: string
maxLength: 64
lastSuggestedAt:
default: null
anyOf:
- type: string
maxLength: 40
- type: "null"
required:
- enabled
- stopped
- dismissedCadences
- lastSuggestedAt
additionalProperties: false
required:
- tone
- verbosity
- excludeMetrics
- showEvidenceByDefault
- defaultWindow
additionalProperties: false
$ref: "#/components/schemas/CoachPrefsResponse"
error:
type: "null"
meta:
@@ -22088,6 +22081,13 @@ components:
properties:
modules:
$ref: "#/components/schemas/ModuleMap"
updatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- modules
additionalProperties: false
@@ -24067,7 +24067,7 @@ components:
type: object
properties:
data:
$ref: "#/components/schemas/NotificationPrefs"
$ref: "#/components/schemas/NotificationPrefsResponse"
error:
type: "null"
meta:
@@ -24080,7 +24080,7 @@ components:
- data
- error
additionalProperties: false
NotificationPrefs:
NotificationPrefsResponse:
type: object
properties:
medication:
@@ -24164,19 +24164,24 @@ components:
- nudgeRoutine
- nudgeFrequency
additionalProperties: false
updatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- medication
- mood
- cycle
- coach
additionalProperties: false
description: Fully-resolved per-user notification preferences. Every key is present — a null / drifted persisted row
resolves to the documented defaults.
PatchNotificationPrefsResponse:
type: object
properties:
data:
$ref: "#/components/schemas/NotificationPrefs"
$ref: "#/components/schemas/NotificationPrefsResponse"
error:
type: "null"
meta:
@@ -28517,12 +28522,20 @@ components:
type: array
items:
type: string
updatedAt:
description: "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."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- groupOrder
- placements
additionalProperties: false
description: "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category
set, `placements` as stored."
set, `placements` as stored. `updatedAt` is the optimistic-concurrency token — echo it as `baseUpdatedAt` on the
next PUT."
MoodTagLayoutPutEnvelope:
type: object
properties:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.21",
"version": "1.32.22",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.21";
/* @sw-version-fallback */ "v1.32.22";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,174 @@
/**
* v1.32.22 (M4) — `PUT /api/auth/me/coach-prefs` optimistic concurrency.
*
* A full-replace endpoint hydrated from a stale GET (two tabs / two devices)
* clobbers silently; the base token 409s that. The guarded write covers BOTH
* `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one
* conditional update, so a 409 leaves both untouched together. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/auth/me/coach-prefs", () => {
it("returns a SAVED user's prefs plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: { tone: "concise" },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("concise");
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("returns the PURE default with NO token for a never-saved user", async () => {
// Mirrors the insights-layout never-saved GET: nothing to guard yet, and
// the first save is the tokenless unconditional write (backward-compat).
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: null,
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("warm");
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
it("guards both columns in one conditional write and echoes the token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as {
where: { id: string; updatedAt: Date };
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect((call.where.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
// Both mirror columns ride the SAME guarded update.
expect(call.data.coachPrefsJson).toBeTruthy();
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => {
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("coach_prefs_conflict");
// Nothing written — the single guarded update matched no row.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+38 -6
View File
@@ -19,6 +19,11 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
coachPrefsSchema,
parseCoachPrefs,
@@ -32,9 +37,18 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
select: { coachPrefsJson: true, updatedAt: true },
});
return apiSuccess(parseCoachPrefs(row?.coachPrefsJson));
const prefs = parseCoachPrefs(row?.coachPrefsJson);
// v1.32.22 (M4) — a never-saved user gets the PURE default with NO token
// (mirrors the insights-layout never-saved GET). There is nothing to guard
// against yet, and the first save is just the tokenless unconditional write.
// A saved row carries `updatedAt` (additive sibling key; the prefs shape is a
// fixed key set, so it cannot collide) so the client can echo it.
if (row?.coachPrefsJson == null) {
return apiSuccess(prefs);
}
return apiSuccess({ ...prefs, updatedAt: row.updatedAt?.toISOString() });
});
export const PUT = apiHandler(async (req: Request) => {
@@ -46,7 +60,15 @@ export const PUT = apiHandler(async (req: Request) => {
throw new HttpError(422, "coach-prefs.body.invalid_json");
}
const parsed = coachPrefsSchema.safeParse(body ?? {});
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
// parse. This is a full-replace endpoint, so the classic two-tab / two-device
// stale-replace clobbers silently: the form was hydrated from a GET minutes
// ago. The token closes that window.
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = coachPrefsSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.coach-prefs.put.invalid" },
@@ -63,13 +85,23 @@ export const PUT = apiHandler(async (req: Request) => {
// Coach snapshot share a single source of truth. Keeps the user's
// privacy contract symmetric across the two surfaces without
// requiring a separate Insights settings sheet.
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M4) — the guarded write covers BOTH columns in one conditional
// update so the mirror-column contract (v1.4.36 W3 T3) stays unsplittable: a
// 409 leaves `coachPrefsJson` AND `insightsExcludeMetrics` untouched together.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: {
coachPrefsJson: parsed.data,
insightsExcludeMetrics: parsed.data.excludeMetrics,
},
conflict: {
action: "auth.me.coach-prefs.conflict",
errorCode: "coach_prefs_conflict",
message: "Coach preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "auth.me.coach-prefs.put" },
@@ -83,5 +115,5 @@ export const PUT = apiHandler(async (req: Request) => {
clusterCount: parsed.data.dataClusters?.length ?? null,
},
});
return apiSuccess(parsed.data);
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
});
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
cycleProfile: {
findUnique: vi.fn(),
@@ -198,6 +199,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { mood: false } },
});
expect(auditLog).toHaveBeenCalledWith(
@@ -220,6 +222,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: false, sleep: false } },
});
});
@@ -234,6 +237,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: true } },
});
});
@@ -310,7 +314,100 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
});
});
/**
* v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE
* the `.strict()` schema parse, so it never trips strict validation; a stale
* token 409s and writes nothing; a tokenless PATCH keeps the unconditional
* write.
*/
describe("PATCH /api/auth/me/modules — optimistic concurrency", () => {
it("guards on the base token and skips the unconditional write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("modules_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("strips the token pre-parse: a token ALONE does not trip the strict schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
// 200, not 422 — `baseUpdatedAt` was removed before the strict parse.
expect(res.status).toBe(200);
});
it("still 422s an unknown key even alongside a valid token (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+32 -5
View File
@@ -36,6 +36,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
@@ -49,7 +54,13 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me.modules.get" } });
const modules = await resolveModuleMap(user.id);
return apiSuccess({ modules });
// v1.32.22 (M3) — surface the optimistic-concurrency token. `resolveModuleMap`
// returns no row metadata, so read `updatedAt` on its own.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { updatedAt: true },
});
return apiSuccess({ modules, updatedAt: row?.updatedAt?.toISOString() });
});
export const PATCH = apiHandler(async (req: Request) => {
@@ -75,7 +86,14 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "modules.body.invalid_json");
}
const parsed = modulePrefsPatchSchema.safeParse(body ?? {});
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
// parse: `modulePrefsPatchSchema` is `.strict()` and must stay strict, so
// the transport token can never reach it (it would 422 as an unknown key).
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = modulePrefsPatchSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.modules.patch.invalid_shape" },
@@ -105,10 +123,19 @@ export const PATCH = apiHandler(async (req: Request) => {
changed.push(key);
}
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M3) — guard the write on the client's base token; a stale base
// 409s and writes nothing. Tokenless requests keep the unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { modulePreferencesJson: merged },
conflict: {
action: "auth.me.modules.conflict",
errorCode: "modules_conflict",
message: "Module preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
const modules = await resolveModuleMap(user.id);
@@ -123,5 +150,5 @@ export const PATCH = apiHandler(async (req: Request) => {
meta: { changed },
});
return apiSuccess({ modules });
return apiSuccess({ modules, updatedAt: guarded.updatedAt });
});
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
@@ -234,10 +235,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
measurementReminder: {
clientManaged: false,
},
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -365,6 +369,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -427,10 +432,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
measurementReminder: {
clientManaged: false,
},
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -476,6 +484,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -531,6 +540,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: expect.objectContaining({
medication: {
@@ -588,3 +598,113 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 (M1) — optimistic concurrency (issue #581 family). A PATCH carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token. The headline case: a concurrent web PATCH of a
* DIFFERENT category can no longer silently revert the iOS `clientManaged`
* flag (the double-reminder class). A tokenless PATCH keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PATCH /api/auth/me/notification-prefs — optimistic concurrency", () => {
function mkTokenedPatch(body: unknown, baseUpdatedAt?: string): Request {
return mkPatch(
baseUpdatedAt !== undefined
? { ...(body as object), baseUpdatedAt }
: body,
);
}
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ notificationPrefs: null } as never)
// post-write fresh-token read
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch(
{ medication: { clientManaged: true } },
"2026-07-24T10:00:00.000Z",
),
);
expect(res.status).toBe(200);
// Conditional write, no unconditional fallback.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("regression: a stale-token web write does NOT revert the iOS clientManaged flag", async () => {
// iOS set medication.clientManaged = true (row now carries it). A web card
// PATCH based on a pre-iOS read of a DIFFERENT category arrives with a stale
// token: it must 409 and write NOTHING, so clientManaged survives.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ mood: { reminderHour: 9 } }, "2026-07-24T08:00:00.000Z"),
);
expect(res.status).toBe(409);
const env = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(env.data).toBeNull();
expect(env.meta?.errorCode).toBe("notification_prefs_conflict");
// The guarded write matched no row; nothing was written, so the persisted
// clientManaged flag is untouched.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("compat: the SAME second write TOKENLESS still lands (old clients keep last-write-wins)", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. This pins that
// the clobber for old clients is a documented choice, not an accident.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ medication: { clientManaged: true } }, "not-a-date"),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -34,6 +34,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
notificationPrefsSchema,
parseNotificationPrefs,
@@ -51,9 +56,14 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
// v1.32.22 (M1) — `updatedAt` rides the response as the optimistic-
// concurrency token so a client can echo it on its next PATCH.
select: { notificationPrefs: true, updatedAt: true },
});
return apiSuccess({
...parseNotificationPrefs(row?.notificationPrefs ?? null),
updatedAt: row?.updatedAt?.toISOString(),
});
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
});
export const PATCH = apiHandler(async (req: Request) => {
@@ -79,7 +89,17 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "notification-prefs.body.invalid_json");
}
const parsed = notificationPrefsSchema.safeParse(body ?? {});
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so it never pollutes `changedCategories`
// (`Object.keys(parsed.data)`) or the schema. The race that matters: iOS
// sets `medication.clientManaged = true`, a web card PATCH based on a
// pre-iOS read deep-merges the whole blob WITHOUT it, silently reverting the
// flag (the double-reminder class). With the token, that stale write 409s.
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = notificationPrefsSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
@@ -103,10 +123,21 @@ export const PATCH = apiHandler(async (req: Request) => {
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M1) — guard the deep-merge write on the client's base token. A
// stale base 409s and writes NOTHING, so a concurrent web PATCH can't revert
// a category (e.g. `medication.clientManaged`) another writer just set from a
// fresher read. A tokenless request keeps the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { notificationPrefs: toJson(merged) },
conflict: {
action: "user.notification_prefs.conflict",
errorCode: "notification_prefs_conflict",
message: "Notification preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
// Capture which categories the PATCH actually touched so the audit
// trail records the user's intent, not just the full resolved row.
@@ -130,5 +161,5 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
return apiSuccess(merged);
return apiSuccess({ ...merged, updatedAt: guarded.updatedAt });
});
@@ -9,16 +9,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
// v1.32.22 (M5) — the PATCH / DELETE now run inside `prisma.$transaction`
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as the tx, so the existing model mocks apply
// inside the transaction and `$queryRaw` (the advisory lock) is observable.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -52,11 +59,16 @@ vi.mock("next/headers", () => ({
})),
}));
import { PATCH } from "../route";
import { PATCH, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const prismaTx = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>;
$transaction: ReturnType<typeof vi.fn>;
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
@@ -76,6 +88,11 @@ const ROUTE_CTX = {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it so
// the callback still runs with the base client as the tx.
prismaTx.$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
@@ -244,3 +261,95 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling",
expect("doseStrength" in arg.data).toBe(false);
});
});
/**
* v1.32.22 (M5) — the stock-correction PATCH and the DELETE route their
* read-compose-write through the SAME per-medication advisory lock that
* `consumeForIntake` takes, so an absolute stock write can no longer clobber a
* concurrent dose decrement. These pin the routing: the write runs inside a
* transaction that takes the advisory lock BEFORE it reads + writes.
*/
describe("PATCH / DELETE inventory — advisory-lock serialisation (M5)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "i1",
} as never);
});
it("takes the medication advisory lock inside a transaction before the write", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
// One transaction, one advisory-lock query.
expect(prismaTx.$transaction).toHaveBeenCalledTimes(1);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
// The lock SQL is the canonical `pg_advisory_xact_lock` — proves the route
// shares the intake hook's key rather than re-deriving one.
const sql = prismaTx.$queryRaw.mock.calls[0]?.[0] as {
strings?: string[];
};
expect((sql.strings ?? []).join("")).toContain("pg_advisory_xact_lock");
// Lock is acquired BEFORE the row update (serialises against a concurrent
// consume decrement).
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const updateOrder = vi.mocked(prisma.medicationInventoryItem.update).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(updateOrder);
});
it("re-reads the row INSIDE the locked transaction", async () => {
await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
// The ownership re-read runs after the lock is taken.
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const readOrder = vi.mocked(prisma.medicationInventoryItem.findUnique).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(readOrder);
});
it("DELETE takes the same lock before deleting", async () => {
const res = await DELETE(patchReq({}), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const deleteOrder = vi.mocked(prisma.medicationInventoryItem.delete).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(deleteOrder);
});
it("still 404s an item owned by another user (checked inside the lock)", async () => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "someone-else",
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
firstUseAt: null,
notes: null,
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(404);
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
});
});
@@ -36,33 +36,17 @@ import {
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
import { lockMedicationInventory } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
async function loadOwnedItem(
medicationId: string,
itemId: string,
userId: string,
) {
const item = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
return null;
}
return item;
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -84,67 +68,6 @@ export const PATCH = apiHandler(
notes,
} = parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
// v1.16.12 — Decimal column; work in JS numbers locally (the
// arithmetic below stays well within double precision) and let
// Prisma re-widen on write.
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (unitsRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// The wire field counts UNITS. Clamp to the item's capacity; the
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
nextUnitsRemaining = Math.min(
unitsRemaining,
Number(existing.unitsTotal),
);
}
if (markAsUsedUp === true) {
nextUnitsRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
// Only touch the note columns when the caller supplied a value; an absent
// `notes` leaves the existing ciphertext untouched. When present, encrypt
// at rest and null the plaintext column.
@@ -163,26 +86,114 @@ export const PATCH = apiHandler(
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
// v1.32.22 (M5) — the stock correction writes an ABSOLUTE `unitsRemaining`
// composed from a prior read. `consumeForIntake` decrements the same rows
// under a per-medication advisory lock; without taking that SAME lock the
// correction's read-compose-write could clobber a concurrent decrement (or
// vice versa). Route the whole read-compose-write through one transaction
// that takes the lock first and re-reads INSIDE it, so the correction
// serializes against intake consumption instead of racing it.
const outcome = await prisma.$transaction(async (tx) => {
await lockMedicationInventory(tx, id);
const existing = await tx.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (
!existing ||
existing.userId !== user.id ||
existing.medicationId !== id
) {
return { notFound: true as const };
}
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
// v1.16.12 — Decimal column; work in JS numbers locally (the
// arithmetic below stays well within double precision) and let
// Prisma re-widen on write.
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (unitsRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// The wire field counts UNITS. Clamp to the item's capacity; the
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
nextUnitsRemaining = Math.min(
unitsRemaining,
Number(existing.unitsTotal),
);
}
if (markAsUsedUp === true) {
nextUnitsRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
const updated = await tx.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
});
return { prevState: existing.state, updated };
});
if ("notFound" in outcome) return apiError("Inventory item not found", 404);
const { prevState, updated } = outcome;
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
prevState: existing.state,
prevState,
nextState: updated.state,
},
});
@@ -193,7 +204,7 @@ export const PATCH = apiHandler(
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id, prev: existing.state, next: updated.state },
meta: { medication_id: id, prev: prevState, next: updated.state },
});
// A stock correction / first-use / used-up flip changes the
@@ -211,11 +222,24 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
// v1.32.22 (M5) — take the same per-medication advisory lock the intake
// consumption hook takes, so a delete that interleaves a `consumeForIntake`
// serializes against it: the consume either fully applies before the row
// vanishes or blocks until the delete commits and then finds nothing to
// decrement, instead of half-applying against a row deleted mid-flight.
const existing = await prisma.$transaction(async (tx) => {
await lockMedicationInventory(tx, id);
const item = await tx.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== user.id || item.medicationId !== id) {
return null;
}
await tx.medicationInventoryItem.delete({ where: { id: itemId } });
return item;
});
if (!existing) return apiError("Inventory item not found", 404);
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -1,8 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
// v1.32.22 (M5) — the [itemId] PATCH / DELETE now run inside a transaction
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as tx, and `$queryRaw` stands in for the
// advisory-lock query.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
@@ -11,8 +15,11 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
},
}));
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -72,6 +79,12 @@ function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it.
(
prisma as unknown as { $transaction: ReturnType<typeof vi.fn> }
).$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
user: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
moodTagCategory: { findMany: vi.fn() },
}));
@@ -103,6 +103,8 @@ describe("PUT /api/mood/tags/layout", () => {
expect(res.status).toBe(200);
expect(db.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
// v1.32.22 (M2) — tokenless write echoes the fresh `updatedAt` token.
select: { updatedAt: true },
data: {
moodTagLayoutJson: {
groupOrder: ["custom", "feelings"],
@@ -0,0 +1,173 @@
/**
* v1.32.22 (M2) — `PUT /api/mood/tags/layout` optimistic concurrency.
*
* The two manage cards write DIFFERENT fields of the same blob (group order
* vs placements) from the same page; a stale merge would resurrect the other
* card's overwritten field. The base token 409s that instead. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
moodTagCategory: {
findMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood/tags/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
});
describe("GET /api/mood/tags/layout", () => {
it("returns the layout plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a", "b"], placements: {} },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { groupOrder: string[]; updatedAt?: string };
};
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("omits updatedAt for a fresh user (null row)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as {
data: { updatedAt?: string };
};
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/mood/tags/layout — optimistic concurrency", () => {
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ moodTagLayoutJson: null } as never)
// post-write fresh token
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({
groupOrder: ["a", "b"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a"], placements: {} },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({
placements: { a: ["t1"] },
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("mood_tag_layout_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a", "b"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+36 -6
View File
@@ -3,6 +3,11 @@ import { NextRequest } from "next/server";
import { prisma, toJson } from "@/lib/db";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import { annotate } from "@/lib/logging/context";
import {
moodTagLayoutSchema,
@@ -33,9 +38,11 @@ export const dynamic = "force-dynamic";
async function resolveLayoutResponse(
userId: string,
layout: MoodTagLayout,
updatedAt?: string,
): Promise<{
groupOrder: string[];
placements: Record<string, string[]>;
updatedAt?: string;
}> {
const categories = await prisma.moodTagCategory.findMany({
where: { isActive: true, OR: [{ userId: null }, { userId }] },
@@ -48,6 +55,9 @@ async function resolveLayoutResponse(
layout.groupOrder,
),
placements: layout.placements ?? {},
// v1.32.22 (M2) — optimistic-concurrency token; omitted (dropped from
// JSON) when absent so a fresh user's response shape is unchanged.
...(updatedAt !== undefined ? { updatedAt } : {}),
};
}
@@ -55,10 +65,12 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { moodTagLayoutJson: true },
select: { moodTagLayoutJson: true, updatedAt: true },
});
const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson);
return apiSuccess(await resolveLayoutResponse(user.id, stored));
return apiSuccess(
await resolveLayoutResponse(user.id, stored, row?.updatedAt?.toISOString()),
);
});
export const PUT = apiHandler(async (request: NextRequest) => {
@@ -68,7 +80,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = moodTagLayoutSchema.safeParse(rawJsonBody);
// v1.32.22 (M2) — split the optimistic-concurrency base token off before the
// Zod parse. The two cards on this page write DIFFERENT fields of the same
// blob (group order vs placements); a stale merge would resurrect the other
// card's overwritten field, which the token now 409s instead.
const taken = takeBaseToken(rawJsonBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = moodTagLayoutSchema.safeParse(taken.rest);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe
@@ -88,10 +109,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
: {}),
});
await prisma.user.update({
where: { id: user.id },
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { moodTagLayoutJson: toJson(merged) },
conflict: {
action: "mood.tag.layout.conflict",
errorCode: "mood_tag_layout_conflict",
message: "Mood-tag layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "mood.tag.layout.update" },
@@ -101,5 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
},
});
return apiSuccess(await resolveLayoutResponse(user.id, merged));
return apiSuccess(
await resolveLayoutResponse(user.id, merged, guarded.updatedAt),
);
});
+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(),
@@ -38,7 +38,14 @@ const mutateSpy = vi.fn();
const invalidateSpy = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
useQueryClient: () => ({
invalidateQueries: invalidateSpy,
// v1.32.22 (M3) — the writer reads/seeds the optimistic-concurrency token
// via the query cache; no token seeded → tokenless (unconditional) write.
getQueryData: () => undefined,
setQueryData: vi.fn(),
removeQueries: vi.fn(),
}),
useMutation: (config: MutationConfig) => {
lastMutation = config;
return { mutate: mutateSpy, isPending: false };
+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);
},
});
}
@@ -29,6 +29,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn() },
},
@@ -29,6 +29,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn() },
},
@@ -46,6 +46,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn() },
},
@@ -36,6 +36,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: {
deleteMany: (...a: unknown[]) => auditDeleteMany(...a),
@@ -27,6 +27,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn() },
},
@@ -0,0 +1,221 @@
/**
* v1.32.22 (M6) — a privacy-mode flip that lands WHILE a comprehensive
* generation is in flight must not be overwritten by that generation.
*
* The generation reads `insightsPrivacyMode` at snapshot time and builds its
* payload under that scope. `PUT /api/insights/settings` flips the mode and
* nulls the cache. If the in-flight generation then committed unconditionally
* it would stamp an old-scope briefing over the null the flip wrote. Every
* cache commit is now guarded on the mode still being what the generation
* read: a mismatch (simulated here by the guarded `updateMany` matching zero
* rows) returns `{ status: "skipped", reason: "privacy-mode-changed" }` and
* writes nothing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const findUnique = vi.fn();
const userUpdate = vi.fn();
const userUpdateMany = vi.fn();
const resolveProviderChain = vi.fn();
const resolveProvider = vi.fn();
const runRawCompletionWithFallback = vi.fn();
const extractFeatures = vi.fn();
const invalidateUserInsights = vi.fn();
const annotate = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(async () => [{ total_tokens: 0 }]),
$executeRaw: vi.fn(async () => 0),
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// Controllable per test: `{ count: 0 }` emulates a privacy flip having
// advanced the guarded column since the generation read it.
updateMany: (...a: unknown[]) => userUpdateMany(...a),
},
auditLog: { deleteMany: vi.fn() },
},
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: (...a: unknown[]) =>
runRawCompletionWithFallback(...a),
}));
vi.mock("@/lib/insights/features", () => ({
FeaturesPayloadTooLargeError: class extends Error {
sizeBytes = 0;
},
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
BRIEFING_FEATURE_WINDOW_DAYS: 400,
}));
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null),
buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""),
}));
vi.mock("@/lib/insights/glp1-plateau", () => ({
detectGlp1Plateau: vi.fn(async () => null),
buildGlp1PlateauPrompt: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
buildAboutMeInsightBlock: vi.fn(() => ""),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: (...a: unknown[]) => invalidateUserInsights(...a),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: (...a: unknown[]) => annotate(...a) };
});
import { generateComprehensiveInsight } from "../comprehensive-generate";
import { hashInsightSnapshot } from "../snapshot-hash";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } };
const FEATURES_HASH = hashInsightSnapshot({
features: compactSections(FEATURES as unknown as Record<string, unknown>),
aboutMe: null,
comparisonBaseline: "none",
generationLocale: "de",
});
const todayKey = new Date().toISOString().slice(0, 10);
const CACHED_WITH_BRIEFING = JSON.stringify({
summary: "stable summary",
dailyBriefing: {
paragraph: "Yesterday's phrasing.",
keyFindings: [{ headline: "BP steady", tone: "good" }],
},
});
function annotatedPrivacyChange(): boolean {
return annotate.mock.calls.some(
(c) =>
(c[0] as { action?: { name?: string } })?.action?.name ===
"insights.generate.privacy_mode_changed",
);
}
beforeEach(() => {
vi.clearAllMocks();
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: {} },
]);
resolveProvider.mockResolvedValue({ type: "none" });
extractFeatures.mockResolvedValue(FEATURES);
userUpdate.mockResolvedValue({});
});
describe("generateComprehensiveInsight — privacy-mode flip guard (M6)", () => {
it("main commit: refuses to write when the mode flipped, returns privacy-mode-changed", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
insightsExcludeMetrics: [],
insightsSnapshotHash: "0".repeat(64), // changed → full generation
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify({ dailyBriefing: { p: "new" } }),
tokensUsed: 10,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
// The flip landed: the guarded commit matches zero rows.
userUpdateMany.mockResolvedValue({ count: 0 });
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({
status: "skipped",
reason: "privacy-mode-changed",
});
// The generation reached the commit (provider was called) …
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
// … the commit was the guarded update, keyed on the mode read …
expect(userUpdateMany).toHaveBeenCalledTimes(1);
const where = userUpdateMany.mock.calls[0][0].where as {
insightsPrivacyMode: string;
};
expect(where.insightsPrivacyMode).toBe("aggregated");
// … and it did NOT fall back to an unconditional write.
expect(userUpdate).not.toHaveBeenCalled();
expect(invalidateUserInsights).not.toHaveBeenCalled();
expect(annotatedPrivacyChange()).toBe(true);
});
it("main commit: control — with the mode unchanged the same generation commits", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
insightsExcludeMetrics: [],
insightsSnapshotHash: "0".repeat(64),
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify({ dailyBriefing: { p: "new" } }),
tokensUsed: 10,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
userUpdateMany.mockResolvedValue({ count: 1 });
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
expect(userUpdateMany).toHaveBeenCalledTimes(1);
expect(invalidateUserInsights).toHaveBeenCalledWith("u1");
expect(annotatedPrivacyChange()).toBe(false);
});
it("reroll arm: a flip refuses the paragraph re-roll write too", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: CACHED_WITH_BRIEFING,
insightsExcludeMetrics: [],
insightsSnapshotHash: FEATURES_HASH, // unchanged → reroll path
insightsBriefingRerollDate: null,
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify({
dailyBriefing: { paragraph: "Today's fresh phrasing." },
}),
tokensUsed: 50,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
userUpdateMany.mockResolvedValue({ count: 0 });
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({
status: "skipped",
reason: "privacy-mode-changed",
});
expect(userUpdateMany).toHaveBeenCalledTimes(1);
expect(invalidateUserInsights).not.toHaveBeenCalled();
expect(annotatedPrivacyChange()).toBe(true);
// Not marked as a used-today reroll, since nothing committed.
expect(todayKey).toBe(new Date().toISOString().slice(0, 10));
});
});
@@ -31,6 +31,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn(), create: vi.fn() },
},
@@ -33,6 +33,13 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: (...a: unknown[]) => findUnique(...a),
update: (...a: unknown[]) => userUpdate(...a),
// v1.32.22 (M6) — the cache commit is now a privacy-mode-guarded
// updateMany; record it on the same spy but return a matched count so the
// guard proceeds (mode unchanged in these tests).
updateMany: (...a: unknown[]) => {
userUpdate(...a);
return Promise.resolve({ count: 1 });
},
},
auditLog: { deleteMany: vi.fn() },
},
+78 -25
View File
@@ -70,7 +70,7 @@ import {
resolveDashboardLayout,
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
@@ -132,7 +132,19 @@ export type GenerateOutcome =
* than with `failed` on purpose: there is no upstream fault to retry
* against, so the caller must not enqueue the provider-failure retry.
*/
| { status: "skipped"; reason: "no-provider" | "no-consent" | "budget" }
/**
* v1.32.22 (M6) — a privacy-mode flip landed WHILE this generation was in
* flight. The generation read the old scope at snapshot time; committing its
* output would re-populate the cache under a scope the user just changed
* away from. Each cache write is guarded on `insightsPrivacyMode` still being
* what it was read as; a zero-row match returns this skip and writes nothing.
* The flip already nulled the cache, so the next scheduled/explicit run
* regenerates under the new scope — no warm-on-mount.
*/
| {
status: "skipped";
reason: "no-provider" | "no-consent" | "budget" | "privacy-mode-changed";
}
| { status: "failed"; reason: string };
/** Higher, seedless temperature for the daily-briefing phrasing re-roll. */
@@ -492,6 +504,36 @@ function failBeforeProvider(
return { status: "failed", reason };
}
/**
* v1.32.22 (M6) — commit a cache write ONLY while the privacy mode is still
* what this generation read. Returns `true` when the guarded update matched
* the row, `false` when a concurrent `PUT /api/insights/settings` flip
* advanced `insightsPrivacyMode` since the read — in which case the caller
* must abandon the commit (the flip already nulled the cache). Guards on the
* raw column value including `null`, so a null → "aggregated" explicit write
* also invalidates an in-flight generation correctly.
*/
async function commitInsightUnderMode(
userId: string,
modeAtRead: string,
data: Prisma.UserUpdateInput,
): Promise<boolean> {
const res = await prisma.user.updateMany({
where: { id: userId, insightsPrivacyMode: modeAtRead },
data,
});
return res.count > 0;
}
/** v1.32.22 (M6) — the shared skip outcome when a privacy flip beat the commit. */
function privacyModeChangedSkip(locale: SupportedLocale): GenerateOutcome {
annotate({
action: { name: "insights.generate.privacy_mode_changed" },
meta: { locale },
});
return { status: "skipped", reason: "privacy-mode-changed" };
}
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: SupportedLocale;
@@ -592,6 +634,13 @@ export async function generateComprehensiveInsight(
}
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
// v1.32.22 (M6) — capture the privacy-mode column value read here (a
// non-nullable String defaulting to "aggregated"), so every cache commit
// below can guard on it. A `PUT /api/insights/settings` flip that lands
// between this read and a commit advances the column ("aggregated" ⇄ "raw");
// the guarded write then matches zero rows and the generation is skipped
// rather than stamping an old-scope payload over the cache the flip nulled.
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
// v1.18.11 P1 — bound the briefing's bulk feature read to a recent window
// (all-time extremes are sourced separately, see features.ts). The downgrade
// ladder below stays as the safety net for the rare oversize payload.
@@ -788,14 +837,15 @@ export async function generateComprehensiveInsight(
effectiveTimeoutMs,
});
if (rerolled) {
await prisma.user.update({
where: { id: userId },
data: {
insightsCachedAt: new Date(),
insightsCachedText: rerolled.text,
insightsBriefingRerollDate: todayKey,
},
// v1.32.22 (M6) — the reroll rewrites `insightsCachedText` derived
// from the OLD-scope cache; refuse it if a privacy flip landed since
// the read.
const committed = await commitInsightUnderMode(userId, modeAtRead, {
insightsCachedAt: new Date(),
insightsCachedText: rerolled.text,
insightsBriefingRerollDate: todayKey,
});
if (!committed) return privacyModeChangedSkip(locale);
invalidateUserInsights(userId);
annotate({
action: { name: "insights.generate.briefing_rerolled" },
@@ -805,23 +855,24 @@ export async function generateComprehensiveInsight(
}
// A re-roll miss still stamps the day so a persistently failing
// provider cannot be re-driven on every page-open; the next day retries.
await prisma.user.update({
where: { id: userId },
data: {
insightsCachedAt: new Date(),
insightsBriefingRerollDate: todayKey,
},
// v1.32.22 (M6) — even a timestamp-only stamp would resurrect a "fresh
// insight" claim over a cache a privacy flip deliberately nulled, so it
// is guarded too.
const stamped = await commitInsightUnderMode(userId, modeAtRead, {
insightsCachedAt: new Date(),
insightsBriefingRerollDate: todayKey,
});
if (!stamped) return privacyModeChangedSkip(locale);
annotate({
action: { name: "insights.generate.skipped_unchanged" },
meta: { locale, rerollAttempted: true },
});
return { status: "unchanged" };
}
await prisma.user.update({
where: { id: userId },
data: { insightsCachedAt: new Date() },
const stamped = await commitInsightUnderMode(userId, modeAtRead, {
insightsCachedAt: new Date(),
});
if (!stamped) return privacyModeChangedSkip(locale);
annotate({
action: { name: "insights.generate.skipped_unchanged" },
meta: { locale },
@@ -1145,14 +1196,16 @@ export async function generateComprehensiveInsight(
return failBeforeProvider(userId, locale, "aborted");
}
await prisma.user.update({
where: { id: userId },
data: {
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(insights),
insightsSnapshotHash: snapshotHash,
},
// v1.32.22 (M6) — the payload was built under the scope read at the top of
// this function. If a privacy flip landed since, committing it would stamp an
// old-scope briefing over the null the flip wrote — guard the write on the
// mode being unchanged and skip when it advanced.
const committed = await commitInsightUnderMode(userId, modeAtRead, {
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(insights),
insightsSnapshotHash: snapshotHash,
});
if (!committed) return privacyModeChangedSkip(locale);
// v1.16.8 — no blanket per-status eviction here any more. The cards
// track their own data through the ingest invalidator and their own
// content-hash gates; a fresh comprehensive briefing does not change
+10 -1
View File
@@ -76,7 +76,16 @@ async function runAtomically<T>(
return fn(client);
}
async function lockMedicationInventory(
/**
* Serialize every write against one medication's inventory on a
* transaction-scoped Postgres advisory lock. Intake consumption already
* takes it; the stock-correction PATCH / DELETE route it through the SAME
* key (v1.32.22, M5) so an absolute stock write can no longer race a
* concurrent dose decrement and clobber it. Exported because the key must
* be shared verbatim — a re-derived hash elsewhere would be a different
* lock, i.e. no lock at all.
*/
export async function lockMedicationInventory(
tx: TxClient,
medicationId: string,
): Promise<void> {
+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
+13 -6
View File
@@ -104,23 +104,30 @@ describe("PUT /api/auth/me/coach-prefs", () => {
}),
);
expect(putRes.status).toBe(200);
const putEnv = (await putRes.json()) as { data: typeof expected };
expect(putEnv.data).toEqual(expected);
// v1.32.22 (M4) — the write echoes the fresh optimistic-concurrency
// `updatedAt` token alongside the canonical prefs.
const putEnv = (await putRes.json()) as {
data: typeof expected & { updatedAt?: string };
};
expect(putEnv.data).toEqual({ ...expected, updatedAt: expect.any(String) });
// DB row reflects the canonical defaulted form.
// DB row reflects the canonical defaulted form. `coachPrefsJson` must NOT
// carry `updatedAt` — that token is the separate `User.updatedAt` column.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
});
expect(row?.coachPrefsJson).toEqual(expected);
// GET reads the saved values back.
// GET reads the saved values back, now carrying the token (saved row).
const getRes = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/coach-prefs"),
);
expect(getRes.status).toBe(200);
const getEnv = (await getRes.json()) as { data: typeof expected };
expect(getEnv.data).toEqual(expected);
const getEnv = (await getRes.json()) as {
data: typeof expected & { updatedAt?: string };
};
expect(getEnv.data).toEqual({ ...expected, updatedAt: expect.any(String) });
});
it("rejects unknown tone values with 422", async () => {