mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ec39c2be | ||
|
|
43880e8cca | ||
|
|
bb631de7c8 | ||
|
|
e47200505e | ||
|
|
9a69e4e557 | ||
|
|
8be7458c7a | ||
|
|
cf755d32af | ||
|
|
574367737f | ||
|
|
e65390792d | ||
|
|
f9afa794e9 | ||
|
|
3f33a30415 | ||
|
|
8849f2cecd | ||
|
|
5c581e9e58 | ||
|
|
572f5176bd | ||
|
|
ab03bc96f1 | ||
|
|
df4425babf | ||
|
|
0317942f1c | ||
|
|
f251ae13f8 | ||
|
|
0baffe04cd | ||
|
|
1d3963bebf | ||
|
|
15b8df11bc | ||
|
|
5c36a9a776 | ||
|
|
115c3e3dcd | ||
|
|
da8dfe4256 | ||
|
|
25b7ada8bf | ||
|
|
6a0bec7553 | ||
|
|
490963dfd8 | ||
|
|
0ee5081766 | ||
|
|
1a08083436 | ||
|
|
1e72d0aea0 | ||
|
|
ed260f1ed8 | ||
|
|
cd344047dd | ||
|
|
69359a609a | ||
|
|
872fe047e3 | ||
|
|
73594d35f7 | ||
|
|
aa850acab8 | ||
|
|
8bb0719c3a | ||
|
|
aeee3d657d | ||
|
|
5ad66af565 | ||
|
|
0b828e2c5a | ||
|
|
f88546dc2b | ||
|
|
93c0d616bb | ||
|
|
4f95afa33c | ||
|
|
1fca6c1d25 | ||
|
|
6cf9ad1273 | ||
|
|
0b3280c604 | ||
|
|
d2d79b70a2 | ||
|
|
dece1f89d9 | ||
|
|
6333d4d989 | ||
|
|
9c95e713d2 | ||
|
|
25393d93fe | ||
|
|
7b765c7659 | ||
|
|
c1bb997de7 | ||
|
|
6e46afc1fc | ||
|
|
9e6453c51d | ||
|
|
61a1592665 | ||
|
|
27b548ab13 | ||
|
|
d321391024 | ||
|
|
9d584806c7 | ||
|
|
a69d0493f8 |
No files matched your search
+402
@@ -2,6 +2,408 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.23] — 2026-07-24
|
||||
|
||||
Sync status now tells the truth for the connected wearables and CGM sources. A
|
||||
few paths could report a sync as healthy when it had actually failed, which
|
||||
reset the failure streak, hid the reconnect prompt, and let a dead grant get
|
||||
retried on every hourly tick. This release closes those gaps so a failed sync
|
||||
reads as failed, an interrupted history import is retried instead of marked
|
||||
complete, and a couple of sources start pulling data sooner.
|
||||
|
||||
- **A dead WHOOP token no longer reports a successful sync.** When the stored
|
||||
grant is revoked, the connection stays parked with its reconnect prompt and
|
||||
the last-synced time stays put, instead of flipping back to connected and
|
||||
hammering the revoked token every hour. This holds on both the manual sync and
|
||||
the hourly background path.
|
||||
- **An interrupted WHOOP history import is retried, not marked done.** A
|
||||
full-history backfill that stops partway is no longer stamped complete, so the
|
||||
gap is retried rather than frozen in place. To pull deep history after
|
||||
connecting, run a full sync from Settings. It walks back to 2020.
|
||||
- **Withings ECG now syncs on its own schedule.** A watch-only account with no
|
||||
scale previously never had its ECG recordings pulled. An hourly check now
|
||||
brings them in without waiting on a webhook.
|
||||
- **Withings activity and sleep write failures now surface.** A failed write no
|
||||
longer reads as a clean sync; it shows up on the integration's status instead
|
||||
of leaving a green state over missing data.
|
||||
- **Oura, Polar, and Nightscout sync errors that previously vanished now
|
||||
surface.** A write that fails partway through is recorded on the integration
|
||||
status rather than being silently dropped, and each error is classified and
|
||||
redacted by the source that owns it.
|
||||
- **Strava history import starts at connect time.** Deep history begins
|
||||
importing right after you connect instead of waiting for the next server
|
||||
restart.
|
||||
- **WHOOP cycle data uses a wider catch-up window.** A day's strain and energy
|
||||
figures that are still being revised are re-fetched for a week, matching the
|
||||
other WHOOP collections, so a late revision is not missed.
|
||||
|
||||
## [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
|
||||
protected against a concurrent edit overwriting a newer one. This is the same
|
||||
protection the dashboard layout already had. If you save a layout in one place
|
||||
while something else saved a change to the same record a moment earlier, your
|
||||
save now stops and reloads the current state instead of quietly reverting that
|
||||
other change. Older clients that do not send the new marker keep working exactly
|
||||
as before.
|
||||
|
||||
- **Insights layout saves no longer clobber a concurrent edit.** Saving the
|
||||
overview arrangement and saving the pill order are two separate surfaces that
|
||||
write the same record. If one lands while the other was based on an older
|
||||
read, the later save now reloads and asks you to redo it rather than reverting
|
||||
the first.
|
||||
- **Medications layout saves get the same guard.** The card/table view toggle
|
||||
and the manual order both write one record, so a stale toggle can no longer
|
||||
resurrect an order you just saved, and the reverse holds too.
|
||||
- **The coach about-me note is guarded as well.** A save based on an older read
|
||||
is stopped and reloaded instead of overwriting a newer version of the note.
|
||||
- **Older clients are unaffected.** A request that does not carry the new base
|
||||
marker keeps the previous save behavior unchanged, so nothing needs updating
|
||||
to keep working.
|
||||
- **The dashboard layout contract is now in the published API spec.** It was
|
||||
missing from the OpenAPI file even though it was the first surface to get this
|
||||
protection.
|
||||
|
||||
## [1.32.20] — 2026-07-24
|
||||
|
||||
The medication-adherence insight now passes through the same safety screen as
|
||||
every other insight card. It was the one card of eight that showed the model's
|
||||
text without running that screen first. It already held back a reply that came
|
||||
back broken or cut off, but a dose-change instruction or an invented figure
|
||||
could still reach it. It now runs the same check the other seven cards run, and
|
||||
anything that trips the check falls back to the plain, data-grounded line the
|
||||
card shows when no model is set up.
|
||||
|
||||
- **The medication-adherence card is screened like the rest.** A reply that
|
||||
reads as a dose change, or that cites a made-up risk figure, is held back and
|
||||
the card shows its plain fallback. A normal grounded summary still shows as
|
||||
before.
|
||||
- **Two background notification lines can no longer print a raw model reply.**
|
||||
The "just in" reaction line and the proactive coach nudge now catch a reply
|
||||
that arrived as structured data or got cut off partway, and use their fixed
|
||||
wording instead of printing it as is.
|
||||
- **Smaller cleanups in the same area.** A stray marker left behind by a
|
||||
cut-off coach reply is now stripped from the text, and an unreachable branch
|
||||
in the adherence figure was removed.
|
||||
|
||||
## [1.32.19] — 2026-07-24
|
||||
|
||||
After you logged a dose, the Today view could keep showing it as still due
|
||||
until you reloaded the page. The Today hero and the dashboard read from a cache
|
||||
that refreshes on a timer, and a write made from another screen marked that
|
||||
cache stale without actually re-reading it. The change reached the server, but
|
||||
the screen you came back to still showed the old state for up to two minutes,
|
||||
or until a hard refresh.
|
||||
|
||||
- **A dose you take now clears from Today at once.** This covers the "Take all
|
||||
due" button on the medications page, editing or deleting a logged dose, and
|
||||
importing intake history. The Today view and the dashboard now re-read the
|
||||
moment the write lands instead of waiting for the timer.
|
||||
- **A preventive-care reminder marked done leaves the Today rail immediately.**
|
||||
Marking one off, editing it, or deleting it updates the Today view straight
|
||||
away rather than after a refresh.
|
||||
- **A lab reading you add updates its cards right away.** Adding a reading
|
||||
refreshes that marker's assessment on the same page and the "what changed
|
||||
since your last panel" card, instead of leaving them on their earlier text.
|
||||
- **A briefing you regenerate shows up on Today without a reload.** The new
|
||||
briefing now reaches the Today hero and the dashboard as soon as it is ready.
|
||||
- **Saving a dashboard layout keeps its safeguard against a conflicting edit.**
|
||||
A visit to the home page could leave the layout editor without the token that
|
||||
guards a save, which quietly turned off the protection added in v1.32.16
|
||||
against two edits overwriting each other. The editor now always loads its own
|
||||
copy, so the guard stays on.
|
||||
|
||||
Thanks to @lutzkind, whose run of reports surfaced the stale Today view.
|
||||
|
||||
Refs #581
|
||||
|
||||
## [1.32.18] — 2026-07-24
|
||||
|
||||
When a Fitbit token was revoked or expired, the hourly sync noticed the failure
|
||||
but then stamped the same cycle as a success. The connection flipped back to
|
||||
connected, the sync position moved forward, and after you reconnected the
|
||||
catch-up window could no longer reach the days that were missed. So the data
|
||||
was skipped for good, and in the meantime the failing token was retried every
|
||||
hour.
|
||||
|
||||
- **A revoked Fitbit token no longer reports a successful sync.** A failed token
|
||||
refresh now fails the whole cycle. The connection stays parked until you
|
||||
reconnect, the sync position holds instead of advancing past real data, and
|
||||
the once-per-failure alert stops repeating every hour.
|
||||
- **Data after a reconnect is no longer skipped.** Because the position is held
|
||||
back on a failed cycle, the next successful sync still covers the days that
|
||||
were missed rather than starting after them.
|
||||
- **A write failure during a sync holds its place too.** If rows were fetched
|
||||
but could not be saved, the cycle keeps its position so the next run fetches
|
||||
them again instead of letting them fall outside the catch-up window.
|
||||
- **An interrupted history backfill no longer marks itself complete.** A partial
|
||||
first-time import is retried until a full pass finishes, so the one-year
|
||||
history walk fills in rather than freezing halfway.
|
||||
|
||||
If your Fitbit history already has a gap from before this release: reconnect
|
||||
Fitbit if prompted, then open Settings, Integrations, Fitbit and run a full
|
||||
sync. It re-walks the last year and fills the gap in place. Gaps older than a
|
||||
year are outside the backfill window and cannot be recovered.
|
||||
|
||||
## [1.32.17] — 2026-07-24
|
||||
|
||||
The mood correlations on the Insights page could line up a day off. Your mood
|
||||
is dated by the calendar day you logged it in your own timezone, but the
|
||||
metrics it was matched against were still grouped by the UTC day. If you live
|
||||
away from UTC, an evening reading slid onto the next UTC day, so a mood and a
|
||||
reading from the same evening were paired as if a day apart. A regression from
|
||||
v1.32.12, when the mood side moved to local days on its own.
|
||||
|
||||
- **Mood now pairs with metrics from the same local day.** The blood pressure,
|
||||
weight, and resting pulse series that mood is correlated against are grouped
|
||||
by the day you logged them in your own timezone, so both sides of the pairing
|
||||
share one calendar. An evening entry no longer drifts onto the next day.
|
||||
- **Non-UTC users get the correct same-day figures.** For anyone west of UTC an
|
||||
evening reading used to pair with the wrong day's mood, turning a same-day
|
||||
relationship into a one-day-apart one. Those correlations and their scatter
|
||||
charts now reflect the day the data was actually recorded. UTC users see no
|
||||
change.
|
||||
|
||||
## [1.32.16] — 2026-07-24
|
||||
|
||||
Changing which tiles and charts appear on your dashboard could get undone if
|
||||
you also toggled a hero score ring at about the same moment. Both actions save
|
||||
the whole layout, and the ring change was built from an older copy, so
|
||||
whichever reached the server last won. Thanks to @lutzkind for the clear
|
||||
report.
|
||||
|
||||
- **A saved layout now stays saved.** Your dashboard save is treated as the
|
||||
authoritative one. If another change arrives after it but was based on an
|
||||
older version of the layout, the server declines that write rather than
|
||||
letting it overwrite your work. The ring toggle also carries only its own
|
||||
fields, so it can no longer pull stale tiles along with it.
|
||||
- **The two saves take turns instead of racing.** While a layout save is in
|
||||
flight the score ring controls are held, and while a ring change is saving
|
||||
the Save button waits, so the two writes can no longer overlap on the server.
|
||||
- **A change underneath you reloads instead of getting lost.** In the rare case
|
||||
where another tab saved first, the layout refetches from the server and a
|
||||
small note asks you to make your change again against the fresh copy.
|
||||
|
||||
Refs #581.
|
||||
|
||||
## [1.32.15] — 2026-07-24
|
||||
|
||||
Your Polar training sessions now sync into HealthLog, alongside the recovery,
|
||||
sleep, and daily activity that Polar already brought in. Thanks to
|
||||
@StefanIndustries, whose report asked for this after the vitals sync landed.
|
||||
|
||||
- **Polar exercises arrive as workouts.** Every run, ride, swim, or gym session
|
||||
you upload to Polar Flow shows up in your workout history with its sport,
|
||||
start time, duration, distance, calories, and heart rate. The raw Polar sport
|
||||
label and training load ride along in the background so nothing is lost. The
|
||||
sync reads your last 30 days of uploads once an hour, so a session synced
|
||||
late from your watch is still caught.
|
||||
- **A Polar session does not double up with your other sources.** If the same
|
||||
workout also reaches HealthLog through Apple Health or another connection, the
|
||||
two are matched and only one is shown. A Polar watch counts as a direct
|
||||
heart-rate recording, so it ranks above a Strava re-upload of the same run but
|
||||
below your primary on-wrist wearables. You can change that order under
|
||||
Settings, Sources.
|
||||
- **The two Polar halves stay independent.** A hiccup fetching your training
|
||||
sessions no longer holds back the recovery and sleep sync, and the reverse
|
||||
holds too, so a problem on one side never masks the other.
|
||||
|
||||
## [1.32.14] — 2026-07-24
|
||||
|
||||
When the Coach removes a number it could not check against your data, the reply
|
||||
now reads clearly and tells you what happened.
|
||||
|
||||
- **A withheld figure reads as a quiet omission.** The Coach checks every number
|
||||
in its answer against the figures it was actually shown, and holds back any it
|
||||
cannot verify so a drifted value never reaches you as if it were real. That
|
||||
held-back number used to show up as an internal `[unverified]` marker in the
|
||||
middle of the sentence. It now appears as a short editorial mark, and a small
|
||||
muted line under the message tells you that some figures could not be checked
|
||||
and were left out. The line is worded for each of the six languages and stays
|
||||
out of the way until there is something to say.
|
||||
- **Coach action cards and the tool trace survive a reload.** A suggested
|
||||
reminder, a confirm-to-create action card, and the "what I looked at" tool
|
||||
trace were saved with the message but dropped when the conversation was
|
||||
reopened, so they quietly vanished on reload. They are now restored with the
|
||||
rest of the message, so reopening a thread shows the same cards and trace it
|
||||
showed the first time.
|
||||
|
||||
## [1.32.13] — 2026-07-24
|
||||
|
||||
An Insights assessment card no longer shows the raw model output when a response
|
||||
comes back incomplete. If the underlying text was cut off, the card now falls
|
||||
back to its computed summary instead of printing the unfinished data.
|
||||
|
||||
- **A cut-off assessment stays hidden.** The per-metric assessment cards, like
|
||||
the one for resting pulse, ask the model for a small structured reply and then
|
||||
read the summary out of it. When that reply arrived truncated, the reader
|
||||
could see the whole envelope on the card, including the property name, the
|
||||
braces, the quotes, and the escaped line breaks. The card now recognises an
|
||||
incomplete or malformed reply and serves the deterministic summary it already
|
||||
falls back to when no model text is available, so the raw output never reaches
|
||||
you.
|
||||
- **Plain-text assessments are untouched.** A model that answers in an ordinary
|
||||
sentence rather than the structured shape is still shown as written, so this
|
||||
change only affects broken replies and leaves normal ones exactly as before.
|
||||
- **The same guard covers the other cards.** Medication compliance and the
|
||||
derived-score assessments read their summary the same way, so they now hold
|
||||
back a broken reply too rather than persisting it as the day's text.
|
||||
|
||||
Thanks to @lutzkind for reporting this one.
|
||||
|
||||
## [1.32.12] — 2026-07-24
|
||||
|
||||
A mood you log late in the evening now lands on the day you logged it, in your
|
||||
own timezone, both on the dashboard and in the Coach's mood view.
|
||||
|
||||
- **Your late-night mood stays on the right day.** The pre-computed mood history
|
||||
used to file each entry by its UTC date, so a mood logged after dark in a
|
||||
timezone that runs ahead of or behind UTC could jump to the neighbouring day
|
||||
once the nightly rebuild ran. Every mood now files under the calendar day it
|
||||
was logged in your own timezone, matching the raw entry list.
|
||||
- **The dashboard and the Coach agree.** The daily mood series behind the
|
||||
dashboard tile and the Coach's mood view now read the same day for an entry as
|
||||
the entry list does, so a value no longer shifts by a day when the faster
|
||||
pre-computed path takes over from the live one.
|
||||
- **The correction applies itself.** On upgrade the pre-computed mood history is
|
||||
cleared and rebuilt under the corrected keying. While it rebuilds, mood reads
|
||||
fall back to the live calculation, which was already correct, so there is no
|
||||
window where the wrong day is shown.
|
||||
|
||||
## [1.32.11] — 2026-07-24
|
||||
|
||||
Signing in to the iOS app on a self-hosted domain now runs through your own web
|
||||
login, so saved passwords and passkeys behave the way they should.
|
||||
|
||||
- **Sign in through your own domain.** On a self-hosted instance the app now
|
||||
opens your instance's real web login page inside a secure in-app browser.
|
||||
Because the login happens on your own origin, your password manager files the
|
||||
credentials under the right site and passkeys can run at all, which the older
|
||||
native form could not do on a custom domain.
|
||||
- **A single-use handoff, not a token in a link.** Once you have logged in, the
|
||||
server hands the app a one-time code that lives for about ninety seconds and
|
||||
can be redeemed only once. The access and refresh tokens never ride a URL. The
|
||||
whole exchange mirrors the existing native single sign-on handoff and reuses
|
||||
its protections, including the per-request PKCE binding.
|
||||
- **The login has to happen inside the flow.** The app receives a code only when
|
||||
you actually authenticate during that browser session. A login left open in
|
||||
the background from before cannot complete the handoff by itself, so a stray
|
||||
link can never quietly sign the app in as you.
|
||||
- **Your second factor still applies.** An account with an authenticator app or
|
||||
a security key completes that step on the web page before any code is issued,
|
||||
exactly as it does everywhere else.
|
||||
|
||||
## [1.32.9] — 2026-07-23
|
||||
|
||||
The Coach output guard now remembers the numbers you were actually shown across
|
||||
a whole conversation, so figures you legitimately carry forward stop getting
|
||||
marked unverified while an invented one still gets caught.
|
||||
|
||||
- **A figure from earlier in the chat is no longer flagged when you bring it
|
||||
back up.** The guard now keeps a running record of every number the Coach was
|
||||
given this turn and in earlier turns, plus your goal figures, your medication
|
||||
doses, and the reference bands it was shown. A value it can trace to one of
|
||||
those is left alone even a few turns later. A value the Coach only wrote in an
|
||||
earlier reply is never trusted on that basis, so a number that once slipped
|
||||
through cannot quietly become authoritative later.
|
||||
- **An educational sentence keeps its numbers.** A line that states a general
|
||||
guideline, like the note that adults usually need seven to nine hours of
|
||||
sleep, is recognised as a reference and left intact instead of having its
|
||||
numbers stripped. This works in every supported language.
|
||||
- **A dose you are actually on reads as a restatement, a different one does
|
||||
not.** When the Coach says to keep taking a dose, the reminder is allowed only
|
||||
when the amount matches a dose on your schedule. A wrong maintenance amount, or
|
||||
any wording that nudges the dose up or down, is still held back.
|
||||
- **Fabricated risk numbers are caught in more languages.** The screen that
|
||||
blocks an invented clinical risk figure or a named risk-engine result now
|
||||
reads French, Spanish, Italian, and Polish to the same depth it already read
|
||||
English and German, including a percentage written out in words.
|
||||
|
||||
## [1.32.8] — 2026-07-23
|
||||
|
||||
Health records written through the app now carry where they came from, and a
|
||||
HealthKit sync can say what set it off.
|
||||
|
||||
- **A dose logged from the app is recorded as an app entry.** Every intake used
|
||||
to be stored as a web entry no matter how it arrived. The server now reads the
|
||||
transport it authenticated the call with and tags the dose to match, so a dose
|
||||
logged in the native app reads as an app entry while one logged in the browser
|
||||
stays a web entry. A reminder confirmation, an import, and an Apple Health
|
||||
mirror keep their own labels. The value is derived on the server and can never
|
||||
be set from the request body.
|
||||
- **The dose history shows how each dose was recorded.** The per-dose history
|
||||
returns a source label on every intake, so a client can show at a glance
|
||||
whether a dose came from the web, the app, a reminder, an import, or an Apple
|
||||
Health mirror. Rows written before the label existed report as unknown rather
|
||||
than guessing.
|
||||
- **A HealthKit sync can report what woke it.** The batch upload accepts an
|
||||
optional trigger tag reading foreground, background, or push. It is recorded
|
||||
for diagnostics only and never changes which readings are stored or how they
|
||||
are deduplicated, so sending it or leaving it off produces the same result.
|
||||
|
||||
## [1.32.7] — 2026-07-23
|
||||
|
||||
The Coach output guard now reads numbers the way you actually write them, so a
|
||||
correct figure stops getting marked unverified while an invented one still gets
|
||||
caught.
|
||||
|
||||
- **A number you reformatted is no longer flagged.** The guard recognises a
|
||||
rounded restatement, a delta narrated as a drop, an adherence rate written as
|
||||
a percent, minutes read back as hours, a reformatted date, and a
|
||||
thousands-separated step count, each checked against the figure the server
|
||||
actually gave the Coach. A drifted number, like an average of 128 quoted back
|
||||
as 138, is still caught.
|
||||
- **A blood-pressure claim can no longer borrow a weight number.** Every figure
|
||||
is matched inside its own unit family, and a raw reading never earns the
|
||||
looser rounding reserved for a headline average, so a fabricated average
|
||||
cannot slip through on a nearby sample.
|
||||
- **A correct clinical-risk refusal streams through untouched.** Naming ASCVD or
|
||||
a ten-year risk to explain that a clinician computes it now passes. What still
|
||||
blocks is an asserted figure or a categorical verdict, whether it carries
|
||||
digits ("your risk is about 14%"), spelled-out words ("roughly twelve
|
||||
percent"), or no number at all ("SCORE2 would put you in the high-risk band").
|
||||
- **A dose you already take reads as support.** "Keep taking your 7.5 mg as
|
||||
prescribed" passes, while anything that moves the dose stays blocked.
|
||||
- **A figure removed as unverifiable leaves the sentence intact.** The strip
|
||||
touches only the exact token, so a year like 2023 is never clipped by a
|
||||
flagged 23.
|
||||
|
||||
Refs #587, #591.
|
||||
|
||||
## [1.32.6] — 2026-07-23
|
||||
|
||||
Hardening and data-integrity fixes that were reviewed earlier and are now folded
|
||||
|
||||
+1429
-522
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"undo": "Rückgängig",
|
||||
@@ -360,6 +361,9 @@
|
||||
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
|
||||
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Anmeldung bei der HealthLog-App"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
@@ -451,6 +455,7 @@
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
@@ -2347,6 +2352,7 @@
|
||||
"send": "Senden",
|
||||
"stop": "Stopp",
|
||||
"thinking": "Denke nach…",
|
||||
"unverifiedFiguresNotice": "Einige Zahlen ließen sich nicht mit deinen Daten abgleichen und wurden weggelassen.",
|
||||
"composerPlaceholder": "Frag mich etwas…",
|
||||
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
|
||||
"fabLabel": "Coach öffnen",
|
||||
@@ -5541,7 +5547,7 @@
|
||||
"nightscoutDisconnectTitle": "Nightscout trennen?",
|
||||
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.",
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.",
|
||||
"polarViewData": "Schlaf & Erholung ansehen",
|
||||
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"polarCredentials": "API-Zugangsdaten",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"conflictReloaded": "Reloaded from the server. Please make your change again.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"undo": "Undo",
|
||||
@@ -350,6 +351,9 @@
|
||||
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
|
||||
"errorFailed": "Single sign-on failed. Please try again."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Signing in to the HealthLog app"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Two-factor authentication",
|
||||
"totpHint": "Enter the 6-digit code from your authenticator app.",
|
||||
@@ -441,6 +445,7 @@
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
@@ -2337,6 +2342,7 @@
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"thinking": "Thinking…",
|
||||
"unverifiedFiguresNotice": "Some figures couldn't be checked against your data and were left out.",
|
||||
"composerPlaceholder": "Ask me anything…",
|
||||
"nudgeBubbleLabel": "New message from the Coach — open chat",
|
||||
"fabLabel": "Open the Coach",
|
||||
@@ -5531,7 +5537,7 @@
|
||||
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
|
||||
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, daily activity, and training sessions.",
|
||||
"polarViewData": "View your sleep & recovery",
|
||||
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
|
||||
"polarCredentials": "API Credentials",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"undo": "Deshacer",
|
||||
@@ -362,6 +363,9 @@
|
||||
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
|
||||
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Iniciando sesión en la app de HealthLog"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticación de dos factores",
|
||||
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
|
||||
@@ -453,6 +457,7 @@
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutSaveSuccess": "Diseño del panel guardado",
|
||||
"layoutSaveError": "No se ha podido guardar el diseño",
|
||||
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"layoutResetSuccess": "Restablecido al diseño por defecto",
|
||||
"layoutUsingDefaults": "Diseño por defecto activo",
|
||||
"layoutCustomized": "Diseño personalizado activo",
|
||||
@@ -2349,6 +2354,7 @@
|
||||
"send": "Enviar",
|
||||
"stop": "Detener",
|
||||
"thinking": "Pensando…",
|
||||
"unverifiedFiguresNotice": "Algunas cifras no se pudieron contrastar con tus datos y se omitieron.",
|
||||
"composerPlaceholder": "Pregúntame algo …",
|
||||
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
|
||||
"fabLabel": "Abrir el coach",
|
||||
@@ -5543,7 +5549,7 @@
|
||||
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.",
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño, actividad diaria y sesiones de entrenamiento.",
|
||||
"polarViewData": "Ver tu sueño y recuperación",
|
||||
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"polarCredentials": "Credenciales de API",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"undo": "Annuler",
|
||||
@@ -364,6 +365,9 @@
|
||||
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
|
||||
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Connexion à l'application HealthLog"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Authentification à deux facteurs",
|
||||
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
|
||||
@@ -455,6 +459,7 @@
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutSaveSuccess": "Mise en page enregistrée",
|
||||
"layoutSaveError": "Impossible d’enregistrer la mise en page",
|
||||
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
|
||||
"layoutResetSuccess": "Mise en page par défaut restaurée",
|
||||
"layoutUsingDefaults": "Mise en page par défaut active",
|
||||
"layoutCustomized": "Mise en page personnalisée active",
|
||||
@@ -2351,6 +2356,7 @@
|
||||
"send": "Envoyer",
|
||||
"stop": "Arrêter",
|
||||
"thinking": "Réflexion en cours…",
|
||||
"unverifiedFiguresNotice": "Certains chiffres n'ont pas pu être vérifiés avec tes données et ont été omis.",
|
||||
"composerPlaceholder": "Pose ta question …",
|
||||
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
|
||||
"fabLabel": "Ouvrir le coach",
|
||||
@@ -5545,7 +5551,7 @@
|
||||
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
|
||||
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
|
||||
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil, l'activité et les séances d'entraînement.",
|
||||
"polarViewData": "Voir votre sommeil et récupération",
|
||||
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"polarCredentials": "Identifiants API",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"undo": "Annulla",
|
||||
@@ -354,6 +355,9 @@
|
||||
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
|
||||
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Accesso all'app HealthLog"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticazione a due fattori",
|
||||
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
|
||||
@@ -445,6 +449,7 @@
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutSaveSuccess": "Layout della dashboard salvato",
|
||||
"layoutSaveError": "Impossibile salvare il layout",
|
||||
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"layoutResetSuccess": "Layout predefinito ripristinato",
|
||||
"layoutUsingDefaults": "Layout predefinito attivo",
|
||||
"layoutCustomized": "Layout personalizzato attivo",
|
||||
@@ -2341,6 +2346,7 @@
|
||||
"send": "Invia",
|
||||
"stop": "Interrompi",
|
||||
"thinking": "Sto pensando…",
|
||||
"unverifiedFiguresNotice": "Alcuni numeri non è stato possibile verificarli con i tuoi dati e sono stati omessi.",
|
||||
"composerPlaceholder": "Chiedimi pure …",
|
||||
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
|
||||
"fabLabel": "Apri il coach",
|
||||
@@ -5535,7 +5541,7 @@
|
||||
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
|
||||
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno, l'attività giornaliera e le sessioni di allenamento.",
|
||||
"polarViewData": "Vedi sonno e recupero",
|
||||
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"polarCredentials": "Credenziali API",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Zapisz",
|
||||
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"undo": "Cofnij",
|
||||
@@ -348,6 +349,9 @@
|
||||
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
|
||||
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
|
||||
},
|
||||
"native": {
|
||||
"signingInToApp": "Logowanie do aplikacji HealthLog"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Uwierzytelnianie dwuskładnikowe",
|
||||
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
|
||||
@@ -439,6 +443,7 @@
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutSaveSuccess": "Układ panelu zapisany",
|
||||
"layoutSaveError": "Nie udało się zapisać układu",
|
||||
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"layoutResetSuccess": "Przywrócono układ domyślny",
|
||||
"layoutUsingDefaults": "Aktywny układ domyślny",
|
||||
"layoutCustomized": "Aktywny układ niestandardowy",
|
||||
@@ -2335,6 +2340,7 @@
|
||||
"send": "Wyślij",
|
||||
"stop": "Zatrzymaj",
|
||||
"thinking": "Myślę…",
|
||||
"unverifiedFiguresNotice": "Niektórych liczb nie udało się zweryfikować z Twoimi danymi, więc je pominięto.",
|
||||
"composerPlaceholder": "Zapytaj mnie …",
|
||||
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
|
||||
"fabLabel": "Otwórz trenera",
|
||||
@@ -5529,7 +5535,7 @@
|
||||
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
|
||||
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
|
||||
"polar": "Polar",
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu, dzienną aktywność i treningi.",
|
||||
"polarViewData": "Zobacz sen i regenerację",
|
||||
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"polarCredentials": "Dane API",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.6",
|
||||
"version": "1.32.23",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.32.11 — native web-handoff login (iOS #65).
|
||||
--
|
||||
-- The `oidc_native_handoffs` table now backs two flows: the original OIDC SSO
|
||||
-- native leg and the first-party web-handoff login a self-hoster's iOS app runs
|
||||
-- against its own domain. Both mint the same single-use, PKCE-locked, hashed-at-
|
||||
-- rest handoff code through the shared core; the `flow` column records which
|
||||
-- flow minted a row so the consume path can refuse a cross-flow redemption
|
||||
-- (an `oidc` code presented at the web-login token route, or vice versa).
|
||||
--
|
||||
-- Additive and backfill-safe: every existing row is an OIDC handoff, so the
|
||||
-- column defaults to 'oidc' and no data migration is needed.
|
||||
|
||||
ALTER TABLE "oidc_native_handoffs"
|
||||
ADD COLUMN "flow" TEXT NOT NULL DEFAULT 'oidc';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.32.12 — mood rollup timezone re-key.
|
||||
--
|
||||
-- The mood rollup writer used to bucket on the UTC truncation of
|
||||
-- `mood_logged_at`, so a mood logged late in the evening (or after
|
||||
-- midnight) in a non-UTC timezone landed on the wrong calendar day.
|
||||
-- The writer now keys every bucket on the canonical per-row
|
||||
-- `mood_entries."date"` label instead, byte-identical to the live
|
||||
-- fallback and to the day the user logged the mood in their own zone.
|
||||
--
|
||||
-- Data-only migration: no schema change. `MoodEntryRollup` is a derived
|
||||
-- cache, fully reconstructible from `mood_entries` at any time. Emptying
|
||||
-- the table drops every stale UTC-keyed row; the app then serves correct
|
||||
-- data through the live `date`-keyed fallback until the rollups are
|
||||
-- rebuilt, and the boot-time full-backfill worker
|
||||
-- (`enqueueBootTimeMoodRollupBackfill` → delete-then-refold) re-mints
|
||||
-- every mood-owning user's buckets under the new keying. No mood data is
|
||||
-- touched.
|
||||
DELETE FROM "mood_entry_rollups";
|
||||
@@ -3415,10 +3415,22 @@ model RefreshToken {
|
||||
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
|
||||
// the token pair the first exchange issued (refresh-token reuse-detection
|
||||
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
|
||||
// v1.32.11 — this table now backs BOTH native handoff flows: the original
|
||||
// OIDC SSO leg (`flow = "oidc"`) and the first-party web-handoff login the iOS
|
||||
// app uses on a self-hosted domain (`flow = "web_login"`, iOS #65). The mint +
|
||||
// consume core is shared (`src/lib/auth/native-handoff.ts`); the `flow`
|
||||
// discriminator is written at mint and CHECKED at exchange, so an oidc code can
|
||||
// never be redeemed at the web-login token route and vice versa.
|
||||
model OidcNativeHandoff {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||||
/// Which handoff flow minted this code — `oidc` (the SSO native leg) or
|
||||
/// `web_login` (the first-party web-handoff, iOS #65). Defaults to `oidc` so
|
||||
/// every pre-existing row keeps its meaning. The consume path filters on it,
|
||||
/// making a cross-flow redemption structurally impossible (not merely
|
||||
/// unobserved).
|
||||
flow String @default("oidc") @map("flow")
|
||||
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
|
||||
/// s256(code_verifier) against it (constant-time) before consuming.
|
||||
codeChallenge String @map("code_challenge")
|
||||
|
||||
+1
-1
@@ -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.6";
|
||||
/* @sw-version-fallback */ "v1.32.23";
|
||||
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,171 @@
|
||||
/**
|
||||
* Structural guard on the "stale Today hero / dashboard after a write" class.
|
||||
*
|
||||
* The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()`
|
||||
* (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation
|
||||
* that invalidates one of the daily-reads-bearing bundles
|
||||
* (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`)
|
||||
* with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a
|
||||
* write made from a surface where the daily reads are UNMOUNTED (the medications
|
||||
* page, a detail page, the mood/measurement forms) marks them stale but never
|
||||
* refetches them. Returning to `/` then serves the pre-write cache until the
|
||||
* poll ticks or the tab is hard-reloaded.
|
||||
*
|
||||
* The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its
|
||||
* wrapper `invalidateMedicationReads`), which forces the two inactive reads to
|
||||
* refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1
|
||||
* digest, v1.32.19 "Take all due") because the bundle cannot express
|
||||
* `refetchType`, so every new call site re-decides it by hand. This guard makes
|
||||
* a fourth recurrence fail CI.
|
||||
*
|
||||
* Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show
|
||||
* an allowlisted exception is correct — only that no NEW site invalidates a
|
||||
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
|
||||
* without someone editing this file.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The factory itself DEFINES the bundles + helpers; it is never a call site. */
|
||||
const FACTORY = "lib/query-keys/index.ts";
|
||||
|
||||
/**
|
||||
* A direct-bundle invalidation call site: `invalidateKeys(<qc>, <bundle>)` or
|
||||
* `invalidateKeys(<qc>, [...<bundle>, <localKey>])`. Captures every form the
|
||||
* codebase actually uses (bare bundle, or spread into an array with an extra
|
||||
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
|
||||
* match — the wrapper carries the refetch internally, so they are compliant by
|
||||
* construction and correctly invisible here.
|
||||
*/
|
||||
const INVALIDATION =
|
||||
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g;
|
||||
// Match the CALL form only (open paren), never the bare import specifier —
|
||||
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
|
||||
// count and let a file drop one paired call while still "passing".
|
||||
const REFETCH = /refetchInactiveDailyReads\(/g;
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function count(s: string, re: RegExp): number {
|
||||
return (s.match(re) ?? []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that invalidate a daily bundle WITHOUT pairing the inactive refetch,
|
||||
* deliberately. Frozen: a new entry is a conscious decision that must land in a
|
||||
* reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a
|
||||
* surface where the missing refetch is intentional, grouped by reason.
|
||||
*/
|
||||
const NO_REFETCH_ALLOWLIST = [
|
||||
// The digest is ACTIVE on the dashboard where the quick-add lives, so the
|
||||
// default active invalidation already refetches it (audit B, §1: "not the
|
||||
// bug").
|
||||
"components/dashboard/medication-intake-quick-add.tsx",
|
||||
// Injection-site metadata saved AFTER an already-recorded (and already
|
||||
// refetched) take — it does not change any dose's due/taken state.
|
||||
"components/medications/glp1-medication-card.tsx",
|
||||
"components/medications/medication-card.tsx",
|
||||
// Pull-to-refresh: a current-surface read gesture on the medications page,
|
||||
// not a hero-affecting write (audit B, B-5).
|
||||
"app/medications/page-client.tsx",
|
||||
// Medication configuration / metadata surfaces — rename, dose, schedule
|
||||
// times, phase config, push preferences, API tokens, the create wizard.
|
||||
// None records an intake; a schedule edit's next-due shift converges via the
|
||||
// standard poll from a deep-config surface. Tighten in a later pass if a
|
||||
// config surface is shown to strand the rail.
|
||||
"components/medications/scheduling/schedule-times-editor.tsx",
|
||||
"components/medications/sections/api-tokens-row.tsx",
|
||||
"components/medications/sections/notifications-section.tsx",
|
||||
"components/medications/sections/phase-config-sheet.tsx",
|
||||
"components/medications/sections/settings-section.tsx",
|
||||
"components/medications/wizard/medication-wizard-dialog.tsx",
|
||||
// Background integration-sync completion on the Settings surface — the user
|
||||
// is configuring an integration, not acting on the hero; convergence via the
|
||||
// daily reads' own poll/focus refetch is the intended behaviour.
|
||||
"components/settings/integrations/fitbit-card.tsx",
|
||||
"components/settings/integrations/google-health-card.tsx",
|
||||
"components/settings/integrations/nightscout-card.tsx",
|
||||
"components/settings/integrations/oauth-provider-card.tsx",
|
||||
"components/settings/integrations/whoop-card.tsx",
|
||||
"components/settings/integrations/withings-card.tsx",
|
||||
].sort();
|
||||
|
||||
describe("daily-reads refetch guard", () => {
|
||||
it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => {
|
||||
const src = read(FACTORY);
|
||||
// The one canonical entry point every intake surface routes through.
|
||||
expect(src).toMatch(/export async function invalidateMedicationReads\(/);
|
||||
// It must do BOTH — invalidate the bundle AND force the inactive refetch.
|
||||
const body = src.slice(
|
||||
src.indexOf("export async function invalidateMedicationReads("),
|
||||
);
|
||||
expect(body).toMatch(
|
||||
/invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/,
|
||||
);
|
||||
expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/);
|
||||
});
|
||||
|
||||
it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => {
|
||||
// For each file that invalidates a daily bundle, the count of paired
|
||||
// refetches must be at least the count of invalidations. A count rule (not
|
||||
// a line-proximity heuristic) catches BOTH a new unpaired file AND a new
|
||||
// unpaired call site slipped into an already-compliant file (its refetch
|
||||
// count would then fall below its invalidation count).
|
||||
const nonCompliant: string[] = [];
|
||||
for (const rel of sourceFiles()) {
|
||||
if (rel === FACTORY) continue;
|
||||
const s = read(rel);
|
||||
const invalidations = count(s, INVALIDATION);
|
||||
if (invalidations === 0) continue;
|
||||
const refetches = count(s, REFETCH);
|
||||
if (refetches < invalidations) nonCompliant.push(rel);
|
||||
}
|
||||
|
||||
// The set of deliberately-unpaired sites must be EXACTLY the allowlist:
|
||||
// a new intake mutation that forgets the refetch appears here but not in
|
||||
// the allowlist → fail; a paired site that loses its refetch drops into
|
||||
// this set → fail; a deliberate new exception must be added above.
|
||||
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
|
||||
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
|
||||
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
|
||||
// as by the count assertion above.
|
||||
const routedThroughHelper = [
|
||||
"components/medications/take-all-due.ts",
|
||||
"components/medications/intake-edit-dialog.tsx",
|
||||
"components/medications/intake-history-editable.tsx",
|
||||
"components/medications/intake-import-dialog.tsx",
|
||||
"components/medications/sections/destructive-zone-section.tsx",
|
||||
"components/medications/use-medication-intake.ts",
|
||||
];
|
||||
for (const rel of routedThroughHelper) {
|
||||
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
|
||||
}
|
||||
const pairInPlace = [
|
||||
"components/medications/dose-history-ledger.tsx",
|
||||
"components/medications/dose-history-add-dialog.tsx",
|
||||
];
|
||||
for (const rel of pairInPlace) {
|
||||
expect(read(rel)).toMatch(REFETCH);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Structural guard — every Prisma migration owns a unique numeric prefix.
|
||||
*
|
||||
* Migrations are named `NNNN_description`, and the four-digit prefix is the
|
||||
* ordering key. When two short-lived branches each grab the next free number
|
||||
* and both merge, the tree ends up with two `0268_*` directories. Prisma
|
||||
* applies them in lexical order, but the collision is a silent
|
||||
* merge-time hazard: a reviewer reading `git log` sees one number twice, and a
|
||||
* later renumber has to touch history. This test fails the build the moment a
|
||||
* prefix repeats, so the collision is caught at PR CI rather than after merge.
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const MIGRATIONS_DIR = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../prisma/migrations",
|
||||
);
|
||||
|
||||
const NUMBERED_MIGRATION = /^(\d+)_/;
|
||||
|
||||
/**
|
||||
* One grandfathered collision that predates this guard. `0025_refresh_tokens`
|
||||
* and `0025_user_locale_drift_fix` are both long applied on every deployed
|
||||
* database — a migration directory name is the primary key in the
|
||||
* `_prisma_migrations` ledger, so renaming either would orphan the applied
|
||||
* record and force a re-run on every tenant. It is frozen as history; the
|
||||
* guard grandfathers exactly this prefix and refuses every NEW one.
|
||||
*/
|
||||
const GRANDFATHERED_PREFIXES = new Set(["0025"]);
|
||||
|
||||
describe("prisma migrations — numeric prefixes are unique", () => {
|
||||
it("has no two migration directories sharing a numeric prefix", () => {
|
||||
const prefixes = new Map<string, string[]>();
|
||||
for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const match = NUMBERED_MIGRATION.exec(entry.name);
|
||||
if (!match) continue;
|
||||
const prefix = match[1];
|
||||
const bucket = prefixes.get(prefix) ?? [];
|
||||
bucket.push(entry.name);
|
||||
prefixes.set(prefix, bucket);
|
||||
}
|
||||
|
||||
const collisions = [...prefixes.entries()].filter(
|
||||
([prefix, names]) =>
|
||||
names.length > 1 && !GRANDFATHERED_PREFIXES.has(prefix),
|
||||
);
|
||||
|
||||
expect(
|
||||
collisions,
|
||||
collisions.length > 0
|
||||
? `Duplicate migration prefixes:\n${collisions
|
||||
.map(([prefix, names]) => ` ${prefix}: ${names.join(", ")}`)
|
||||
.join("\n")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the migration set (guards against a broken directory path)", () => {
|
||||
const numbered = readdirSync(MIGRATIONS_DIR, {
|
||||
withFileTypes: true,
|
||||
}).filter((e) => e.isDirectory() && NUMBERED_MIGRATION.test(e.name));
|
||||
expect(numbered.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -55,6 +55,7 @@ import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -36,6 +36,7 @@ describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
authMethod: "cookie" as const,
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Structural invariants for the native web-handoff surface (iOS #65).
|
||||
*
|
||||
* These are tripwires, not proofs — they assert the load-bearing constructions
|
||||
* the red-team relied on stay in place: no `redirect_uri` parameter, one
|
||||
* compile-time callback constant, a trailing-slash public-path entry, the
|
||||
* cookie-only admin / step-up boundaries, and that the new routes never resolve
|
||||
* a Bearer token or mint an ApiToken.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
|
||||
|
||||
/**
|
||||
* Strip block + line comments so an invariant asserts against CODE, not against
|
||||
* a doc comment that legitimately names the thing it forbids (e.g. "there is no
|
||||
* `redirect_uri` parameter").
|
||||
*/
|
||||
function code(rel: string): string {
|
||||
return read(rel)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
const ROUTE_FILES = [
|
||||
"app/api/auth/native/login/route.ts",
|
||||
"app/api/auth/native/complete/route.ts",
|
||||
"app/api/auth/native/token/route.ts",
|
||||
];
|
||||
|
||||
describe("T6 — open redirect / redirect_uri removed by construction", () => {
|
||||
it("no new route or handoff module references a redirect_uri parameter", () => {
|
||||
const files = [
|
||||
...ROUTE_FILES,
|
||||
"lib/auth/native-web-handoff.ts",
|
||||
"lib/auth/native-handoff.ts",
|
||||
];
|
||||
// Comments legitimately state "there is no redirect_uri parameter"; the
|
||||
// invariant is that no CODE reads or builds one.
|
||||
for (const rel of files) {
|
||||
expect(code(rel)).not.toMatch(/redirect_uri/);
|
||||
}
|
||||
});
|
||||
|
||||
it("the callback scheme is a single compile-time constant", () => {
|
||||
// Defined exactly once, as a string literal, with no interpolation.
|
||||
const mod = read("lib/auth/native-web-handoff.ts");
|
||||
expect(mod).toMatch(
|
||||
/NATIVE_WEB_HANDOFF_REDIRECT_URI\s*=\s*"healthlog:\/\/login-callback"/,
|
||||
);
|
||||
// The routes build the Location only through the helper, never a bespoke
|
||||
// scheme string in code (doc comments naming the scheme are fine).
|
||||
for (const rel of ROUTE_FILES) {
|
||||
expect(code(rel)).not.toMatch(/"healthlog:\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("A4 — public-path allowlist carries the trailing slash", () => {
|
||||
it("proxy admits exactly /api/auth/native/ (slash), never the bare prefix", () => {
|
||||
const proxy = read("proxy.ts");
|
||||
expect(proxy).toMatch(/"\/api\/auth\/native\/"/);
|
||||
// The slashless literal must not appear as its own allowlist entry.
|
||||
expect(proxy).not.toMatch(/"\/api\/auth\/native"(?!\/)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T12 — the new routes resolve no Bearer and mint no ApiToken", () => {
|
||||
it("no native route resolves a Bearer token or hand-rolls a tokenHash lookup", () => {
|
||||
for (const rel of ROUTE_FILES) {
|
||||
const src = read(rel);
|
||||
expect(src).not.toMatch(/resolveBearerToken/);
|
||||
expect(src).not.toMatch(/apiToken\.findUnique/);
|
||||
}
|
||||
});
|
||||
|
||||
it("no native route creates an ApiToken", () => {
|
||||
for (const rel of ROUTE_FILES) {
|
||||
const src = read(rel);
|
||||
expect(src).not.toMatch(/apiToken\.create\(|issueApiToken\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => {
|
||||
it("the token route never sets a cookie itself", () => {
|
||||
// The bundle rides the JSON body; a Set-Cookie here would be the browser leak.
|
||||
expect(read("app/api/auth/native/token/route.ts")).not.toMatch(
|
||||
/cookies\.set|Set-Cookie/,
|
||||
);
|
||||
});
|
||||
|
||||
it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => {
|
||||
const handler = read("lib/api-handler.ts");
|
||||
// Both resolve via getSession() (cookie-only) — restated as a tripwire so a
|
||||
// future edit that threads a Bearer into either trips this test.
|
||||
const requireAdmin = handler.slice(
|
||||
handler.indexOf("export async function requireAdmin"),
|
||||
handler.indexOf("export async function requireAdmin") + 400,
|
||||
);
|
||||
expect(requireAdmin).toMatch(/getSession\(\)/);
|
||||
const requireFresh = handler.slice(
|
||||
handler.indexOf("export async function requireFreshMfa"),
|
||||
handler.indexOf("export async function requireFreshMfa") + 400,
|
||||
);
|
||||
expect(requireFresh).toMatch(/getSession\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* GET /api/auth/native/complete — the freshness-bound code mint (iOS #65).
|
||||
*
|
||||
* The load-bearing test is the mandatory-refusal one (red-team A3): a STALE
|
||||
* session (createdAt < startedAt) is refused with a specific reason AND mints no
|
||||
* code / row. This file is the mutation-check target for the freshness gate
|
||||
* (flip the comparison in the route → the stale-refusal case goes RED).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY = "0".repeat(64);
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
const validateSessionWithCreatedAt = vi.fn();
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v),
|
||||
SESSION_COOKIE_NAME: "healthlog_session",
|
||||
}));
|
||||
|
||||
// Keep the real shared core (buildHandoffCallbackUrl is used by
|
||||
// native-web-handoff) but stub the mint so we can assert called / not-called.
|
||||
vi.mock("@/lib/auth/native-handoff", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<typeof import("@/lib/auth/native-handoff")>();
|
||||
return { ...actual, mintNativeHandoff: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { session: { delete: vi.fn().mockResolvedValue({}) } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
encodeNativeHandoffState,
|
||||
} from "@/lib/auth/native-web-handoff";
|
||||
|
||||
const STARTED_AT = new Date("2026-07-24T10:00:00.000Z");
|
||||
|
||||
/** Build a request carrying a valid encrypted state cookie + a session cookie. */
|
||||
function makeRequest(opts: {
|
||||
withState?: boolean;
|
||||
stateValue?: string;
|
||||
withSession?: boolean;
|
||||
}): NextRequest {
|
||||
const r = new NextRequest("http://localhost/api/auth/native/complete");
|
||||
if (opts.stateValue !== undefined) {
|
||||
r.cookies.set(NATIVE_HANDOFF_STATE_COOKIE, opts.stateValue);
|
||||
} else if (opts.withState !== false) {
|
||||
r.cookies.set(
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
encodeNativeHandoffState({
|
||||
appCodeChallenge: "a".repeat(43),
|
||||
startedAt: STARTED_AT.toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.withSession !== false) {
|
||||
r.cookies.set("healthlog_session", "hls_sessionsecret");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function sessionCreatedAt(createdAt: Date) {
|
||||
return {
|
||||
session: {
|
||||
id: "sess-1",
|
||||
createdAt,
|
||||
expiresAt: new Date(Date.now() + 1000000),
|
||||
},
|
||||
user: { id: "user-1", username: "alice" },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(mintNativeHandoff).mockResolvedValue({
|
||||
code: "hlh_" + "z".repeat(43),
|
||||
handoffId: "ho-1",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/native/complete", () => {
|
||||
it("fresh session → mints a web_login code and redirects to the scheme", async () => {
|
||||
// createdAt AFTER startedAt: the user authenticated inside this flow.
|
||||
validateSessionWithCreatedAt.mockResolvedValue(
|
||||
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
|
||||
);
|
||||
|
||||
const res = await GET(makeRequest({}));
|
||||
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://login-callback?code=hlh_")).toBe(
|
||||
true,
|
||||
);
|
||||
// The token pair never rides the URL — only the code.
|
||||
expect(location).not.toContain("token");
|
||||
expect(location).not.toContain("refresh");
|
||||
|
||||
expect(mintNativeHandoff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: "a".repeat(43),
|
||||
flow: "web_login",
|
||||
}),
|
||||
);
|
||||
// State cookie deleted + scaffold session destroyed + session cookie cleared.
|
||||
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe("");
|
||||
expect(prisma.session.delete).toHaveBeenCalledWith({
|
||||
where: { id: "sess-1" },
|
||||
});
|
||||
expect(res.cookies.get("healthlog_session")?.value).toBe("");
|
||||
});
|
||||
|
||||
it("A3 — STALE session (createdAt < startedAt) is REFUSED and mints nothing", async () => {
|
||||
validateSessionWithCreatedAt.mockResolvedValue(
|
||||
sessionCreatedAt(new Date(STARTED_AT.getTime() - 1000)),
|
||||
);
|
||||
|
||||
const res = await GET(makeRequest({}));
|
||||
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=stale_session",
|
||||
);
|
||||
// No code minted, no scaffold session destroyed.
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
expect(prisma.session.delete).not.toHaveBeenCalled();
|
||||
// The single-use state cookie is still cleared on the failure branch.
|
||||
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe("");
|
||||
});
|
||||
|
||||
it("no state cookie → invalid_state, no mint", async () => {
|
||||
validateSessionWithCreatedAt.mockResolvedValue(
|
||||
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
|
||||
);
|
||||
const res = await GET(makeRequest({ withState: false }));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=invalid_state",
|
||||
);
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tampered / garbage state cookie → invalid_state (auth tag fails)", async () => {
|
||||
validateSessionWithCreatedAt.mockResolvedValue(
|
||||
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
|
||||
);
|
||||
const res = await GET(makeRequest({ stateValue: "v1.not-a-real-blob" }));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=invalid_state",
|
||||
);
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no valid session → no_session, no mint", async () => {
|
||||
validateSessionWithCreatedAt.mockResolvedValue(null);
|
||||
const res = await GET(makeRequest({ withSession: false }));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=no_session",
|
||||
);
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limited → scheme error, no mint", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await GET(makeRequest({}));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=rate_limited",
|
||||
);
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* GET /api/auth/native/complete
|
||||
*
|
||||
* Completion leg of the first-party web-handoff login (iOS #65). After the user
|
||||
* signs in on the instance's own web login page (inside the app's
|
||||
* `ASWebAuthenticationSession`), the page navigates the browser here as a
|
||||
* top-level GET. This endpoint validates the web session, enforces the FRESHNESS
|
||||
* BINDING, mints a single-use handoff code, and redirects to the compiled-in
|
||||
* custom scheme carrying ONLY the opaque code.
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - `userId` is resolved SOLELY from the DB-validated session cookie — the
|
||||
* request carries no identity field; the app challenge lives in the
|
||||
* tamper-authenticated state cookie.
|
||||
* - FRESHNESS: `session.createdAt >= state.startedAt`. The session completing the
|
||||
* flow must have been minted AFTER the flow started — i.e. the user actually
|
||||
* authenticated inside this ASWebAuthenticationSession. A pre-existing browser
|
||||
* session can NEVER silently complete a handoff. Both timestamps are DB-clock
|
||||
* values, so there is no app/DB skew (red-team A1). `createdAt` is read from a
|
||||
* dedicated projection (`validateSessionWithCreatedAt`) so the comparison can
|
||||
* never be silently dropped (red-team A3).
|
||||
* - The token pair NEVER rides the URL — only the opaque code does.
|
||||
* - Every failure redirects to `healthlog://login-callback?error=<reason>` and
|
||||
* deletes the state cookie; no code or row is minted on any failure branch.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import {
|
||||
validateSessionWithCreatedAt,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "@/lib/auth/session";
|
||||
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
|
||||
import {
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
NATIVE_HANDOFF_STATE_COOKIE_PATH,
|
||||
buildWebHandoffCallbackUrl,
|
||||
decodeNativeHandoffState,
|
||||
type NativeHandoffErrorReason,
|
||||
} from "@/lib/auth/native-web-handoff";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The state cookie is path-scoped to `/api/auth/native`; RFC 6265 keys cookies
|
||||
* by name+domain+path, so the delete must repeat that path or it silently
|
||||
* targets `/` and leaves the single-use blob alive.
|
||||
*/
|
||||
function deleteStateCookie(response: NextResponse): void {
|
||||
response.cookies.delete({
|
||||
name: NATIVE_HANDOFF_STATE_COOKIE,
|
||||
path: NATIVE_HANDOFF_STATE_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.native.complete" } });
|
||||
|
||||
// Every failure — and success — deletes the single-use state cookie.
|
||||
const fail = (reason: NativeHandoffErrorReason): NextResponse => {
|
||||
const response = NextResponse.redirect(
|
||||
buildWebHandoffCallbackUrl({ error: reason }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:native:complete",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
annotate({ meta: { reason: "rate_limited" } });
|
||||
return fail("rate_limited");
|
||||
}
|
||||
|
||||
// (1) The encrypted, tamper-authenticated state cookie. A missing, forged, or
|
||||
// tampered blob (AES-256-GCM auth tag) fails to decode → no mint.
|
||||
const state = decodeNativeHandoffState(
|
||||
req.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value,
|
||||
);
|
||||
if (!state) {
|
||||
annotate({ meta: { reason: "invalid_state" } });
|
||||
return fail("invalid_state");
|
||||
}
|
||||
|
||||
// (2) A genuinely authenticated web session — validated against the database
|
||||
// (row + expiry), never "a cookie is present". Projects `createdAt` so the
|
||||
// freshness comparison below is structurally always available.
|
||||
const authed = await validateSessionWithCreatedAt(
|
||||
req.cookies.get(SESSION_COOKIE_NAME)?.value,
|
||||
);
|
||||
if (!authed) {
|
||||
annotate({ meta: { reason: "no_session" } });
|
||||
return fail("no_session");
|
||||
}
|
||||
|
||||
// (3) FRESHNESS BINDING. The session must have been minted AFTER the flow
|
||||
// started. A stale (pre-existing) session is refused and mints NOTHING.
|
||||
const startedAt = new Date(state.startedAt);
|
||||
if (authed.session.createdAt.getTime() < startedAt.getTime()) {
|
||||
annotate({ meta: { reason: "stale_session" } });
|
||||
return fail("stale_session");
|
||||
}
|
||||
|
||||
// (4) Mint the single-use, PKCE-locked, flow-tagged handoff code. `userId` is
|
||||
// the server-resolved session identity; the app's S256 challenge (from the
|
||||
// tamper-authenticated cookie) locks the code to the app instance that
|
||||
// started the flow.
|
||||
const ua = req.headers.get("user-agent");
|
||||
const { code } = await mintNativeHandoff({
|
||||
userId: authed.user.id,
|
||||
appCodeChallenge: state.appCodeChallenge,
|
||||
flow: "web_login",
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
|
||||
await auditLog("auth.native.handoff_minted", {
|
||||
userId: authed.user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.native.handoff_minted" } });
|
||||
|
||||
const response = NextResponse.redirect(buildWebHandoffCallbackUrl({ code }));
|
||||
deleteStateCookie(response);
|
||||
|
||||
// (5) Destroy the scaffold web session (design §2.6, recommended). The flow's
|
||||
// product is the native bundle; the browser sheet's residual login is not
|
||||
// something the user asked for, and the freshness rule already makes a
|
||||
// retained session worthless for any FUTURE flow. Best-effort — a delete
|
||||
// hiccup never fails the handoff.
|
||||
await prisma.session
|
||||
.delete({ where: { id: authed.session.id } })
|
||||
.catch(() => {});
|
||||
response.cookies.set(SESSION_COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* GET /api/auth/native/login — authorize entry (iOS #65).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY = "0".repeat(64);
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
// Only `$queryRaw` (the DB clock) is used here; return a fixed instant.
|
||||
// Hoisted so the mock factory (also hoisted) can reference it.
|
||||
const { DB_NOW } = vi.hoisted(() => ({
|
||||
DB_NOW: new Date("2026-07-24T10:00:00.000Z"),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { $queryRaw: vi.fn().mockResolvedValue([{ now: DB_NOW }]) },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
decodeNativeHandoffState,
|
||||
} from "@/lib/auth/native-web-handoff";
|
||||
|
||||
const VALID_CHALLENGE = "a".repeat(43);
|
||||
|
||||
function req(query = `code_challenge=${VALID_CHALLENGE}`): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/auth/native/login?${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/native/login", () => {
|
||||
it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => {
|
||||
const res = await GET(req());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain("/auth/login?flow=native");
|
||||
// No custom scheme on the success path.
|
||||
expect(location.startsWith("healthlog://")).toBe(false);
|
||||
|
||||
const cookie = res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE);
|
||||
expect(cookie?.value).toBeTruthy();
|
||||
expect(cookie?.path).toBe("/api/auth/native");
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
|
||||
// The state round-trips: challenge bound, startedAt is the DB clock.
|
||||
const state = decodeNativeHandoffState(cookie!.value);
|
||||
expect(state?.appCodeChallenge).toBe(VALID_CHALLENGE);
|
||||
expect(state?.startedAt).toBe(DB_NOW.toISOString());
|
||||
});
|
||||
|
||||
it("invalid (short) challenge → scheme error, no state cookie", async () => {
|
||||
const res = await GET(req("code_challenge=tooshort"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=invalid_request",
|
||||
);
|
||||
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeFalsy();
|
||||
// No DB clock read on the reject path.
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing challenge → scheme error", async () => {
|
||||
const res = await GET(req(""));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=invalid_request",
|
||||
);
|
||||
});
|
||||
|
||||
it("rate-limited → scheme error, no state cookie, no DB write", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://login-callback?error=rate_limited",
|
||||
);
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* GET /api/auth/native/login
|
||||
*
|
||||
* Authorize entry for the first-party web-handoff login (iOS #65). The iOS app,
|
||||
* on a self-hosted domain, opens this URL inside an `ASWebAuthenticationSession`
|
||||
* with its PKCE `code_challenge`. This endpoint validates the challenge, folds
|
||||
* it plus a DB-clock `startedAt` into an AES-256-GCM state cookie, and redirects
|
||||
* to the instance's own web login at `/auth/login?flow=native` so the login runs
|
||||
* in the real web origin (fixing password autofill + passkeys for self-hosters).
|
||||
*
|
||||
* Pre-auth surface: writes NO database rows (anonymous flooding costs the caller
|
||||
* a cookie in their own jar). Every error branch redirects to the compiled-in
|
||||
* custom scheme with a machine-readable `error=<reason>` — an error redirect
|
||||
* carries no code or session, so it leaks nothing.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import {
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
NATIVE_HANDOFF_STATE_COOKIE_PATH,
|
||||
NATIVE_HANDOFF_STATE_TTL_MS,
|
||||
buildWebHandoffCallbackUrl,
|
||||
encodeNativeHandoffState,
|
||||
nativeHandoffDbNow,
|
||||
type NativeHandoffErrorReason,
|
||||
} from "@/lib/auth/native-web-handoff";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.native.login" } });
|
||||
|
||||
// The whole route is a native start — every failure redirects to the scheme.
|
||||
const loginError = (reason: NativeHandoffErrorReason): NextResponse =>
|
||||
NextResponse.redirect(buildWebHandoffCallbackUrl({ error: reason }));
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:native:login",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) return loginError("rate_limited");
|
||||
|
||||
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
|
||||
// structurally unsupported — the exchange only ever verifies S256.
|
||||
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
|
||||
if (!isValidChallenge(appCodeChallenge)) {
|
||||
annotate({ meta: { reason: "invalid_native_request" } });
|
||||
return loginError("invalid_request");
|
||||
}
|
||||
|
||||
// Stamp `startedAt` from the DB clock so it is directly comparable to
|
||||
// `Session.createdAt` (also DB-side) at completion, with no app/DB skew.
|
||||
const startedAt = await nativeHandoffDbNow();
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/auth/login?flow=native", req.url),
|
||||
);
|
||||
response.cookies.set(
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
encodeNativeHandoffState({
|
||||
appCodeChallenge,
|
||||
startedAt: startedAt.toISOString(),
|
||||
}),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// The completion is a top-level GET navigated by the page after login;
|
||||
// Lax carries the cookie on that same-site top-level navigation.
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(NATIVE_HANDOFF_STATE_TTL_MS / 1000),
|
||||
// Only the completion endpoint ever reads it — the delete repeats this path.
|
||||
path: NATIVE_HANDOFF_STATE_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* POST /api/auth/native/token — web-handoff exchange (iOS #65).
|
||||
*
|
||||
* Mirrors the OIDC native-token route tests, with the `web_login` flow gate.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/native-client", () => ({
|
||||
isCookielessNativeCaller: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/native-handoff", () => ({
|
||||
consumeNativeHandoff: vi.fn(),
|
||||
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/native-handoff";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const VALID_BODY = {
|
||||
code: "hlh_" + "a".repeat(43),
|
||||
codeVerifier: "a".repeat(43),
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
body: unknown = VALID_BODY,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/native/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-client-type": "native",
|
||||
...headers,
|
||||
},
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function nativeBundleResponse() {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
user: { id: "user-1", username: "alice" },
|
||||
token: "hlk_access",
|
||||
tokenExpiresAt: new Date().toISOString(),
|
||||
refreshToken: "hlr_refresh",
|
||||
refreshTokenExpiresAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/native/token", () => {
|
||||
it("429 when rate-limited (before any state is touched)", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 and never consumes when the transport is not cookie-less native", async () => {
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 when the caller presents no native marker (browser UA path)", async () => {
|
||||
// isCookielessNativeCaller true, but no x-client-type / native UA →
|
||||
// isNativeClientRequest false → rejected before any state.
|
||||
const res = await POST(makeRequest(VALID_BODY, { "x-client-type": "web" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422 on a malformed body", async () => {
|
||||
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes with the web_login flow gate", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "not_found",
|
||||
} as never);
|
||||
await POST(makeRequest());
|
||||
expect(consumeNativeHandoff).toHaveBeenCalledWith(
|
||||
VALID_BODY.code,
|
||||
VALID_BODY.codeVerifier,
|
||||
"web_login",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns ONE identical generic 401 across every invalid-code class", async () => {
|
||||
const statuses = [
|
||||
{ status: "not_found" },
|
||||
{ status: "replayed", userId: "user-1" },
|
||||
{ status: "expired" },
|
||||
{ status: "pkce_mismatch" },
|
||||
{ status: "race_lost" },
|
||||
] as const;
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const s of statuses) {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
}
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not mint on the deleted-user branch", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
await POST(makeRequest());
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("200 on success: mints via the web_handoff source, stamps, audits, no mfaVerified", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(finishArg.source).toBe("login.web_handoff");
|
||||
// Never set — the handoff login can't satisfy step-up.
|
||||
expect(finishArg.mfaVerified).toBeUndefined();
|
||||
|
||||
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.login",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { transport: "native", method: "web_handoff" },
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data.refreshToken).toBe("hlr_refresh");
|
||||
});
|
||||
|
||||
it("is NOT blocked by OIDC_ONLY", async () => {
|
||||
const prev = process.env.OIDC_ONLY;
|
||||
process.env.OIDC_ONLY = "true";
|
||||
try {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OIDC_ONLY;
|
||||
else process.env.OIDC_ONLY = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* POST /api/auth/native/token
|
||||
*
|
||||
* The cookie-less native leg of the first-party web-handoff login (iOS #65). The
|
||||
* app presents the one-time handoff code it received on
|
||||
* `healthlog://login-callback?code=` plus its PKCE `codeVerifier`; on success it
|
||||
* receives the SAME native token bundle password login issues
|
||||
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
|
||||
*
|
||||
* Byte-for-byte the posture of `POST /api/auth/oidc/native/token`, with the
|
||||
* `flow: "web_login"` gate on the shared consume:
|
||||
* - It IS the credential mint: no session or Bearer is required to reach it.
|
||||
* - The transport gate (`isCookielessNativeCaller` + `isNativeClientRequest`)
|
||||
* makes the endpoint structurally incapable of minting a web session or handing
|
||||
* a 60-day refresh token into a browser context.
|
||||
* - `userId` is derived SOLELY from the server-side handoff row.
|
||||
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
|
||||
* cross-flow / deleted-user so a code-guesser learns nothing.
|
||||
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
|
||||
* `requireFreshMfa` step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import { isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/native-handoff";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The single generic rejection for every invalid-code class. */
|
||||
function invalidCode() {
|
||||
return apiError("Invalid or expired code", 401);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "auth.native.token" } });
|
||||
|
||||
// (1) Rate limit — the MFA-verify surface's posture, incl. the
|
||||
// trust-violation collapse semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:native:token",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Transport gate — this endpoint only ever hands a native token bundle. A
|
||||
// browser (Mozilla UA or an inbound session cookie) is rejected, AND the
|
||||
// caller must present an explicit native marker, so the cookie branch of
|
||||
// `finishLogin` is structurally unreachable.
|
||||
if (
|
||||
!isCookielessNativeCaller(request.headers) ||
|
||||
!isNativeClientRequest(request.headers)
|
||||
) {
|
||||
annotate({ action: { name: "auth.native.token.wrong_transport" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// (3) Body — bounded, then the exact wire contract.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = nativeHandoffTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
// (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume.
|
||||
// The `web_login` flow gate rejects an OIDC-minted code as `not_found`.
|
||||
const result = await consumeNativeHandoff(code, codeVerifier, "web_login");
|
||||
if (result.status !== "ok") {
|
||||
switch (result.status) {
|
||||
case "not_found":
|
||||
annotate({ action: { name: "auth.native.token.not_found" } });
|
||||
break;
|
||||
case "replayed":
|
||||
annotate({
|
||||
action: { name: "auth.native.handoff_replay" },
|
||||
meta: { revoked_issued_pair: result.revokedIssuedPair },
|
||||
});
|
||||
break;
|
||||
case "expired":
|
||||
annotate({ action: { name: "auth.native.token.expired" } });
|
||||
break;
|
||||
case "pkce_mismatch":
|
||||
annotate({ action: { name: "auth.native.pkce_failed" } });
|
||||
break;
|
||||
case "race_lost":
|
||||
annotate({ action: { name: "auth.native.token.race_lost" } });
|
||||
break;
|
||||
}
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (5) Load the user (may have been deleted mid-flight).
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: result.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.native.token.user_missing" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (6) Mint the native bundle. Step (2) guarantees the cookie-less-native
|
||||
// branch of `finishLogin` (24h access + 60d rotating refresh). `mfaVerified`
|
||||
// is deliberately unset — the handoff login can never satisfy step-up.
|
||||
const ua = request.headers.get("user-agent");
|
||||
const response = await finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.web_handoff",
|
||||
});
|
||||
|
||||
// (7) Stamp the issued refresh token's hash for replay-containment reach-back,
|
||||
// then audit. Best-effort — the code is already single-use-consumed.
|
||||
try {
|
||||
const bundle = (await response.clone().json()) as {
|
||||
data?: { refreshToken?: unknown };
|
||||
};
|
||||
const refreshToken = bundle.data?.refreshToken;
|
||||
if (typeof refreshToken === "string") {
|
||||
await stampIssuedRefreshToken(result.handoffId, refreshToken);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — see comment above.
|
||||
}
|
||||
|
||||
await auditLog("auth.login", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { transport: "native", method: "web_handoff" },
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.32.21 (R5a) — optimistic concurrency on `PUT /api/coach/about-me`
|
||||
* (issue #581 family). The write guards on `UserHealthProfile.updatedAt`:
|
||||
*
|
||||
* - a stale base token → 409 `about_me_conflict`, no write;
|
||||
* - a matched base token → conditional update, fresh token echoed;
|
||||
* - a zero-match against a MISSING row → create wins (nothing to clobber);
|
||||
* - a tokenless request → the prior unconditional upsert (iOS-compat arm).
|
||||
*
|
||||
* Mirrors the mock scaffolding of `route-module-gate.test.ts`.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
}));
|
||||
|
||||
const { requireModuleEnabled } = vi.hoisted(() => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number, meta?: unknown) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
meta,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
const { profile } = vi.hoisted(() => ({
|
||||
profile: {
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { userHealthProfile: profile } }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => Buffer.from(s),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(async () => ({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
})),
|
||||
getPendingQuestionsForUser: vi.fn(async () => []),
|
||||
setPendingQuestionsForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
|
||||
deriveClarifyingQuestions: vi.fn(async () => ({
|
||||
questions: [],
|
||||
source: "none",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
|
||||
type Envelope = {
|
||||
data: { updatedAt?: string } | null;
|
||||
error: string | null;
|
||||
status: number;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
function putReq(baseUpdatedAt?: string): Request {
|
||||
return new Request("http://localhost/api/coach/about-me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
aboutMe: "I run daily",
|
||||
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
describe("about-me — optimistic concurrency", () => {
|
||||
it("guards on the base token and echoes the advanced token", async () => {
|
||||
const base = new Date("2026-07-24T10:00:00.000Z");
|
||||
const advanced = new Date("2026-07-24T10:05:00.000Z");
|
||||
profile.updateMany.mockResolvedValue({ count: 1 });
|
||||
profile.findUnique.mockResolvedValue({ updatedAt: advanced });
|
||||
|
||||
const res = await put(putReq(base.toISOString()));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Conditional write on the exact base token — never the upsert.
|
||||
expect(profile.updateMany).toHaveBeenCalledTimes(1);
|
||||
const whereArg = profile.updateMany.mock.calls[0]?.[0]?.where as {
|
||||
userId: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
expect(whereArg.userId).toBe("u1");
|
||||
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
expect(res.data?.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
|
||||
it("a stale base token 409s and clobbers nothing (existing row advanced)", async () => {
|
||||
profile.updateMany.mockResolvedValue({ count: 0 });
|
||||
// The row exists but its token advanced → real conflict, not a delete.
|
||||
profile.findUnique.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.data).toBeNull();
|
||||
expect(res.meta?.errorCode).toBe("about_me_conflict");
|
||||
|
||||
// No write happened: neither the upsert nor a create ran.
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
expect(profile.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a zero-match against a MISSING row creates instead of 409ing", async () => {
|
||||
profile.updateMany.mockResolvedValue({ count: 0 });
|
||||
profile.findUnique.mockResolvedValue(null);
|
||||
profile.create.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(profile.create).toHaveBeenCalledTimes(1);
|
||||
expect(res.data?.updatedAt).toBe("2026-07-24T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("keeps the unconditional upsert when the token is omitted (iOS compat)", async () => {
|
||||
profile.upsert.mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const res = await put(putReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(profile.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(profile.updateMany).not.toHaveBeenCalled();
|
||||
expect(res.data?.updatedAt).toBe("2026-07-24T11:00:00.000Z");
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await put(putReq("not-a-date"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(profile.updateMany).not.toHaveBeenCalled();
|
||||
expect(profile.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -110,12 +111,21 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = aboutMePutSchema.safeParse(body);
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse (issue #581 family). The token guards on
|
||||
// `UserHealthProfile.updatedAt`, which — unlike the layout endpoints on the
|
||||
// shared `User` row — moves only when THIS surface (or the `/adopt`
|
||||
// sub-route) writes, so it is noise-free.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
|
||||
const parsed = aboutMePutSchema.safeParse(taken.rest);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
@@ -165,12 +175,53 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
|
||||
// - no base token → today's upsert (backward-compat arm, byte-identical);
|
||||
// - base present + row still carries it → conditional update, echo fresh;
|
||||
// - base present + zero match → the row either vanished (deleted since the
|
||||
// read → create wins, nothing to clobber) or advanced (real conflict →
|
||||
// 409, no write).
|
||||
let updatedAtIso: string;
|
||||
if (base === undefined) {
|
||||
const row = await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...update },
|
||||
update,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = row.updatedAt.toISOString();
|
||||
} else {
|
||||
const guarded = await prisma.userHealthProfile.updateMany({
|
||||
where: { userId: user.id, updatedAt: base },
|
||||
data: update,
|
||||
});
|
||||
if (guarded.count === 0) {
|
||||
const existing = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (existing) {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.conflict" },
|
||||
meta: { base_updated_at: base.toISOString() },
|
||||
});
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
const created = await prisma.userHealthProfile.create({
|
||||
data: { userId: user.id, ...update },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = created.updatedAt.toISOString();
|
||||
} else {
|
||||
const fresh = await prisma.userHealthProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Read back the effective state (covers omitted fields) and derive
|
||||
// the clarifying questions. An entirely empty self-context clears
|
||||
@@ -222,7 +273,7 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
allergies: ctx.allergies,
|
||||
coachFocus: ctx.coachFocus,
|
||||
pendingQuestions,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
updatedAt: updatedAtIso,
|
||||
maxChars: ABOUT_ME_MAX_CHARS,
|
||||
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.32.16 (issue #581) — the guarded optimistic-concurrency write.
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
@@ -741,3 +743,101 @@ describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layo
|
||||
expect(body.data.widgets[0].visible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.16 — optimistic concurrency (issue #581). A Save that carries the
|
||||
* `baseUpdatedAt` it was made against writes CONDITIONALLY on the stored row
|
||||
* still carrying that token, so a committed Save can never be silently
|
||||
* reverted by a later request that started from an older snapshot. A full
|
||||
* layout with every top-level field present skips the preserve-read, so the
|
||||
* only `findUnique` here is the post-write token read.
|
||||
*/
|
||||
describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
|
||||
// A full layout carrying every preserved field so `needsPreserveRead`
|
||||
// is false and the route takes the guarded write path directly.
|
||||
function fullLayoutBody(baseUpdatedAt?: string) {
|
||||
return {
|
||||
version: 1,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
|
||||
comparisonBaseline: "none",
|
||||
chartOverlayPrefs: {},
|
||||
selectedScoreRings: ["MED_COMPLIANCE"],
|
||||
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("guards the write on the base token and returns the advanced token", async () => {
|
||||
const base = new Date("2026-07-24T10:00:00.000Z");
|
||||
const advanced = new Date("2026-07-24T10:05:00.000Z");
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
// The post-write token read.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
updatedAt: advanced,
|
||||
} as never);
|
||||
|
||||
const res = await callPut(makeReq(fullLayoutBody(base.toISOString())));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Wrote conditionally on the exact base token — never unconditionally.
|
||||
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(base.toISOString());
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
|
||||
// The response echoes the advanced token so the client can rebase.
|
||||
const body = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(body.data.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
|
||||
it("rejects a stale base token with 409 and writes nothing", async () => {
|
||||
// The guarded update matches zero rows — someone advanced updatedAt.
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq(fullLayoutBody("2026-07-24T09:00:00.000Z")),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("dashboard_layout_conflict");
|
||||
|
||||
// The conditional write matched no row; there is NO unconditional
|
||||
// fallback, so the stored layout is left exactly as it was.
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await callPut(makeReq(fullLayoutBody("not-a-date")));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unconditional write when the client omits the token", async () => {
|
||||
// Backward-compatible path for older web builds / the native client.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPut(makeReq(fullLayoutBody()));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type DashboardLayoutWithToken,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -150,14 +156,25 @@ const layoutSchema = z.object({
|
||||
.array(z.enum(HERO_RING_IDS))
|
||||
.max(MAX_HERO_RING_ORDER)
|
||||
.optional(),
|
||||
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
|
||||
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
|
||||
// which strips it BEFORE this parse: the token is transport, not a layout
|
||||
// field, and pulling it pre-Zod keeps the domain schema pure.
|
||||
});
|
||||
|
||||
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
|
||||
async function buildDashboardLayout(
|
||||
userId: string,
|
||||
): Promise<DashboardLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
// v1.32.16 (issue #581) — `updatedAt` rides the cached layout so GET
|
||||
// hands the client the optimistic-concurrency token alongside the
|
||||
// layout it applies to. Cached together (and busted on every write)
|
||||
// so the (layout, token) pair a client reads is always consistent.
|
||||
select: { dashboardWidgetsJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
return { ...layout, updatedAt: row?.updatedAt?.toISOString() };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -167,7 +184,7 @@ export const GET = apiHandler(async () => {
|
||||
// hits the Settings → Dashboard save button, which invalidates this
|
||||
// cache via `invalidateUserDashboardWidgets()`.
|
||||
const layout = await cached(
|
||||
caches.dashboardWidgets as ServerCache<DashboardLayout>,
|
||||
caches.dashboardWidgets as ServerCache<DashboardLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildDashboardLayout(user.id),
|
||||
annotate,
|
||||
@@ -178,11 +195,20 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE anything else touches it (issue #581). A malformed token 422s
|
||||
// without a write; an absent token takes the unconditional backward-compat
|
||||
// arm inside `guardedUserUpdate` below.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
|
||||
// on write. The 27 ids (16 web + 11 iOS-only) all validate + persist;
|
||||
// only a genuinely-unknown id (a retired tile, or a typo) is filtered
|
||||
@@ -310,6 +336,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
// v1.32.16 (issue #581) — the concurrency token was already split off the
|
||||
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
|
||||
// never leak the token into the stored blob.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
|
||||
const needsRangePointsPreserve =
|
||||
@@ -358,12 +387,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
|
||||
// the shared `guardedUserUpdate` helper. When the client sends the
|
||||
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
|
||||
// stored row still carrying that token, so a Save that already committed
|
||||
// can never be silently reverted by a later request (e.g. an instant
|
||||
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
|
||||
// returns 409 and writes NOTHING. Clients that omit the token (older web
|
||||
// builds, the native client) keep the prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { dashboardWidgetsJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "dashboard.widgets.conflict",
|
||||
errorCode: "dashboard_layout_conflict",
|
||||
message: "Dashboard layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
const freshUpdatedAt = guarded.updatedAt;
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
@@ -374,7 +417,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// next dashboard mount paints the new layout.
|
||||
invalidateUserDashboardWidgets(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
// Echo the advanced token so the client can base its next write on it.
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: freshUpdatedAt,
|
||||
} satisfies DashboardLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -371,12 +371,42 @@ describe("POST — numeric grounding", () => {
|
||||
.join("");
|
||||
// 999 is not in the document (which says 160) → soft-stripped.
|
||||
expect(reply).not.toContain("999");
|
||||
expect(reply).toContain("[unverified]");
|
||||
expect(reply).toContain("[…]");
|
||||
// The persisted assistant turn carries the corrected prose.
|
||||
const assistantWrite = vi
|
||||
.mocked(appendMessage)
|
||||
.mock.calls.find((c) => c[0].role === "assistant");
|
||||
expect(assistantWrite?.[0].content).toContain("[unverified]");
|
||||
expect(assistantWrite?.[0].content).toContain("[…]");
|
||||
// v1.32.14 — the fenced path rides a MINIMAL provenance envelope so the
|
||||
// per-message notice renders + survives reload: count only, no windows/metrics.
|
||||
expect(assistantWrite?.[0].metricSource).toEqual({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
unverifiedFigures: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists no provenance when the reply is clean", async () => {
|
||||
vi.mocked(runStreamingRawCompletionWithFallback).mockResolvedValue({
|
||||
result: {
|
||||
content: "Your LDL reading looks steady overall.",
|
||||
tokensUsed: 20,
|
||||
model: "claude",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
workingProvider: { providerType: "anthropic", instance: {} },
|
||||
fallbackHops: [],
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req("doc-1", { message: "how is my ldl?" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
await frames(res);
|
||||
const assistantWrite = vi
|
||||
.mocked(appendMessage)
|
||||
.mock.calls.find((c) => c[0].role === "assistant");
|
||||
// v1.32.14 — clean reply → metricSourceJson stays null (no notice on reload).
|
||||
expect(assistantWrite?.[0].metricSource ?? null).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const imported = await syncUserFitbit(user.id, { fullSync });
|
||||
const { imported } = await syncUserFitbit(user.id, { fullSync });
|
||||
return apiSuccess({ imported, fullSync });
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.32.9 (Coach Guard II / G2) — the Grounding Ledger is CROSS-TURN, so its
|
||||
* two load-bearing properties can only be seen at the route level, over a
|
||||
* conversation with prior turns (the #591 lesson — the pure module cannot see
|
||||
* the route's activation + cross-turn assembly):
|
||||
*
|
||||
* - a prior-turn TOOL figure (persisted as `groundedFigures`) recalled this
|
||||
* turn reconciles and is NOT stripped;
|
||||
* - a number that appears only in a prior ASSISTANT REPLY (never a tool figure)
|
||||
* does NOT ground the next turn — assistant prose is never a ledger source
|
||||
* (D3 / red-team H3). It is still stripped.
|
||||
*
|
||||
* Guards are REAL here (coach-prose-grounding + grounding-ledger unmocked); the
|
||||
* provider loop + persistence are stubbed, exactly like route-tool-mode.
|
||||
*/
|
||||
|
||||
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/redact", () => ({
|
||||
redactSecrets: (s: string) => s,
|
||||
redactOptional: (s: unknown) => s,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
|
||||
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runStreamingRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain,
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
const { assertConsentForChain } = vi.hoisted(() => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/types", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/lib/ai/coach/types",
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
const { fetchConversationWithMessages } = vi.hoisted(() => ({
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(async () => ({ id: "m1" })),
|
||||
createConversation: vi.fn(async () => ({ id: "c1" })),
|
||||
fetchConversationWithMessages,
|
||||
listConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
storeDeterministicFacts: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
|
||||
// unit-tested in the outbound-screen suite.
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-23"),
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
|
||||
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
screenCoachReply: vi.fn(() => ({ block: false })),
|
||||
coachOutboundFallback: vi.fn(() => "fallback"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: vi.fn(async () => ({
|
||||
snapshotJson: SNAPSHOT_JSON,
|
||||
sections: { bloodPressure: { aggregate: { mean: 128 } } },
|
||||
provenance: { windows: ["last30days"], metrics: ["bp"] },
|
||||
referenceGrounding: "REFERENCE RANGES",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/hr-series", () => ({
|
||||
buildWorkoutHrSeries: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/zones", () => ({
|
||||
computeZones: vi.fn(() => null),
|
||||
hrMaxFromAge: vi.fn(() => 185),
|
||||
parseWhoopZoneDurations: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
buildSportContext: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
renderFocusHint,
|
||||
runCoachToolLoop,
|
||||
} = vi.hoisted(() => ({
|
||||
buildCoachDataInventory: vi.fn(async () => ({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: ["bp"], window: "last30days" },
|
||||
})),
|
||||
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
|
||||
renderFocusHint: vi.fn(() => ""),
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
renderFocusHint,
|
||||
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
|
||||
runCoachToolLoop,
|
||||
}));
|
||||
|
||||
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
|
||||
const { parseSuggestReminder } = vi.hoisted(() => ({
|
||||
parseSuggestReminder: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
|
||||
const { appendMessage } = await import("@/lib/ai/coach/persistence");
|
||||
|
||||
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
sse.done = Promise.resolve(
|
||||
producer({ signal: { aborted: false }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Force the model's assembled prose through the sentinel/suggest parsers. */
|
||||
function stubReply(prose: string, toolResults: unknown[]): void {
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose });
|
||||
runCoachToolLoop.mockImplementation(async () => ({
|
||||
result: { content: prose, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
toolResults,
|
||||
}));
|
||||
}
|
||||
|
||||
function assistantContent(): string {
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (assistant?.[0] as { content: string }).content;
|
||||
}
|
||||
|
||||
function assistantProvenance(): Record<string, unknown> | null | undefined {
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (
|
||||
assistant?.[0] as {
|
||||
metricSource?: Record<string, unknown> | null;
|
||||
}
|
||||
).metricSource;
|
||||
}
|
||||
|
||||
describe("coach chat — cross-turn Grounding Ledger (G2)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
|
||||
detectRefusal.mockReturnValue({ refuse: false });
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
assertConsentForChain.mockResolvedValue(undefined);
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recalls a PRIOR-turn tool figure (persisted groundedFigures) without stripping it", async () => {
|
||||
// Turn 1 fetched systolic 128 and persisted it as groundedFigures.
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "c1",
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [
|
||||
{ role: "user", content: "How is my BP?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Your systolic averaged 128.",
|
||||
metricSource: { groundedFigures: [128] },
|
||||
},
|
||||
],
|
||||
});
|
||||
// This turn: a FRESH sleep tool activates the verifier; the model recalls
|
||||
// the prior 128 AND cites the fresh 440.
|
||||
stubReply(
|
||||
"Your sleep averaged 440 minutes, and your systolic 128 average still holds.",
|
||||
[{ present: true, data: { metric: "sleep", aggregate: { mean: 440 } } }],
|
||||
);
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
const content = assistantContent();
|
||||
expect(content).toContain("128");
|
||||
expect(content).toContain("440");
|
||||
expect(content).not.toContain("[unverified]");
|
||||
expect(content).not.toContain("[…]");
|
||||
// v1.32.14 — a fully grounded reply carries NO unverifiedFigures key.
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT let a number from a prior ASSISTANT REPLY ground the next turn (D3 / H3)", async () => {
|
||||
// The prior assistant reply contains 158, but the persisted tool figure was
|
||||
// 128 — 158 was never a tool figure (a rung leak / fabrication). The ledger
|
||||
// must not read the assistant prose, so 158 stays ungrounded and is stripped.
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "c1",
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [
|
||||
{ role: "user", content: "How is my BP?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Your systolic spike to 158 mmHg is worth watching.",
|
||||
metricSource: { groundedFigures: [128] },
|
||||
},
|
||||
],
|
||||
});
|
||||
// This turn: a fresh BP tool returns the real 128; the model repeats the
|
||||
// fabricated 158.
|
||||
stubReply("Your systolic spike to 158 mmHg persists this week.", [
|
||||
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
|
||||
]);
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "still high?" }));
|
||||
await sse.done;
|
||||
|
||||
const content = assistantContent();
|
||||
expect(content).not.toContain("158");
|
||||
// v1.32.14 — the withheld figure reads as the editorial elision mark, never
|
||||
// the old internal QA literal, and the count rides the provenance envelope.
|
||||
expect(content).toContain("[…]");
|
||||
expect(content).not.toContain("[unverified]");
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.32.7 (Coach Guard I / G1) — route-level SSE assertions on the ASSEMBLED
|
||||
* reply, the class the pure-module tests cannot see (the #591 lesson: the unit
|
||||
* suite was green while the route regressed, because the guard runs inside
|
||||
* `route.ts`). Unlike `route-tool-mode.test.ts`, this file uses the REAL
|
||||
* outbound guard and the REAL numeric verifier — only the provider + IO are
|
||||
* stubbed — so it exercises the guard exactly as production does, on both a
|
||||
* tool turn and a no-tool turn.
|
||||
*
|
||||
* It asserts on the persisted assistant `content`, which is the fully-guarded
|
||||
* text streamed to the client (every guard runs before the first token frame).
|
||||
*/
|
||||
|
||||
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
|
||||
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
|
||||
workout: { findFirst: vi.fn(async () => null) },
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runStreamingRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain,
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
const { assertConsentForChain } = vi.hoisted(() => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/types", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/lib/ai/coach/types",
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(async () => ({ id: "m1" })),
|
||||
createConversation: vi.fn(async () => ({ id: "c1" })),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
storeDeterministicFacts: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-21"),
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
|
||||
|
||||
// NB: the outbound guard + numeric verifier are REAL — that is the point.
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: vi.fn(async () => ({
|
||||
snapshotJson: SNAPSHOT_JSON,
|
||||
sections: { bloodPressure: { aggregate: { mean: 128 } } },
|
||||
provenance: { windows: ["last30days"], metrics: ["bp"] },
|
||||
referenceGrounding: "REFERENCE RANGES",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/hr-series", () => ({
|
||||
buildWorkoutHrSeries: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/zones", () => ({
|
||||
computeZones: vi.fn(() => null),
|
||||
hrMaxFromAge: vi.fn(() => 185),
|
||||
parseWhoopZoneDurations: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
buildSportContext: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const { runCoachToolLoop } = vi.hoisted(() => ({
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory: vi.fn(async () => ({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: ["bp"], window: "last30days" },
|
||||
})),
|
||||
renderDataInventory: vi.fn(() => "DATA INVENTORY"),
|
||||
renderFocusHint: vi.fn(() => ""),
|
||||
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
|
||||
runCoachToolLoop,
|
||||
}));
|
||||
|
||||
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
|
||||
const { parseSuggestReminder } = vi.hoisted(() => ({
|
||||
parseSuggestReminder: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
|
||||
const { appendMessage } = await import("@/lib/ai/coach/persistence");
|
||||
|
||||
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
sse.done = Promise.resolve(
|
||||
producer({ signal: { aborted: false }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { COACH_OUTBOUND_RISK_BLOCK_EN } from "@/lib/ai/coach/outbound-guard";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** The reply the model "returned" flows through the sentinel parsers verbatim. */
|
||||
function echo(content: string): void {
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose: content,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose: content });
|
||||
}
|
||||
|
||||
/** Drive a TOOL turn returning `content` with the given tool payloads. */
|
||||
async function toolTurn(
|
||||
content: string,
|
||||
toolResults: Array<{ present: boolean; data: unknown }> = [],
|
||||
): Promise<string> {
|
||||
echo(content);
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
runCoachToolLoop.mockResolvedValue({
|
||||
result: { content, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
toolResults,
|
||||
});
|
||||
const res = await post(chatReq({ message: "How is my BP?" }));
|
||||
await sse.done;
|
||||
expect(res.status).toBe(200);
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (assistant?.[0] as { content: string }).content;
|
||||
}
|
||||
|
||||
/** Drive a NO-TOOL turn (a provider lacking tools) returning `content`. */
|
||||
async function noToolTurn(content: string): Promise<string> {
|
||||
echo(content);
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
{ providerType: "local", instance: { supportsTools: false } },
|
||||
]);
|
||||
runStreamingRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content, tokensUsed: 42, model: "m" },
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
});
|
||||
const res = await post(chatReq({ message: "How is my BP?" }));
|
||||
await sse.done;
|
||||
expect(res.status).toBe(200);
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (assistant?.[0] as { content: string }).content;
|
||||
}
|
||||
|
||||
describe("coach chat — Guard I at the route level (G1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
|
||||
detectRefusal.mockReturnValue({ refuse: false });
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
assertConsentForChain.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("replaces a fabricated risk assertion end-to-end (tool turn)", async () => {
|
||||
const content =
|
||||
"Based on SCORE2, your risk sits at about 14% given your numbers.";
|
||||
const out = await toolTurn(content, [
|
||||
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
|
||||
]);
|
||||
expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN);
|
||||
expect(out).not.toContain("14%");
|
||||
});
|
||||
|
||||
it("replaces a fabricated risk assertion end-to-end (no-tool turn)", async () => {
|
||||
const out = await noToolTurn(
|
||||
"Your ten-year cardiovascular risk is roughly twelve percent.",
|
||||
);
|
||||
expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN);
|
||||
expect(out).not.toContain("twelve percent");
|
||||
});
|
||||
|
||||
it("streams a model-perfect refusal intact — it is not blocked or mangled (tool turn)", async () => {
|
||||
const content =
|
||||
"I can't calculate a 10-year cardiovascular risk for you — an ASCVD score is something your clinician computes with lab values I don't have.";
|
||||
const out = await toolTurn(content, [
|
||||
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
|
||||
]);
|
||||
expect(out).toBe(content);
|
||||
expect(out).not.toContain("[…]");
|
||||
});
|
||||
|
||||
it("streams dates, ranges and thousands unmangled when they are grounded (tool turn)", async () => {
|
||||
const content =
|
||||
"On 2026-07-23 your systolic sat between 120 and 135 mmHg, with about 10,000 steps logged.";
|
||||
const out = await toolTurn(content, [
|
||||
{
|
||||
present: true,
|
||||
data: { metric: "bp", section: { aggregate: { min: 121, max: 134 } } },
|
||||
},
|
||||
{
|
||||
present: true,
|
||||
data: { metric: "steps", section: { aggregate: { latest: 10000 } } },
|
||||
},
|
||||
]);
|
||||
expect(out).toBe(content);
|
||||
expect(out).not.toContain("[…]");
|
||||
});
|
||||
|
||||
it("soft-strips a genuinely fabricated figure on a tool turn (the floor still holds)", async () => {
|
||||
const content = "Your systolic averaged about 138 recently.";
|
||||
const out = await toolTurn(content, [
|
||||
{
|
||||
present: true,
|
||||
data: { metric: "bp", section: { aggregate: { avgSys30: 128 } } },
|
||||
},
|
||||
]);
|
||||
expect(out).toContain("[…]");
|
||||
expect(out).not.toContain("138");
|
||||
});
|
||||
});
|
||||
@@ -405,7 +405,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
const content = (assistantCall?.[0] as { content: string }).content;
|
||||
expect(content).toContain("[unverified]");
|
||||
expect(content).toContain("[…]");
|
||||
expect(content).not.toContain("138");
|
||||
});
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
const content = (assistantCall?.[0] as { content: string }).content;
|
||||
expect(content).not.toContain("[unverified]");
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).toContain("42");
|
||||
expect(content).toContain("320");
|
||||
});
|
||||
@@ -534,7 +534,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
const content = (assistantCall?.[0] as { content: string }).content;
|
||||
expect(content).not.toContain("[unverified]");
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).toContain("130");
|
||||
});
|
||||
|
||||
|
||||
@@ -103,9 +103,14 @@ import {
|
||||
} from "@/lib/ai/coach/tools";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import {
|
||||
findUnverifiedCoachNumbers,
|
||||
findUnverifiedCoachNumbersInLedger,
|
||||
stripUnverifiedNumbers,
|
||||
} from "@/lib/ai/coach/coach-prose-grounding";
|
||||
import {
|
||||
buildGroundingLedger,
|
||||
figuresForPersistence,
|
||||
} from "@/lib/ai/coach/grounding-ledger";
|
||||
import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses";
|
||||
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
|
||||
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
|
||||
@@ -270,6 +275,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// ── Conversation resolution ──────────────────────────────────
|
||||
let workingConversationId: string;
|
||||
let priorTurns: CoachTurn[] = [];
|
||||
// v1.32.9 (Coach Guard II / G2) — cross-turn Grounding Ledger sources. Prior
|
||||
// USER messages (numbers the user themselves stated) and the persisted
|
||||
// per-turn tool figures of prior turns (`groundedFigures` — server-computed
|
||||
// magnitudes, NEVER the assistant's prose, per D3). Both feed reconciliation
|
||||
// so a figure recalled a turn later is not stripped, without a laundering
|
||||
// path through assistant narration.
|
||||
let priorUserMessages: string[] = [];
|
||||
let priorToolFigures: number[] = [];
|
||||
// v1.11.1 — rolling summary of the elided older turns, read stale-while-
|
||||
// revalidate; null for a fresh conversation or when none is on file.
|
||||
let priorSummary: string | null = null;
|
||||
@@ -316,6 +329,16 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
// Ledger cross-turn sources (D3-safe): user-authored numbers + the
|
||||
// persisted tool figures of prior turns. Assistant PROSE is never read.
|
||||
priorUserMessages = existing.messages
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => m.content);
|
||||
priorToolFigures = existing.messages.flatMap((m) =>
|
||||
m.metricSource?.groundedFigures
|
||||
? [...m.metricSource.groundedFigures]
|
||||
: [],
|
||||
);
|
||||
|
||||
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
|
||||
// only on the inbound `message` per turn, so an injection that
|
||||
@@ -409,6 +432,11 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// v1.16.0 — composed self-context: structured questionnaire fields
|
||||
// plus age/gender merged in from the User profile.
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
// v1.32.9 (Coach Guard II / G3) — the user's active medication doses, for the
|
||||
// Grounding Ledger's `schedule` source AND the schedule-gated dose
|
||||
// continuation exemption. Fail-open: a read failure yields an empty set, which
|
||||
// keeps the Guard I phrase-anchored dose behaviour.
|
||||
const scheduleDoses = await getScheduledDoseValues(userId).catch(() => []);
|
||||
// v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so
|
||||
// the two memory/action clauses are appended at assembly time here: one
|
||||
// teaches the model to emit `---REMEMBER---` (durable "remind me" capture),
|
||||
@@ -890,7 +918,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// the system-prompt GLP-1/grounding contracts. On a trip the turn is
|
||||
// replaced with a calm, grounded fallback and any reminder suggestion /
|
||||
// key-value provenance is dropped — the user never sees the unsafe text.
|
||||
const outbound = screenCoachReply(replyText, locale);
|
||||
const outbound = screenCoachReply(replyText, locale, scheduleDoses);
|
||||
if (outbound.block && outbound.reason) {
|
||||
replyText = coachOutboundFallback(outbound.reason, locale);
|
||||
annotate({
|
||||
@@ -909,7 +937,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross-
|
||||
// check every number the model cited against this turn's authoritative figure
|
||||
// set; an unmatched number (transcription / paraphrase drift) is soft-stripped
|
||||
// to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there
|
||||
// to the editorial elision mark `[…]` and annotated (v1.32.14 — the withheld
|
||||
// count also rides the provenance envelope to drive the per-message notice).
|
||||
// Cheap, non-blocking, and a no-op when there
|
||||
// is no authoritative set — the prompt-level grounding rule remains the
|
||||
// backstop, exactly like the briefing's "no signals → skip". A blocked turn
|
||||
// already carries canned fallback prose, so skip it.
|
||||
@@ -919,31 +949,67 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// (`snapshot.sections`, which already carries the correlations-snapshot block).
|
||||
// Exactly one is populated per turn; the grading, tolerance, and exemptions are
|
||||
// identical, so a number the model invents is flagged the same way on both.
|
||||
const authoritativePayloads =
|
||||
// v1.32.9 (Coach Guard II / G2+G3) — grade the prose against the typed
|
||||
// Grounding Ledger rather than a per-turn magnitude bag. Activation is
|
||||
// unchanged from Guard I: the ledger is graded ONLY when THIS turn delivered
|
||||
// fresh figures the model was told to ground against (a present tool result
|
||||
// / pinned workout, or the full snapshot on the no-tools path). The
|
||||
// cross-turn, memory, schedule, reference, and guided sources only WIDEN an
|
||||
// active grading — they never ACTIVATE it, so a snapshot figure the model
|
||||
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
|
||||
// holds). Assistant prose is never a ledger source (D3).
|
||||
const activatingPayloads =
|
||||
toolResultPayloads.length > 0
|
||||
? toolResultPayloads
|
||||
: noToolsSnapshotPayloads;
|
||||
if (!outbound.block && authoritativePayloads.length > 0) {
|
||||
const unverified = findUnverifiedCoachNumbers(
|
||||
replyText,
|
||||
authoritativePayloads,
|
||||
);
|
||||
if (unverified.length > 0) {
|
||||
const { prose: corrected, stripped } = stripUnverifiedNumbers(
|
||||
let groundedFigures: number[] = [];
|
||||
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
|
||||
// elision mark). Hoisted out of the guard block so it can ride the provenance
|
||||
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
|
||||
// (the guard is skipped, its fallback prose carries no figures).
|
||||
let unverifiedStripped = 0;
|
||||
if (activatingPayloads.length > 0) {
|
||||
const ledger = buildGroundingLedger({
|
||||
toolPayloads: activatingPayloads,
|
||||
priorToolFigures,
|
||||
priorUserMessages,
|
||||
// The self-context (goals / about-me) rides the system prompt on both
|
||||
// paths — the ledger's `memory` source.
|
||||
memoryTexts: aboutMe ? [aboutMe] : [],
|
||||
scheduleDoses,
|
||||
// Reference grounding rides the prompt only on the no-tools
|
||||
// full-snapshot path; the guided block rides both paths.
|
||||
referenceGrounding:
|
||||
!toolMode && includeFullSnapshot ? snapshot.referenceGrounding : null,
|
||||
guidedBlock: turnContext.guidedBlock,
|
||||
});
|
||||
// Persist THIS turn's tool figures (bare magnitudes) so a LATER turn can
|
||||
// recall them via the ledger — D3: the tool trace, never the prose.
|
||||
groundedFigures = figuresForPersistence(ledger);
|
||||
if (!outbound.block) {
|
||||
const unverified = findUnverifiedCoachNumbersInLedger(
|
||||
replyText,
|
||||
unverified,
|
||||
ledger,
|
||||
locale,
|
||||
);
|
||||
replyText = corrected;
|
||||
annotate({
|
||||
action: { name: "coach.prose.number_unverified" },
|
||||
meta: {
|
||||
flagged: unverified.length,
|
||||
stripped,
|
||||
// No raw values — just the count + truncated tokens for ops triage.
|
||||
tokens: unverified.slice(0, 6).map((u) => u.source),
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
if (unverified.length > 0) {
|
||||
const { prose: corrected, stripped } = stripUnverifiedNumbers(
|
||||
replyText,
|
||||
unverified,
|
||||
);
|
||||
replyText = corrected;
|
||||
unverifiedStripped = stripped;
|
||||
annotate({
|
||||
action: { name: "coach.prose.number_unverified" },
|
||||
meta: {
|
||||
flagged: unverified.length,
|
||||
stripped,
|
||||
// No raw values — just the count + truncated tokens for ops triage.
|
||||
tokens: unverified.slice(0, 6).map((u) => u.source),
|
||||
promptVersion: PROMPT_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,6 +1114,19 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
// v1.32.9 (Coach Guard II / G2) — persist THIS turn's tool figures so a
|
||||
// later turn's Grounding Ledger can recall them (D3-safe: tool trace, not
|
||||
// prose). Dropped on a blocked turn (its figures rode the discarded reply).
|
||||
...(!outbound.block && groundedFigures.length > 0
|
||||
? { groundedFigures }
|
||||
: {}),
|
||||
// v1.32.14 — the count of figures the grounding guard withheld this turn,
|
||||
// so the quiet "some figures couldn't be checked" notice renders under the
|
||||
// bubble and survives a conversation reload. Count only, no values. Omitted
|
||||
// when nothing was withheld.
|
||||
...(unverifiedStripped > 0
|
||||
? { unverifiedFigures: unverifiedStripped }
|
||||
: {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
|
||||
@@ -93,6 +93,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -157,6 +158,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
@@ -229,6 +231,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
},
|
||||
},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 1,
|
||||
@@ -285,6 +288,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
sys: [{ measuredAt: at, value: 125 }],
|
||||
dia: [{ measuredAt: at, value: 75 }],
|
||||
},
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: at,
|
||||
totalMeasurements: 2,
|
||||
@@ -324,6 +328,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }],
|
||||
dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }],
|
||||
},
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date("2026-05-14T21:50:00Z"),
|
||||
totalMeasurements: 2,
|
||||
@@ -350,6 +355,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
@@ -409,6 +415,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 0,
|
||||
@@ -456,6 +463,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
|
||||
@@ -491,6 +499,7 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
|
||||
const EMPTY_AGGREGATE = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
@@ -543,3 +552,278 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
|
||||
expect(freshBody.data.revalidating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.32.17 (R1) — mood × metric correlations must pair on the LOCAL
|
||||
// logging day. The mood series has been local-day-keyed since v1.32.12;
|
||||
// the three metric partners (sys BP, weight, resting pulse) were still
|
||||
// UTC-keyed, so for non-UTC users an evening reading landed a day off and
|
||||
// paired with the wrong mood entry. These tests run under CI's TZ=UTC and
|
||||
// carry tz-explicit fixtures so the assertions are host-TZ-independent.
|
||||
describe("GET /api/insights/comprehensive — mood × metric local-day pairing (v1.32.17)", () => {
|
||||
type MoodBody = {
|
||||
data: {
|
||||
moodBpScatterData: Array<{ mood: number; sysBP: number }>;
|
||||
moodWeightScatterData: Array<{ mood: number; weight: number }>;
|
||||
moodPulseScatterData: Array<{ mood: number; pulse: number }>;
|
||||
scatterData: Array<{ weight: number; sysBP: number }>;
|
||||
weightBpCorrelation: unknown;
|
||||
bpMedicationCorrelation: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function moodRollupDay(midnightZ: string, mean: number, count = 1) {
|
||||
return {
|
||||
userId: "user-comp-1",
|
||||
granularity: "DAY",
|
||||
bucketStart: new Date(midnightZ),
|
||||
count,
|
||||
mean,
|
||||
minScore: mean,
|
||||
maxScore: mean,
|
||||
sd: null,
|
||||
computedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionWithTz(tz: string) {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("Berlin: a mood + BP logged the same local evening now pair (previously dropped)", async () => {
|
||||
sessionWithTz("Europe/Berlin");
|
||||
// Mood logged 00:30 local on 2026-07-10 → rollup bucketStart is the
|
||||
// canonical local day at UTC midnight.
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
// 22:35Z = 00:35 Berlin on Jul 10 → local day 2026-07-10, but UTC
|
||||
// day 2026-07-09. The old UTC `dailyByType` key (below) would never
|
||||
// match the mood's local 2026-07-10 → the pair was dropped.
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: new Date("2026-07-09T22:35:00Z"), value: 128 }],
|
||||
dia: [],
|
||||
},
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
BLOOD_PRESSURE_SYS: [{ day: "2026-07-09", value: 128 }],
|
||||
},
|
||||
firstMeasurementAt: new Date("2026-07-09T22:35:00Z"),
|
||||
totalMeasurements: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as MoodBody;
|
||||
// Derived from the raw row in the user's tz → pairs on local 2026-07-10.
|
||||
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 128 }]);
|
||||
});
|
||||
|
||||
it("New York: the lag-1 offset is gone — each pair couples mood(D) with the metric logged local day D", async () => {
|
||||
sessionWithTz("America/New_York");
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([
|
||||
moodRollupDay("2026-07-10T00:00:00.000Z", 2),
|
||||
moodRollupDay("2026-07-11T00:00:00.000Z", 3),
|
||||
moodRollupDay("2026-07-12T00:00:00.000Z", 4),
|
||||
]);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
// Each sys row is at (D+1)T01:00Z = 21:00 local on day D (EDT UTC-4).
|
||||
// Old UTC code keyed each on D+1 and paired it with mood(D+1) — a
|
||||
// lag-1 correlation sold as same-day. Local derivation keys on D.
|
||||
bpRawRows: {
|
||||
sys: [
|
||||
{ measuredAt: new Date("2026-07-11T01:00:00Z"), value: 120 },
|
||||
{ measuredAt: new Date("2026-07-12T01:00:00Z"), value: 130 },
|
||||
{ measuredAt: new Date("2026-07-13T01:00:00Z"), value: 140 },
|
||||
],
|
||||
dia: [],
|
||||
},
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date("2026-07-11T01:00:00Z"),
|
||||
totalMeasurements: 3,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as MoodBody;
|
||||
// mood(07-10)=2 ↔ 120 (logged local 07-10), not 130 (the old +1 offset).
|
||||
expect(body.data.moodBpScatterData).toEqual([
|
||||
{ mood: 2, sysBP: 120 },
|
||||
{ mood: 3, sysBP: 130 },
|
||||
{ mood: 4, sysBP: 140 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Berlin: mood × weight and mood × resting pulse also pair on the local day (all three partners moved)", async () => {
|
||||
sessionWithTz("Europe/Berlin");
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
|
||||
// Resting-HR row at 00:30 local Jul 10 (= 22:30Z Jul 9).
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 58 }],
|
||||
);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
// Weight row at 00:30 local Jul 10 (= 22:30Z Jul 9).
|
||||
weightRawRows: [
|
||||
{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 80.5 },
|
||||
],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date("2026-07-09T22:30:00Z"),
|
||||
totalMeasurements: 2,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as MoodBody;
|
||||
expect(body.data.moodWeightScatterData).toEqual([
|
||||
{ mood: 4, weight: 80.5 },
|
||||
]);
|
||||
expect(body.data.moodPulseScatterData).toEqual([{ mood: 4, pulse: 58 }]);
|
||||
});
|
||||
|
||||
it("UTC user: pairing is byte-identical to the pre-fix UTC-slice join (invariance)", async () => {
|
||||
sessionWithTz("UTC");
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
// Under UTC, `userDayKey` degenerates to the ISO slice, so the raw
|
||||
// derivation reproduces exactly what the old `dailyByType` join gave.
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: new Date("2026-07-10T08:00:00Z"), value: 122 }],
|
||||
dia: [],
|
||||
},
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
BLOOD_PRESSURE_SYS: [{ day: "2026-07-10", value: 122 }],
|
||||
},
|
||||
firstMeasurementAt: new Date("2026-07-10T08:00:00Z"),
|
||||
totalMeasurements: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as MoodBody;
|
||||
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 122 }]);
|
||||
});
|
||||
|
||||
it("metric × metric guard: weight × BP and BP-med continuity are byte-identical across timezones (UTC joins untouched)", async () => {
|
||||
// R1 must NOT re-key the two internally-consistent UTC joins
|
||||
// (weight × BP, BP-med continuity) — those migrate with the rollup
|
||||
// tier in R12. Both read the UTC `dailyByType` / UTC `scheduledFor`
|
||||
// slice, so their output must be invariant to the caller's timezone.
|
||||
// Run the SAME fixture under UTC then America/New_York and deep-equal.
|
||||
const days = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
const aggregate = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
|
||||
day: d,
|
||||
value: 120 + i * 2,
|
||||
})),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
};
|
||||
const medication = {
|
||||
id: "med-bp",
|
||||
name: "Amlodipine",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
// Intake taken on the first three days, missed on the last two → a
|
||||
// non-degenerate continuity series.
|
||||
const intakeEvents = days.slice(0, 3).map((d) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${d}T07:00:00Z`),
|
||||
takenAt: new Date(`${d}T07:05:00Z`),
|
||||
skipped: false,
|
||||
}));
|
||||
|
||||
function primeMocks() {
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(aggregate);
|
||||
(
|
||||
prisma.medication.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([medication]);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(intakeEvents);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
});
|
||||
}
|
||||
|
||||
sessionWithTz("UTC");
|
||||
primeMocks();
|
||||
const utcBody = (await (await callGet(makeReq())).json()) as MoodBody;
|
||||
|
||||
// Fresh cache so the second run recomputes rather than serving the
|
||||
// cached UTC body under the same user id.
|
||||
__resetAllCachesForTests();
|
||||
|
||||
sessionWithTz("America/New_York");
|
||||
primeMocks();
|
||||
const nyBody = (await (await callGet(makeReq())).json()) as MoodBody;
|
||||
|
||||
expect(nyBody.data.scatterData).toEqual(utcBody.data.scatterData);
|
||||
expect(nyBody.data.weightBpCorrelation).toEqual(
|
||||
utcBody.data.weightBpCorrelation,
|
||||
);
|
||||
expect(nyBody.data.bpMedicationCorrelation).toEqual(
|
||||
utcBody.data.bpMedicationCorrelation,
|
||||
);
|
||||
// And the guard is meaningful only if these joins actually produced
|
||||
// output to compare.
|
||||
expect(utcBody.data.scatterData).toHaveLength(5);
|
||||
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -136,13 +137,17 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// warm-up so the next request lands on the rollup tier. Same posture
|
||||
// as `/api/mood/analytics`.
|
||||
//
|
||||
// The bucket key shape change is documented in the route-parity test
|
||||
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
|
||||
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
|
||||
// (write-time). For Berlin tenants whose mood log timestamps don't
|
||||
// straddle the UTC boundary the two labels agree on every realistic
|
||||
// entry — the v1.5 per-user-tz bucketing closes the residual DST
|
||||
// edge.
|
||||
// Since v1.32.12 the mood rollup writer mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label at UTC midnight, so
|
||||
// `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day —
|
||||
// byte-identical to the live fallback's `entry.date`. Both mood paths
|
||||
// therefore emit local-day keys. The mood × metric pairing partners
|
||||
// below (sys BP, weight, resting pulse) are consequently derived in the
|
||||
// user's tz (v1.32.17) so both join sides share ONE local-day key space.
|
||||
// The measurement-rollup tier itself still buckets UTC (`dailyByType`)
|
||||
// and migrates to local in a documented follow-up release; the two
|
||||
// internally-consistent UTC joins (weight × BP, BP-med continuity) are
|
||||
// left on it deliberately (see their call sites below).
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
|
||||
let dailyMoodEntries: Array<{ day: string; value: number }>;
|
||||
@@ -247,6 +252,13 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// 24-hour default tolerance — daily-mean buckets are the
|
||||
// SQL-side equivalent and the directive explicitly accepts the
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
//
|
||||
// v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH
|
||||
// sides come from `dailyByType` (weight × sys), so they share one UTC
|
||||
// day-key space and are internally consistent. Re-keying it to local
|
||||
// is R12's read-swap (it migrates with the rollup tier), NOT this
|
||||
// hotfix — doing it here would mean per-request raw re-aggregation
|
||||
// without the local-bucketed rollup behind it.
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
|
||||
@@ -257,14 +269,26 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
}));
|
||||
|
||||
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
|
||||
// Daily-key matches the legacy `buildMoodMetricPairs` exactly —
|
||||
// mood is already per-day in the legacy code and we just align the
|
||||
// metric side to the same daily bucket.
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily);
|
||||
// v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the
|
||||
// rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD
|
||||
// key, so the metric partners MUST also be local-day-keyed or every
|
||||
// evening logger west of UTC gets a lag-1 pairing sold as same-day.
|
||||
// Derive the sys / weight partners as exact local-day means from the raw
|
||||
// rows already on the request path (canonical-source deduped in the
|
||||
// aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be
|
||||
// relabeled into a local-day mean. `round2` mirrors the aggregator's
|
||||
// `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
|
||||
const weightDailyLocal = deriveDailyLocalMeans(
|
||||
aggregate.weightRawRows,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
|
||||
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily);
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
|
||||
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
@@ -278,23 +302,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the
|
||||
// low-percentile daily proxy) when the user has no resting rows — the
|
||||
// proxy needs the per-day distribution, which the day-mean discards.
|
||||
// Day keys follow this route's UTC-bucket convention (the per-user-tz
|
||||
// bucketing is the documented v1.5 follow-up) so the resting series pairs
|
||||
// against the same UTC day keys as the mood series.
|
||||
//
|
||||
// v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)`
|
||||
// to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but
|
||||
// `dailyMoodEntries` below (this series' ONLY pairing partner, joined by
|
||||
// an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored —
|
||||
// it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`,
|
||||
// a real UTC aggregation boundary, not just a display label. Moving ONLY
|
||||
// this side to the user's tz would not fix anything: it would swap
|
||||
// "evening readings shift a day relative to the true local day" for
|
||||
// "evening readings mismatch their mood pairing partner, which used to
|
||||
// line up precisely because both sides shared the same UTC convention."
|
||||
// A genuine fix needs the mood-rollup tier itself to bucket per-user-tz
|
||||
// (the same v1.5 follow-up the comment above already flags) — out of
|
||||
// scope for this pass. Left UTC on both sides intentionally.
|
||||
// v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY
|
||||
// pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since
|
||||
// v1.32.12 (the mood rollup writer now mints `bucketStart` from the
|
||||
// canonical local `MoodEntry.date` label, not a UTC truncation). Keeping
|
||||
// UTC here would offset every evening logger west of UTC by one day and
|
||||
// sell a lag-1 correlation as same-day. This is exactly the flip the
|
||||
// v1.30.3 F8 note said becomes correct once the mood side goes local —
|
||||
// it now has. (Unlike the mood side, this series builds from raw rows in
|
||||
// this route, so it re-keys directly with no rollup-tier change.)
|
||||
const restingPulseRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -316,15 +333,15 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
select: { measuredAt: true, value: true },
|
||||
})
|
||||
: [];
|
||||
const utcDayKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const localDayKey = (d: Date) => userDayKey(d, userTz);
|
||||
const { series: restingPulseSeries } = resolveRestingPulseSeries({
|
||||
restingSamples: restingPulseRows,
|
||||
pulseSamples: rawPulseRowsForResting,
|
||||
dayKeyOf: utcDayKey,
|
||||
dayKeyOf: localDayKey,
|
||||
});
|
||||
const restingByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const s of restingPulseSeries) {
|
||||
const key = utcDayKey(s.measuredAt);
|
||||
const key = localDayKey(s.measuredAt);
|
||||
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += s.value;
|
||||
cur.count += 1;
|
||||
@@ -458,14 +475,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
// v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the
|
||||
// aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below
|
||||
// MUST key on the same UTC day, not the user's own tz — pairing the
|
||||
// two on different day spaces would silently drop pairs rather than
|
||||
// fix anything. Making this whole correlation user-tz-consistent
|
||||
// needs the aggregator's `dailyByType` to bucket in the user's tz
|
||||
// first (a bigger, already-flagged follow-up); until then this stays
|
||||
// UTC to match its only pairing partner.
|
||||
// v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's
|
||||
// `dailyByType`) and `takenByDay` below (intake `scheduledFor`
|
||||
// truncated to UTC) both live in the SAME UTC day-key space and are
|
||||
// internally consistent with each other — this join is NOT broken and
|
||||
// stays as is. Only the mood pairings above moved to a separately
|
||||
// derived local-day series (their partner, the mood series, is
|
||||
// local-day-keyed). This correlation migrates to local when the
|
||||
// measurement-rollup tier buckets per-user tz (R12), not in this hotfix
|
||||
// — re-keying it here without the local-bucketed rollup would mean
|
||||
// per-request raw re-aggregation.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
@@ -565,6 +584,40 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* v1.32.17 — derive exact local-day mean series from raw measurement rows,
|
||||
* keyed by the user's timezone via `userDayKey`. The mood series is
|
||||
* local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an
|
||||
* exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too.
|
||||
* A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day
|
||||
* mean, so the mean is recomputed here from raw rows already on the request
|
||||
* path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no
|
||||
* offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly
|
||||
* and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the
|
||||
* aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift.
|
||||
* Returns `{ day, value }[]` sorted ASC by day, the shape
|
||||
* `pairMoodWithDaily` expects.
|
||||
*/
|
||||
function deriveDailyLocalMeans(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): Array<{ day: string; value: number }> {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of rows) {
|
||||
const key = userDayKey(r.measuredAt, tz);
|
||||
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.value;
|
||||
cur.count += 1;
|
||||
byDay.set(key, cur);
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `pearsonCorrelation`.
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
@@ -682,3 +683,91 @@ describe("DELETE /api/insights/layout", () => {
|
||||
expect(call.data.insightsLayoutJson).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.21 (R5a) — optimistic concurrency (issue #581 family). A PUT carrying
|
||||
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
|
||||
* still carrying that token, so an interleaved edit-mode Save and Settings
|
||||
* pill-order Save can't clobber one another. A tokenless PUT keeps the prior
|
||||
* unconditional write — the iOS-compat arm.
|
||||
*/
|
||||
describe("PUT /api/insights/layout — optimistic concurrency", () => {
|
||||
function body(baseUpdatedAt?: string) {
|
||||
return {
|
||||
version: 2,
|
||||
tiles: [{ id: "overview", visible: true, order: 0 }],
|
||||
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("guards the write on the base token and returns the advanced token", async () => {
|
||||
const base = new Date("2026-07-24T10:00:00.000Z");
|
||||
const advanced = new Date("2026-07-24T10:05:00.000Z");
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
// The merge-read `previous` (no row) then the post-write token read.
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({ insightsLayoutJson: null } as never)
|
||||
.mockResolvedValueOnce({ updatedAt: advanced } as never);
|
||||
|
||||
const res = await callPut(makeReq(body(base.toISOString())));
|
||||
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(base.toISOString());
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
|
||||
const bodyJson = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(bodyJson.data.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
|
||||
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
// Writer B committed first; writer A's guarded write matches zero rows.
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await callPut(makeReq(body("2026-07-24T09:00:00.000Z")));
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const bodyJson = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(bodyJson.data).toBeNull();
|
||||
expect(bodyJson.meta?.errorCode).toBe("insights_layout_conflict");
|
||||
|
||||
// Guarded write matched no row; no unconditional fallback → nothing written.
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await callPut(makeReq(body("not-a-date")));
|
||||
expect(res.status).toBe(422);
|
||||
const bodyJson = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(bodyJson.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unconditional write when the client omits the token (iOS compat)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPut(makeReq(body()));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
const bodyJson = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(bodyJson.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -23,13 +23,18 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
} from "@/lib/insights-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -95,12 +100,21 @@ const layoutSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
|
||||
async function buildInsightsLayout(
|
||||
userId: string,
|
||||
): Promise<InsightsLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { insightsLayoutJson: true },
|
||||
// v1.32.21 (R5a) — `updatedAt` rides the cached layout so GET hands the
|
||||
// client the optimistic-concurrency token alongside the layout it applies
|
||||
// to. Cached together (and busted on every write) so the (layout, token)
|
||||
// pair a client reads is always consistent — the widgets precedent.
|
||||
select: { insightsLayoutJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveInsightsLayout(row?.insightsLayoutJson);
|
||||
return {
|
||||
...resolveInsightsLayout(row?.insightsLayoutJson),
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -110,7 +124,7 @@ export const GET = apiHandler(async () => {
|
||||
// changes only when the user hits the Settings save button, which
|
||||
// invalidates this cache via `invalidateUserInsightsLayout()`.
|
||||
const layout = await cached(
|
||||
caches.insightsLayout as ServerCache<InsightsLayout>,
|
||||
caches.insightsLayout as ServerCache<InsightsLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildInsightsLayout(user.id),
|
||||
annotate,
|
||||
@@ -121,11 +135,18 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets route so
|
||||
@@ -179,12 +200,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const previous = await buildInsightsLayout(user.id);
|
||||
const normalized = serializeInsightsLayout(parsed.data, previous);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
insightsLayoutJson: toJson(normalized),
|
||||
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
|
||||
// 409s and writes NOTHING (so an interleaved edit-mode Save can't be
|
||||
// reverted by a Settings pill-order Save that started from an older read,
|
||||
// and vice versa); a tokenless request keeps the prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { insightsLayoutJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "insights.layout.conflict",
|
||||
errorCode: "insights_layout_conflict",
|
||||
message: "Insights layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.layout.update" },
|
||||
@@ -193,7 +223,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserInsightsLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: guarded.updatedAt,
|
||||
} satisfies InsightsLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// webhook 200 response (the rollup is a cache tier, the source-of-
|
||||
// truth is the mood_entries write that already committed).
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
|
||||
await recomputeMoodBucketsForEntry(user.id, entry.date);
|
||||
} catch (rollupErr) {
|
||||
getEvent()?.addWarning(
|
||||
"Mood rollup recompute failed: " +
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* v1.32.8 (iOS #66) — the optional per-request `syncTrigger` on the HealthKit
|
||||
* batch ingest.
|
||||
*
|
||||
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
|
||||
* so an operator can see, per batch, what woke the client (foreground app open,
|
||||
* background refresh, or a push), and NOTHING else. These pin that contract:
|
||||
*
|
||||
* - a batch carrying `syncTrigger:"background"` is accepted and the value
|
||||
* rides the ingest annotation;
|
||||
* - a batch WITHOUT it is accepted and the annotation carries `null`;
|
||||
* - an invalid value is a 422 (closed enum);
|
||||
* - the field never changes which rows are inserted / updated — the per-row
|
||||
* outcomes are byte-identical with and without it, so it cannot leak into
|
||||
* dedup, attribution, or storage.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (fn as any)(prisma as unknown as { measurement: unknown });
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: mocks.annotate };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: async (
|
||||
tx: {
|
||||
measurement: {
|
||||
findMany: (args: unknown) => Promise<
|
||||
Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
source?: string;
|
||||
externalId?: string | null;
|
||||
}>
|
||||
>;
|
||||
createManyAndReturn: (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
updateMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown> & {
|
||||
userId: string;
|
||||
type: string;
|
||||
source: string;
|
||||
externalId: string;
|
||||
},
|
||||
options: { exactExternalMatch?: "update" | "duplicate" } = {},
|
||||
) => {
|
||||
const existing = await tx.measurement.findMany({});
|
||||
const exact = existing.find(
|
||||
(row: { type?: string; source?: string; externalId?: string | null }) =>
|
||||
row.type === input.type &&
|
||||
row.source === input.source &&
|
||||
row.externalId === input.externalId,
|
||||
);
|
||||
if (exact && options.exactExternalMatch !== "update") {
|
||||
return { status: "duplicate", row: exact };
|
||||
}
|
||||
if (exact) {
|
||||
await tx.measurement.updateMany({
|
||||
where: { id: exact.id },
|
||||
data: { ...input, deletedAt: null },
|
||||
});
|
||||
return { status: "updated", row: { ...exact, ...input } };
|
||||
}
|
||||
const [inserted] = await tx.measurement.createManyAndReturn({
|
||||
data: input,
|
||||
});
|
||||
if (inserted) return { status: "inserted", row: inserted };
|
||||
return { status: "duplicate" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
collapseToTypeDayKeys: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
|
||||
const STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function sampleEntry(externalId: string, value: number) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value,
|
||||
unit: "count/min",
|
||||
startDate: "2026-06-21T14:00:00.000Z",
|
||||
endDate: "2026-06-21T14:00:01.000Z",
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
function stepsEntry(externalId: string, value: number) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value,
|
||||
unit: "count",
|
||||
startDate: "2026-06-21T00:00:00.000Z",
|
||||
endDate: "2026-06-21T23:59:59.000Z",
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
function ingestMeta(): Record<string, unknown> {
|
||||
const call = mocks.annotate.mock.calls.find(
|
||||
([arg]) =>
|
||||
(arg as { action?: { name?: string } })?.action?.name ===
|
||||
"measurement.batch.ingest",
|
||||
);
|
||||
return (call?.[0] as { meta: Record<string, unknown> }).meta;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
|
||||
args: unknown,
|
||||
) => {
|
||||
const { data } = args as {
|
||||
data:
|
||||
| { type: unknown; source: string; externalId: string | null }
|
||||
| Array<{ type: unknown; source: string; externalId: string | null }>;
|
||||
};
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
return rows.map(({ type, source, externalId }) => ({
|
||||
type,
|
||||
source,
|
||||
externalId,
|
||||
}));
|
||||
}) as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () => {
|
||||
it("records `syncTrigger` on the ingest annotation when the client sends it", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [sampleEntry(SAMPLE_ID, 64)],
|
||||
syncTrigger: "background",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(ingestMeta().syncTrigger).toBe("background");
|
||||
});
|
||||
|
||||
it("accepts a batch WITHOUT `syncTrigger` and annotates null", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(ingestMeta().syncTrigger).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects an invalid `syncTrigger` value with 422 (closed enum)", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [sampleEntry(SAMPLE_ID, 64)],
|
||||
syncTrigger: "wifi",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("does not change which rows insert / update with vs without the field", async () => {
|
||||
// A pre-existing per-day steps row makes the re-post an overwrite; a fresh
|
||||
// sample row inserts. The per-row outcome must be identical regardless of
|
||||
// whether `syncTrigger` rides the request.
|
||||
const seedExisting = () =>
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: STATS_ID,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const entries = [sampleEntry(SAMPLE_ID, 64), stepsEntry(STATS_ID, 5000)];
|
||||
|
||||
seedExisting();
|
||||
const withoutRes = await POST(makeRequest({ entries }));
|
||||
expect(withoutRes.status).toBe(200);
|
||||
const withoutBody = (await withoutRes.json()).data;
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation(
|
||||
(async (args: unknown) => {
|
||||
const { data } = args as { data: unknown };
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
return rows.map((r) => {
|
||||
const row = r as {
|
||||
type: unknown;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
};
|
||||
return {
|
||||
type: row.type,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
};
|
||||
});
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
|
||||
seedExisting();
|
||||
const withRes = await POST(makeRequest({ entries, syncTrigger: "push" }));
|
||||
expect(withRes.status).toBe(200);
|
||||
const withBody = (await withRes.json()).data;
|
||||
|
||||
// The row-level outcome (what inserted, what updated, per-entry statuses)
|
||||
// is byte-identical; only the diagnostic annotation differs.
|
||||
expect(withBody.inserted).toBe(withoutBody.inserted);
|
||||
expect(withBody.updated).toBe(withoutBody.updated);
|
||||
expect(withBody.duplicates).toBe(withoutBody.duplicates);
|
||||
expect(withBody.entries).toEqual(withoutBody.entries);
|
||||
expect(withoutBody.updated).toBe(1);
|
||||
expect(withoutBody.inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -159,8 +159,18 @@ const batchEntrySchema = z.object({
|
||||
deviceType: deviceTypeEnum.nullable().optional(),
|
||||
});
|
||||
|
||||
// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the
|
||||
// client for this sync. Purely observational: it flows into the
|
||||
// `measurement.batch.ingest` wide event and NOTHING else — never persisted,
|
||||
// never part of the dedup key, never an input to attribution or how a sample is
|
||||
// stored. Only the client knows its own wake reason, so it is client-asserted
|
||||
// by necessity; being diagnostic-only, that carries no trust surface. Optional
|
||||
// so every pre-#66 caller stays byte-for-byte unchanged.
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
syncTrigger: syncTriggerEnum.optional(),
|
||||
});
|
||||
|
||||
type BatchEntry = z.infer<typeof batchEntrySchema>;
|
||||
@@ -248,7 +258,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// First pass — map each inbound entry; record skipped entries.
|
||||
type Prepared = {
|
||||
@@ -784,6 +794,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// `skippedByReason` build above.
|
||||
skipped_by_reason: skippedByReason,
|
||||
failed: failedCount,
|
||||
// v1.32.8 (iOS #66) — diagnostic sync-trigger tag (foreground /
|
||||
// background / push), or null when the client did not send one.
|
||||
// Observability only; it never influenced any count above.
|
||||
syncTrigger: syncTrigger ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -201,6 +201,56 @@ describe("GET /api/medications/[id]/dose-history", () => {
|
||||
expect(adHoc?.intake?.id).toBe("evt-adhoc");
|
||||
});
|
||||
|
||||
it("returns each intake's write provenance in `source` (iOS #64)", async () => {
|
||||
const from = at(DAY, 0, 0).toISOString();
|
||||
const to = at(DAY, 23, 59).toISOString();
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
// a native-app take carries API provenance
|
||||
{
|
||||
id: "evt-morning",
|
||||
scheduledFor: at(DAY, 7, 0),
|
||||
takenAt: at(DAY, 7, 5),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
source: "API",
|
||||
},
|
||||
// a legacy row written before the column carried a value → null
|
||||
{
|
||||
id: "evt-adhoc",
|
||||
scheduledFor: at(DAY, 11, 29),
|
||||
takenAt: at(DAY, 11, 29),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
source: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
// The route must SELECT `source` from Prisma, or it could never surface it.
|
||||
const res = await GET(
|
||||
getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const select = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0][0]?.select as Record<string, unknown> | undefined;
|
||||
expect(select?.source).toBe(true);
|
||||
|
||||
const json = await res.json();
|
||||
const rows = json.data.rows as Array<{
|
||||
kind: string;
|
||||
timeOfDay: string | null;
|
||||
intake: { id: string | null; source: string | null } | null;
|
||||
}>;
|
||||
|
||||
const morning = rows.find((r) => r.timeOfDay === "07:00");
|
||||
expect(morning?.intake?.id).toBe("evt-morning");
|
||||
expect(morning?.intake?.source).toBe("API");
|
||||
|
||||
const adHoc = rows.find((r) => r.kind === "ad_hoc");
|
||||
expect(adHoc?.intake?.id).toBe("evt-adhoc");
|
||||
expect(adHoc?.intake?.source).toBe(null);
|
||||
});
|
||||
|
||||
it("422 on a reversed from/to window", async () => {
|
||||
const from = at(DAY, 12, 0).toISOString();
|
||||
const to = at(DAY, 6, 0).toISOString();
|
||||
|
||||
@@ -87,6 +87,8 @@ interface SerializedDoseHistoryRow {
|
||||
autoMissed: boolean;
|
||||
/** v1.16.4 — per-intake dose override; null = configured dose. */
|
||||
doseTaken: string | null;
|
||||
/** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH" | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -161,6 +163,9 @@ export const GET = apiHandler(
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -172,6 +177,7 @@ export const GET = apiHandler(
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.attributionSource === "USER_PIN",
|
||||
doseTaken: e.doseTaken,
|
||||
source: e.source,
|
||||
}));
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
@@ -244,6 +250,7 @@ export const GET = apiHandler(
|
||||
autoMissed: e.autoMissed,
|
||||
pinned: e.pinned,
|
||||
doseTaken: e.doseTaken,
|
||||
source: e.source,
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
@@ -269,6 +276,7 @@ export const GET = apiHandler(
|
||||
skipped: row.intake.skipped,
|
||||
autoMissed: row.intake.autoMissed ?? false,
|
||||
doseTaken: row.intake.doseTaken ?? null,
|
||||
source: row.intake.source ?? null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* v1.32.8 (iOS #64) — write-provenance of a medication intake is derived from
|
||||
* the TRANSPORT, never from the request body.
|
||||
*
|
||||
* - a cookie (browser) POST records `source: "WEB"`;
|
||||
* - a Bearer (native app) POST records `source: "API"`;
|
||||
* - a body that tries to set `source` is ignored (no mass assignment);
|
||||
* - the producer-owned values (`REMINDER` / `IMPORT` / `APPLE_HEALTH`) are
|
||||
* minted by OTHER routes and are unreachable here, so this route can only
|
||||
* ever stamp WEB / API — the cookie-vs-Bearer split is the whole surface.
|
||||
*
|
||||
* These run the REAL `apiHandler` + `requireAuth` (only `getSession`,
|
||||
* `next/headers`, and the Bearer resolver are mocked) so the auth-method
|
||||
* derivation that a unit mock would paper over is exercised end to end. The
|
||||
* medication has no schedules, so a taken write lands on the standalone
|
||||
* (ad-hoc) create path where `source` is stamped directly.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
|
||||
// The medication carries no schedules, so a taken write resolves to no
|
||||
// canonical slot and lands on the standalone create path. Stub the resolvers
|
||||
// so the route deterministically takes that branch regardless of the fixtures.
|
||||
vi.mock("@/lib/medications/scheduling/slot-upsert", () => ({
|
||||
resolveSlotForWriteByBand: vi.fn(async () => ({ slotInstant: null })),
|
||||
resolveSlotInstantForWrite: vi.fn(async () => null),
|
||||
resolveForcedSlotForWrite: vi.fn(async () => null),
|
||||
findPinConflict: vi.fn(async () => false),
|
||||
mayConvergeOntoSuppliedSlot: vi.fn(() => true),
|
||||
applyCanonicalSlotWrite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/lifecycle", () => ({
|
||||
reconcileOneShotState: vi.fn().mockResolvedValue("noop"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Default to no Authorization header; the Bearer tests override this.
|
||||
const headerStore = { authorization: null as string | null };
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "authorization" ? headerStore.authorization : null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveBearerToken } from "@/lib/auth/bearer";
|
||||
|
||||
const USER = {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: USER,
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
|
||||
|
||||
function postReq(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** The `source` value stamped on the freshly-created standalone intake row. */
|
||||
function createdSource(): unknown {
|
||||
const call = vi.mocked(prisma.medicationIntakeEvent.create).mock.calls[0];
|
||||
return (call?.[0] as { data?: { source?: unknown } } | undefined)?.data
|
||||
?.source;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
headerStore.authorization = null;
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// The standalone branch wraps the create in a `$transaction([...])`. The
|
||||
// create mock records the `data` it was called with; the transaction
|
||||
// resolves to the created row so the rest of the handler proceeds.
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockReturnValue({
|
||||
id: "evt-1",
|
||||
takenAt: new Date(),
|
||||
scheduledFor: new Date(),
|
||||
} as never);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([
|
||||
{ id: "evt-1", takenAt: new Date(), scheduledFor: new Date() },
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("POST intake — transport-derived source (iOS #64)", () => {
|
||||
it("stamps WEB for a cookie (browser) session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await POST(postReq({}), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(201);
|
||||
expect(createdSource()).toBe("WEB");
|
||||
});
|
||||
|
||||
it("stamps API for a Bearer (native app) token", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
headerStore.authorization = "Bearer hlk_test-token";
|
||||
vi.mocked(resolveBearerToken).mockResolvedValue({
|
||||
user: USER as never,
|
||||
tokenId: "tok-1",
|
||||
permissions: ["*"],
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
});
|
||||
|
||||
const res = await POST(postReq({}), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(201);
|
||||
expect(createdSource()).toBe("API");
|
||||
});
|
||||
|
||||
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
// A cookie caller forging `source: "APPLE_HEALTH"` must still record WEB —
|
||||
// the value is derived from the transport, never read from the body.
|
||||
const res = await POST(postReq({ source: "APPLE_HEALTH" }), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(201);
|
||||
expect(createdSource()).toBe("WEB");
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,16 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const { user } = await requireAuth();
|
||||
const { user, authMethod } = await requireAuth();
|
||||
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
|
||||
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
|
||||
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
|
||||
// routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint
|
||||
// `IMPORT` / `APPLE_HEALTH`), and this route only ever CREATES its own rows —
|
||||
// a converge onto a pending `REMINDER` row updates through
|
||||
// `applyCanonicalSlotWrite`, which never rewrites `source`, so that
|
||||
// provenance is preserved.
|
||||
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
@@ -316,7 +325,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
createSource: intakeSource,
|
||||
// v1.8.5 — resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
@@ -382,7 +391,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitTaken,
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
createSource: intakeSource,
|
||||
injectionSite: resolvedInjectionSite,
|
||||
attributionSource,
|
||||
doseTaken: resolvedDoseTaken,
|
||||
@@ -409,7 +418,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: "WEB",
|
||||
source: intakeSource,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -39,9 +40,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
|
||||
function hhmmToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
return hhmmToMinutesOrNull(value) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
@@ -152,6 +153,8 @@ describe("PUT /api/medications/layout — preserve-when-absent", () => {
|
||||
version: 1,
|
||||
view: "table",
|
||||
order: ["med-a", "med-b"],
|
||||
// v1.32.21 — the write now echoes the fresh optimistic-concurrency token.
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
@@ -275,3 +278,90 @@ describe("DELETE /api/medications/layout", () => {
|
||||
expect(call.data.medicationListLayoutJson).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.21 (R5a) — optimistic concurrency (issue #581 family). A PUT carrying
|
||||
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
|
||||
* still carrying that token, so a view toggle can never resurrect an order the
|
||||
* reorder Save just committed (and vice versa). A tokenless PUT keeps the prior
|
||||
* unconditional write — the iOS-compat arm.
|
||||
*/
|
||||
describe("PUT /api/medications/layout — optimistic concurrency", () => {
|
||||
// A full blob (view + order) so no preserve-read fires; the only findUnique
|
||||
// is the post-write token read.
|
||||
function fullBody(baseUpdatedAt?: string) {
|
||||
return {
|
||||
version: 1,
|
||||
view: "table",
|
||||
order: ["med-a", "med-b"],
|
||||
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
it("guards the write on the base token and returns the advanced token", async () => {
|
||||
const base = new Date("2026-07-24T10:00:00.000Z");
|
||||
const advanced = new Date("2026-07-24T10:05:00.000Z");
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
updatedAt: advanced,
|
||||
} as never);
|
||||
|
||||
const res = await callPut(makeReq(fullBody(base.toISOString())));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Conditional write on the exact base token — never unconditional.
|
||||
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(base.toISOString());
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
|
||||
const body = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(body.data.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
|
||||
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
|
||||
// Writer B committed first (updatedAt advanced); writer A replays with the
|
||||
// pre-B token → the conditional write matches zero rows.
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await callPut(makeReq(fullBody("2026-07-24T09:00:00.000Z")));
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("medication_layout_conflict");
|
||||
|
||||
// The conditional write matched no row; there is NO unconditional
|
||||
// fallback, so nothing was written — B's value survives.
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await callPut(makeReq(fullBody("not-a-date")));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unconditional write when the client omits the token (iOS compat)", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPut(makeReq(fullBody()));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
takeBaseToken,
|
||||
guardedUserUpdate,
|
||||
invalidBaseTokenError,
|
||||
} from "@/lib/optimistic-lock";
|
||||
import {
|
||||
resolveMedicationListLayout,
|
||||
serializeMedicationListLayout,
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
MEDICATION_LIST_VIEWS,
|
||||
MEDICATION_ORDER_ID_MAX_LENGTH,
|
||||
MEDICATION_ORDER_MAX_ENTRIES,
|
||||
type MedicationListLayout,
|
||||
type MedicationListLayoutWithToken,
|
||||
} from "@/lib/medication-list-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -55,12 +60,18 @@ const layoutSchema = z.object({
|
||||
|
||||
async function buildMedicationListLayout(
|
||||
userId: string,
|
||||
): Promise<MedicationListLayout> {
|
||||
): Promise<MedicationListLayoutWithToken> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { medicationListLayoutJson: true },
|
||||
// v1.32.21 (R5a) — `updatedAt` rides the cached blob so GET hands the
|
||||
// client the optimistic-concurrency token alongside the presentation it
|
||||
// applies to (busted on every write, so the pair stays consistent).
|
||||
select: { medicationListLayoutJson: true, updatedAt: true },
|
||||
});
|
||||
return resolveMedicationListLayout(row?.medicationListLayoutJson);
|
||||
return {
|
||||
...resolveMedicationListLayout(row?.medicationListLayoutJson),
|
||||
updatedAt: row?.updatedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
@@ -70,7 +81,7 @@ export const GET = apiHandler(async () => {
|
||||
// caches; the blob changes only on a view toggle or an order save,
|
||||
// which invalidates via `invalidateUserMedicationListLayout()`.
|
||||
const layout = await cached(
|
||||
caches.medicationListLayout as ServerCache<MedicationListLayout>,
|
||||
caches.medicationListLayout as ServerCache<MedicationListLayoutWithToken>,
|
||||
user.id,
|
||||
() => buildMedicationListLayout(user.id),
|
||||
annotate,
|
||||
@@ -81,11 +92,20 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
|
||||
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
|
||||
// BOTH writers (view toggle, order save) route through here, so the token
|
||||
// guards either against a stale interleave of the other.
|
||||
const taken = takeBaseToken(rawBody);
|
||||
if ("invalid" in taken) return invalidBaseTokenError();
|
||||
const base = taken.base;
|
||||
const body = taken.rest;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// Multi-issue 422 envelope matches the dashboard-widgets / insights
|
||||
@@ -144,10 +164,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
order: mergedOrder,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
|
||||
// 409s and writes NOTHING, so a view toggle can't resurrect an order the
|
||||
// reorder Save just committed (or vice versa); a tokenless request keeps the
|
||||
// prior unconditional write.
|
||||
const guarded = await guardedUserUpdate({
|
||||
userId: user.id,
|
||||
base,
|
||||
data: { medicationListLayoutJson: toJson(normalized) },
|
||||
conflict: {
|
||||
action: "medication.layout.conflict",
|
||||
errorCode: "medication_layout_conflict",
|
||||
message: "Medications layout changed since it was loaded",
|
||||
},
|
||||
});
|
||||
if ("conflict" in guarded) return guarded.conflict;
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.layout.update" },
|
||||
@@ -156,7 +187,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserMedicationListLayout(user.id);
|
||||
|
||||
return apiSuccess(normalized);
|
||||
return apiSuccess({
|
||||
...normalized,
|
||||
updatedAt: guarded.updatedAt,
|
||||
} satisfies MedicationListLayoutWithToken);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
|
||||
@@ -247,13 +247,16 @@ export const PUT = apiHandler(
|
||||
// (user, day) tuples) so we fan them out in parallel. Best-
|
||||
// effort: rollup failures must not surface as 5xx.
|
||||
try {
|
||||
const targets = new Set<number>([entry.moodLoggedAt.getTime()]);
|
||||
if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) {
|
||||
targets.add(existing.moodLoggedAt.getTime());
|
||||
// v1.32.12 — key on the `date` label. When the update moved the
|
||||
// entry across a local-day boundary its `date` changed, so both
|
||||
// the old and new labels need a recompute (independent buckets).
|
||||
const targets = new Set<string>([entry.date]);
|
||||
if (existing.date !== entry.date) {
|
||||
targets.add(existing.date);
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(targets).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
Array.from(targets).map((label) =>
|
||||
recomputeMoodBucketsForEntry(user.id, label),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
@@ -324,7 +327,7 @@ export const DELETE = apiHandler(
|
||||
// deleted entry's bucket; the recompute helper handles the
|
||||
// "now-empty day → drop the rollup row" branch internally.
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt);
|
||||
await recomputeMoodBucketsForEntry(user.id, existing.date);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -110,11 +110,11 @@ describe("POST /api/mood-entries/bulk-delete", () => {
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
|
||||
// 3 deleted rows across 2 distinct date labels → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ date: "2026-01-01" },
|
||||
{ date: "2026-01-01" },
|
||||
{ date: "2026-01-02" },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
|
||||
@@ -75,7 +75,7 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
// it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { moodLoggedAt: true },
|
||||
select: { date: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
@@ -101,21 +101,19 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
|
||||
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
|
||||
// one day fires one recompute, not one per row. Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
// v1.32.12 — collapse the deleted rows to the unique set of `date`
|
||||
// labels BEFORE recomputing so a bulk delete spanning one local day
|
||||
// fires one recompute, not one per row, and keys byte-identically to
|
||||
// the stored `MoodEntry.date`. Best-effort — a populator hiccup
|
||||
// never fails the user's delete.
|
||||
const touchedLabels = new Set<string>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
touchedLabels.add(row.date);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
Array.from(touchedLabels).map((label) =>
|
||||
recomputeMoodBucketsForEntry(user.id, label),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
|
||||
@@ -432,23 +432,20 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// unique `(user, dayStart)` set first to bound the recompute count.
|
||||
// Best-effort: rollup failures must not surface as 5xx.
|
||||
if (inserted > 0 || duplicates > 0) {
|
||||
const touchedDayStarts = new Set<number>();
|
||||
// v1.32.12 — collapse to the unique set of `date` labels touched by
|
||||
// this batch (the same per-row `moodDateKey` the insert used), so
|
||||
// the rollup keys byte-identically to the stored `MoodEntry.date`.
|
||||
const touchedLabels = new Set<string>();
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const status = results[i]?.status;
|
||||
if (status === "inserted" || status === "duplicate") {
|
||||
const d = entries[i].moodLoggedAt;
|
||||
const dayStart = Date.UTC(
|
||||
d.getUTCFullYear(),
|
||||
d.getUTCMonth(),
|
||||
d.getUTCDate(),
|
||||
);
|
||||
touchedDayStarts.add(dayStart);
|
||||
touchedLabels.add(moodDateKey(entries[i].moodLoggedAt, tz));
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
Array.from(touchedLabels).map((label) =>
|
||||
recomputeMoodBucketsForEntry(user.id, label),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
|
||||
@@ -111,11 +111,11 @@ describe("POST /api/mood-entries/restore", () => {
|
||||
});
|
||||
|
||||
it("collapses the mood rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 restored rows across 2 distinct UTC days → 2 recomputes.
|
||||
// 3 restored rows across 2 distinct date labels → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ date: "2026-01-01" },
|
||||
{ date: "2026-01-01" },
|
||||
{ date: "2026-01-02" },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
|
||||
@@ -76,7 +76,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
|
||||
// is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
select: { moodLoggedAt: true },
|
||||
select: { date: true },
|
||||
});
|
||||
|
||||
// Un-tombstone in one statement. The `where` pins `userId` so a forged
|
||||
@@ -102,20 +102,18 @@ async function postRestore(request: NextRequest): Promise<Response> {
|
||||
// Bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// Collapse the restored rows to the unique `(user, dayStart)` set
|
||||
// BEFORE recomputing — mirrors the bulk-delete path. Best-effort: a
|
||||
// v1.32.12 — collapse the restored rows to the unique set of `date`
|
||||
// labels BEFORE recomputing — mirrors the bulk-delete path and keys
|
||||
// byte-identically to the stored `MoodEntry.date`. Best-effort: a
|
||||
// populator hiccup never fails the user's restore.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
const touchedLabels = new Set<string>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
touchedLabels.add(row.date);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
Array.from(touchedLabels).map((label) =>
|
||||
recomputeMoodBucketsForEntry(user.id, label),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
|
||||
@@ -334,7 +334,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// Best-effort: a failure here must not surface as a 5xx to the
|
||||
// user, the rollup is a cache tier, not a write-path invariant.
|
||||
try {
|
||||
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
|
||||
await recomputeMoodBucketsForEntry(user.id, date);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* S-1 — the Strava OAuth callback enqueues the self-converging history backfill
|
||||
* at connect time (mirroring the WHOOP / Fitbit callbacks) so deep history
|
||||
* lands within the hour instead of waiting for the next worker reboot. A missing
|
||||
* boss instance is a warning, never a failure — boot discovery is the net.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const {
|
||||
bossSend,
|
||||
getGlobalBossMock,
|
||||
exchangeCodeMock,
|
||||
getCredsMock,
|
||||
userFindUnique,
|
||||
userUpdate,
|
||||
markReconnectedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
bossSend: vi.fn(async () => "job-id"),
|
||||
getGlobalBossMock: vi.fn(),
|
||||
exchangeCodeMock: vi.fn(),
|
||||
getCredsMock: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
userUpdate: vi.fn(async () => ({})),
|
||||
markReconnectedMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: userFindUnique, update: userUpdate } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({
|
||||
setAuth: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` }));
|
||||
vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock }));
|
||||
vi.mock("@/lib/strava/credentials", () => ({
|
||||
getStravaClientCredentials: getCredsMock,
|
||||
}));
|
||||
vi.mock("@/lib/oauth/signed-state", () => ({
|
||||
oauthStateCookieName: () => "strava_oauth_state",
|
||||
stateMatchesCookie: () => true,
|
||||
verifySignedState: () => ({ userId: "u1" }),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
markReconnected: markReconnectedMock,
|
||||
}));
|
||||
vi.mock("@/lib/jobs/strava-backfill", () => ({
|
||||
STRAVA_BACKFILL_QUEUE: "strava-backfill",
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: getGlobalBossMock,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
|
||||
function callback(): Promise<{ headers: { get(k: string): string | null } }> {
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/strava/callback?code=abc&state=signed-state",
|
||||
{ headers: { cookie: "strava_oauth_state=signed-state" } },
|
||||
);
|
||||
return (GET as unknown as (r: NextRequest) => Promise<never>)(req);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
exchangeCodeMock.mockResolvedValue({
|
||||
access_token: "at",
|
||||
refresh_token: "rt",
|
||||
athlete: { id: 42 },
|
||||
});
|
||||
userFindUnique.mockResolvedValue({ stravaAthleteId: null });
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
});
|
||||
|
||||
describe("GET /api/strava/callback — S-1 backfill enqueue", () => {
|
||||
it("enqueues one STRAVA_BACKFILL_QUEUE job for the connecting user", async () => {
|
||||
const res = await callback();
|
||||
|
||||
expect(res.headers.get("location")).toContain("strava=connected");
|
||||
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "strava");
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(bossSend).toHaveBeenCalledWith(
|
||||
"strava-backfill",
|
||||
expect.objectContaining({ userId: "u1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still connects when the boss instance is unavailable (warning, not failure)", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
|
||||
const res = await callback();
|
||||
|
||||
expect(res.headers.get("location")).toContain("strava=connected");
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await auditLog("strava.connect", { userId });
|
||||
await markReconnected(userId, "strava");
|
||||
|
||||
// Enqueue the self-converging history backfill at connect time so deep
|
||||
// history lands within the hour instead of waiting for the next worker
|
||||
// reboot. Best-effort: the boot-time discovery query
|
||||
// (`stravaBackfillCompletedAt IS NULL`) is the safety net, so a missing
|
||||
// boss instance here never strands the connection. Mirrors the WHOOP /
|
||||
// Fitbit callback enqueue.
|
||||
const boss = getGlobalBoss();
|
||||
if (boss) {
|
||||
await boss
|
||||
.send(STRAVA_BACKFILL_QUEUE, {
|
||||
userId,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
})
|
||||
.catch((err) =>
|
||||
getEvent()?.addWarning(`strava-backfill enqueue failed: ${err}`),
|
||||
);
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=connected",
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("POST /api/whoop/sync", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("triggers an incremental sync by default", async () => {
|
||||
sync.mockResolvedValue(7);
|
||||
sync.mockResolvedValue({ imported: 7, failed: false });
|
||||
const res = (await (
|
||||
POST as unknown as (r: NextRequest) => Promise<{ data: unknown }>
|
||||
)(req({}))) as { data: { imported: number; fullSync: boolean } };
|
||||
@@ -40,7 +40,7 @@ describe("POST /api/whoop/sync", () => {
|
||||
});
|
||||
|
||||
it("honours fullSync: true", async () => {
|
||||
sync.mockResolvedValue(99);
|
||||
sync.mockResolvedValue({ imported: 99, failed: false });
|
||||
const res = (await (
|
||||
POST as unknown as (r: NextRequest) => Promise<{ data: unknown }>
|
||||
)(req({ fullSync: true }))) as { data: { fullSync: boolean } };
|
||||
|
||||
@@ -27,6 +27,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// no body provided -> default incremental sync
|
||||
}
|
||||
|
||||
const imported = await syncUserWhoop(user.id, { fullSync });
|
||||
const { imported } = await syncUserWhoop(user.id, { fullSync });
|
||||
return apiSuccess({ imported, fullSync });
|
||||
});
|
||||
@@ -107,11 +107,28 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.32.11 — first-party web-handoff login (iOS #65). The app opens this page
|
||||
// as `/auth/login?flow=native` inside an ASWebAuthenticationSession so the
|
||||
// login runs in the self-hoster's real web origin (fixing password autofill +
|
||||
// passkeys). It stays true for the whole component so every success path
|
||||
// (password, passkey button, passkey autofill, MFA step) routes to completion.
|
||||
const isNativeHandoff = searchParams.get("flow") === "native";
|
||||
|
||||
// v1.16.6 first-load waterfall fix — the session cookie exists the
|
||||
// moment the login POST resolves, so the dashboard snapshot request
|
||||
// can ride in parallel with the route transition + page-chunk
|
||||
// download instead of queueing behind the dashboard mount.
|
||||
function navigateAfterLogin() {
|
||||
// Native web-handoff: hand control back to the app via the server's
|
||||
// single-use code mint. Top-level navigation, not router.push — the
|
||||
// completion answer is a 302 to `healthlog://login-callback` that only the
|
||||
// browser/OS can follow. `next` is meaningless to the app and ignored; the
|
||||
// page always requires a fresh interactive login here (no ambient one-click
|
||||
// pass), which is exactly what the completion route's freshness rule needs.
|
||||
if (isNativeHandoff) {
|
||||
window.location.href = "/api/auth/native/complete";
|
||||
return;
|
||||
}
|
||||
// Start the new session from an empty in-memory cache. The root
|
||||
// QueryClient is created once and outlives every client-side navigation,
|
||||
// so a previous user who closed the tab WITHOUT logging out (no logout
|
||||
@@ -319,6 +336,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNativeHandoff && (
|
||||
<div
|
||||
data-slot="native-handoff-banner"
|
||||
className="bg-primary/10 text-foreground mt-6 rounded-lg p-3 text-center text-sm"
|
||||
>
|
||||
{t("auth.native.signingInToApp")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaChallenge ? (
|
||||
<div className="mt-8">
|
||||
<MfaLoginStep
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// v1.32.14 — the per-message "some figures couldn't be checked" notice renders
|
||||
// only on a settled assistant turn whose provenance carries unverifiedFigures ≥ 1.
|
||||
// Mirror the coach-charts harness: SSR render with the hooks the bubble reaches
|
||||
// for stubbed out.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "tester", role: "USER", avatarUrl: null },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
|
||||
}));
|
||||
|
||||
import { MessageThread } from "../message-thread";
|
||||
import type {
|
||||
CoachConversationDetailDTO,
|
||||
CoachMessageDTO,
|
||||
CoachProvenance,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Distinctive substrings free of HTML-escaped characters, so the assertion does
|
||||
// not couple to how the SSR renderer escapes the apostrophe in "couldn't".
|
||||
const EN_COPY_HEAD = "be checked against your data and were left out.";
|
||||
|
||||
function conversationWith(
|
||||
overrides: Partial<CoachMessageDTO>,
|
||||
): CoachConversationDetailDTO {
|
||||
const message: CoachMessageDTO = {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: "Your systolic was […] mmHg last week.",
|
||||
createdAt: "2026-07-24T10:00:00.000Z",
|
||||
metricSource: null,
|
||||
providerType: "openai",
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
id: "c1",
|
||||
title: "Test",
|
||||
createdAt: "2026-07-24T10:00:00.000Z",
|
||||
updatedAt: "2026-07-24T10:00:00.000Z",
|
||||
messageCount: 1,
|
||||
fenced: false,
|
||||
attachmentCount: 0,
|
||||
messages: [message],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Coach unverified-figures notice", () => {
|
||||
it("renders under a settled assistant turn when unverifiedFigures ≥ 1", () => {
|
||||
const provenance: CoachProvenance = {
|
||||
windows: [],
|
||||
metrics: [],
|
||||
unverifiedFigures: 1,
|
||||
};
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={conversationWith({ metricSource: provenance })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-unverified-notice"');
|
||||
// Muted meta treatment (UI-STANDARDS §3) — never a warning colour.
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
it("carries the localized, count-free copy", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={conversationWith({
|
||||
metricSource: { windows: [], metrics: [], unverifiedFigures: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// Count-free sentence works for any n even though unverifiedFigures is 3.
|
||||
expect(html).toContain(EN_COPY_HEAD);
|
||||
expect(EN_COPY_HEAD).not.toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("does NOT render when no figure was withheld", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={conversationWith({
|
||||
metricSource: { windows: ["last7days"], metrics: ["bp"] },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-unverified-notice"');
|
||||
});
|
||||
|
||||
it("does NOT render when provenance is absent", () => {
|
||||
const html = render(
|
||||
<MessageThread conversation={conversationWith({ metricSource: null })} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-unverified-notice"');
|
||||
});
|
||||
|
||||
it("does NOT render on a refusal turn even with the field set", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={conversationWith({
|
||||
providerType: "refusal",
|
||||
metricSource: { windows: [], metrics: [], unverifiedFigures: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-unverified-notice"');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Info,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
@@ -604,6 +605,18 @@ function ChatBubbleImpl({
|
||||
? selectCoachChartTokens(content, metricSource?.metrics)
|
||||
: [];
|
||||
|
||||
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
|
||||
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
|
||||
// SETTLED assistant turn — skipped while in-flight, on errored and refusal
|
||||
// turns — mirroring the token-footer gating. Rides `metricSource` so it survives
|
||||
// reload with no memo change (the comparator already ref-checks metricSource).
|
||||
const showUnverifiedNotice =
|
||||
!inProgress &&
|
||||
!errorCode &&
|
||||
providerType !== "refusal" &&
|
||||
!!content &&
|
||||
(metricSource?.unverifiedFigures ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-assistant"
|
||||
@@ -658,6 +671,19 @@ function ChatBubbleImpl({
|
||||
{safeError && content && (
|
||||
<p className="text-warning text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.32.14 — quiet grounding caveat when the guard withheld a figure.
|
||||
Muted meta (UI-STANDARDS §3), never a warning colour — it is a
|
||||
grounding caveat, not an error. Static single line, decorative icon,
|
||||
no interactivity. The `[…]` marks in the prose above point here. */}
|
||||
{showUnverifiedNotice && (
|
||||
<p
|
||||
data-slot="coach-unverified-notice"
|
||||
className="text-muted-foreground flex items-start gap-1.5 text-xs leading-relaxed"
|
||||
>
|
||||
<Info aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
|
||||
{t("insights.coach.unverifiedFiguresNotice")}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.22 (W5) — accompanying chart(s). The token was already
|
||||
stripped from the prose by <StreamedProse>; here it mounts the
|
||||
real, self-fetching Recharts chart for the user's own series.
|
||||
|
||||
@@ -30,10 +30,16 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import {
|
||||
type InsightsLayout,
|
||||
type InsightsLayoutWithToken,
|
||||
type InsightsSectionConfig,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
|
||||
/**
|
||||
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
|
||||
@@ -119,15 +125,21 @@ export function InsightsEditMode({
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: InsightsLayout) => {
|
||||
return apiPut<InsightsLayout>("/api/insights/layout", {
|
||||
version: 2,
|
||||
sections: next.sections,
|
||||
tiles: next.tiles,
|
||||
});
|
||||
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
|
||||
// was based on so an interleaved write (a Settings pill-order Save, or
|
||||
// this surface open in another tab) 409s instead of clobbering.
|
||||
return apiPut<InsightsLayoutWithToken>(
|
||||
"/api/insights/layout",
|
||||
withBaseToken(
|
||||
{ version: 2, sections: next.sections, tiles: next.tiles },
|
||||
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
// Optimistic-style settle: write the server-resolved layout into the
|
||||
// shared cache so the overview repaints in the new order, then close.
|
||||
// Optimistic-style settle: write the server-resolved layout (carrying
|
||||
// the advanced token) into the shared cache so the overview repaints in
|
||||
// the new order, then close.
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
@@ -135,7 +147,20 @@ export function InsightsEditMode({
|
||||
toast.success(t("insights.editMode.saveSuccess"));
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
onError: (err) => {
|
||||
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means someone else
|
||||
// committed since this edit was based. Refetch so the token advances,
|
||||
// KEEP the draft (stay in edit mode) so the user can re-save, and nudge
|
||||
// gently — nothing was lost.
|
||||
if (isConflict(err)) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("insights.editMode.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
DailyBriefing as DailyBriefingPayload,
|
||||
TrendAnnotations,
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
@@ -381,15 +381,19 @@ export function useInsightsAdvisorQuery(
|
||||
// their query subtree so the per-section text below the advisor card
|
||||
// re-fetches.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// v1.18.10 — the dashboard hero surfaces the briefing headline from the
|
||||
// SNAPSHOT (server-lifted `User.insightsCachedText`), not from this
|
||||
// advisor cache. A regenerate rewrites that server cache, so invalidate
|
||||
// the snapshot too — otherwise returning to the Startseite shows the
|
||||
// previous briefing under the greeting until the snapshot's own stale
|
||||
// window lapses. Closes "the briefing doesn't update" on the dashboard.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
});
|
||||
// v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing
|
||||
// headline from the SNAPSHOT (server-lifted `User.insightsCachedText`),
|
||||
// and the Today hero's lead line is the SAME briefing lifted into the
|
||||
// digest — neither is this advisor cache. A regenerate rewrites that
|
||||
// server cache, so both the snapshot and the digest must refetch.
|
||||
// Regenerate runs on `/insights`, where both are UNMOUNTED, so the
|
||||
// earlier active-only snapshot invalidation only marked it stale (never
|
||||
// refetched, `refetchOnMount: false`) and never touched the digest at
|
||||
// all — returning to `/` then showed the previous briefing under the
|
||||
// greeting AND in the Today lead for up to 120 s. `refetchInactiveDailyReads`
|
||||
// forces both to refetch right here so the return paints the fresh
|
||||
// briefing at once.
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const READINGS_PAGE_SIZE = 200;
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.24 — the per-marker description used to live on the labs overview rows.
|
||||
@@ -225,16 +225,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { data: assessment, isLoading: assessmentLoading } =
|
||||
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
|
||||
|
||||
// v1.32.19 — a new reading for this marker changes the same-page AI
|
||||
// assessment card (`insightsBiomarkerAssessment`, mounted here) and the
|
||||
// Insights "what changed since your last panel" read (`insightsLabsChanges`).
|
||||
// Both used to stay on their pre-write text until their own stale window
|
||||
// lapsed; bust them alongside the result list so the assessment repaints as
|
||||
// soon as the reading lands.
|
||||
function invalidateAfterReadingWrite() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
});
|
||||
}
|
||||
|
||||
function afterAdd() {
|
||||
setAddOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
invalidateAfterReadingWrite();
|
||||
}
|
||||
|
||||
// v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`,
|
||||
// minus the close. Useful here too — logging several past readings for
|
||||
// this one biomarker back-to-back without re-opening the sheet each time.
|
||||
function afterAddKeepOpen() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
invalidateAfterReadingWrite();
|
||||
}
|
||||
|
||||
function afterEditMarker() {
|
||||
|
||||
@@ -108,6 +108,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
// v1.32.19 — a lab reading edit / delete / restore shifts the two most
|
||||
// recent panels the Insights "what changed since your last panel" card
|
||||
// reads, so bust it too instead of leaving it on its pre-write deltas
|
||||
// until the 60 s stale window lapses.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
});
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
|
||||
@@ -467,13 +467,45 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("successToast"),
|
||||
);
|
||||
// The landed take must reach every dependent consumer.
|
||||
// The landed take must reach every dependent consumer AND force the two
|
||||
// inactive daily reads (dashboard snapshot + Today digest) to refetch —
|
||||
// the blessed `invalidateMedicationReads` fans out the bundle (N keys)
|
||||
// then invalidates those two with `refetchType: "inactive"`.
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(
|
||||
medicationDependentKeys.length,
|
||||
medicationDependentKeys.length + 2,
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("forces the inactive Today hero + dashboard reads to refetch after a landed take (v1.32.19 stale-hero fix)", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-a"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
await runTakeAllDue({
|
||||
medications: [dueTwo[0]],
|
||||
t,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
// The batch button lives on `/medications`, where the digest + snapshot
|
||||
// are UNMOUNTED. A bare bundle invalidation (default active refetch) would
|
||||
// leave them stale — so the helper MUST invalidate both with
|
||||
// `refetchType: "inactive"`, which is what actually refetches them.
|
||||
const calls = vi.mocked(queryClient.invalidateQueries).mock.calls;
|
||||
const inactive = calls.filter(
|
||||
([arg]) =>
|
||||
(arg as { refetchType?: string } | undefined)?.refetchType ===
|
||||
"inactive",
|
||||
);
|
||||
const keys = inactive.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey?: unknown }).queryKey),
|
||||
);
|
||||
expect(keys).toContain(JSON.stringify(["dashboard", "snapshot"]));
|
||||
expect(keys).toContain(JSON.stringify(["daily", "digest"]));
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reports total failure without invalidating (nothing changed server-side)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -491,13 +523,15 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => {
|
||||
// The bundle includes the dashboard snapshot key (v1.16.11), so a
|
||||
// batch take refreshes the hero dose tally too. Pin the source so a
|
||||
// refactor to a hand-rolled key list cannot slip through.
|
||||
it("routes the batch invalidation through the blessed invalidateMedicationReads (source pin)", () => {
|
||||
// v1.32.19 — the batch take now routes through the one blessed helper,
|
||||
// which fans out `medicationDependentKeys` AND forces the inactive digest
|
||||
// + snapshot to refetch (the maintainer's stale-hero repro). Pin the
|
||||
// source so a revert to a bare bundle invalidation cannot slip through.
|
||||
const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8");
|
||||
expect(src).toContain(
|
||||
"await invalidateKeys(queryClient, medicationDependentKeys)",
|
||||
expect(src).toContain("await invalidateMedicationReads(queryClient)");
|
||||
expect(src).not.toContain(
|
||||
"invalidateKeys(queryClient, medicationDependentKeys)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import { Label } from "@/components/ui/label";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
|
||||
@@ -174,6 +178,10 @@ export function LedgerAddDialog({
|
||||
...medicationDependentKeys,
|
||||
ledgerKey,
|
||||
]);
|
||||
// A ledger add can land a today-dated dose, so clear the unmounted
|
||||
// Today hero + dashboard too (v1.32.19 — same class as the ledger's
|
||||
// own log/edit/delete sites).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
setNudgeOpen(false);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
|
||||
import { runUndoIntake } from "@/components/medications/use-medication-intake";
|
||||
@@ -295,6 +296,10 @@ export function DoseHistoryLedger({
|
||||
...medicationDependentKeys,
|
||||
queryKey,
|
||||
]);
|
||||
// The ledger also invalidates its own window key above; the daily
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
if (prev) queryClient.setQueryData(queryKey, prev);
|
||||
toast.error(
|
||||
@@ -334,6 +339,10 @@ export function DoseHistoryLedger({
|
||||
...medicationDependentKeys,
|
||||
queryKey,
|
||||
]);
|
||||
// The ledger also invalidates its own window key above; the daily
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
@@ -365,6 +374,10 @@ export function DoseHistoryLedger({
|
||||
...medicationDependentKeys,
|
||||
queryKey,
|
||||
]);
|
||||
// The ledger also invalidates its own window key above; the daily
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
}
|
||||
@@ -380,6 +393,10 @@ export function DoseHistoryLedger({
|
||||
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
|
||||
toast.success(t("medications.detail.intake.deleteRow.toast"));
|
||||
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
|
||||
// The ledger also invalidates its own window key above; the daily
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface IntakeEditDialogProps {
|
||||
@@ -129,7 +129,10 @@ function IntakeEditDialogBody({
|
||||
body.takenAt = null;
|
||||
}
|
||||
await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// Editing today's dose (un-skip, retime) changes its due/taken state —
|
||||
// route through the blessed helper so the Today hero + dashboard clear
|
||||
// at once even when they are unmounted behind the detail page.
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
onClose();
|
||||
} catch {
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type IntakeHistorySortKey,
|
||||
} from "@/components/medications/intake-history-list-v2";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface IntakeHistoryEditableProps {
|
||||
@@ -94,7 +94,7 @@ export function IntakeHistoryEditable({
|
||||
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
|
||||
eventIds: ids,
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.bulkDelete.toast"));
|
||||
setBulkConfirmOpen(false);
|
||||
clearSelection();
|
||||
@@ -111,7 +111,7 @@ export function IntakeHistoryEditable({
|
||||
const id = pendingDeleteId;
|
||||
try {
|
||||
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.deleteRow.toast"));
|
||||
setPendingDeleteId(null);
|
||||
// Drop the deleted row from the pending selection so the
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
MedicationIntakeImportError,
|
||||
@@ -124,7 +124,10 @@ export function IntakeImportDialog({
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// An import can land today-dated doses, so clear the Today hero +
|
||||
// dashboard through the blessed helper (they are unmounted behind the
|
||||
// import dialog on the detail page).
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationIntakeImportError) {
|
||||
setResult(t("medications.importFailed"));
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
*/
|
||||
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm";
|
||||
|
||||
// Re-exported so the existing `dose-window` consumers (and its unit suite)
|
||||
// keep their import site while the implementation lives in the shared util.
|
||||
export { hhmmToMinutes };
|
||||
|
||||
/** One persisted explicit window. Matches `doseWindowEntrySchema`. */
|
||||
export interface DoseWindowEntry {
|
||||
@@ -40,12 +45,6 @@ export type DoseWindowScale = "intraday" | "dayScale";
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/** Minutes-since-midnight for a regex-valid HH:mm. */
|
||||
export function hhmmToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(":");
|
||||
return Number(h) * 60 + Number(m);
|
||||
}
|
||||
|
||||
/** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */
|
||||
export function minutesToHhmm(total: number): string {
|
||||
const wrapped = ((total % 1440) + 1440) % 1440;
|
||||
|
||||
@@ -46,11 +46,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
@@ -89,7 +85,7 @@ export function LifecycleManageBody({
|
||||
// v1.5.5 C-E3-2 — body carries `{ active }` only; server derives
|
||||
// pausedAt. No client-side pausedAt literal.
|
||||
await apiPut(`/api/medications/${medicationId}`, { active: !pauseNext });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(
|
||||
pauseNext
|
||||
? t("medications.detail.zone.pause.pausedToast")
|
||||
@@ -110,7 +106,7 @@ export function LifecycleManageBody({
|
||||
try {
|
||||
const today = new Date().toISOString();
|
||||
await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
@@ -246,7 +242,7 @@ export function DangerZoneBody({
|
||||
setTier3aBusy(true);
|
||||
try {
|
||||
await apiDelete(`/api/medications/${medicationId}/intake/purge`);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
@@ -281,7 +277,7 @@ export function DangerZoneBody({
|
||||
// Fire-and-forget the dependent-bundle invalidation (list /
|
||||
// analytics / compliance / inline chart). Not awaited: navigation
|
||||
// already happened and a transient refetch must not block it.
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.delete.failed"));
|
||||
setTier3bBusy(false);
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
type ScheduleWindowInput,
|
||||
@@ -230,12 +230,17 @@ export async function runTakeAllDue(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
// One invalidation for the whole batch (the bundle includes the
|
||||
// dashboard snapshot key, so the hero dose tally refreshes too). A
|
||||
// zero-success run changed nothing server-side — skip the refetch and
|
||||
// keep the due set as-is for the retry.
|
||||
// One invalidation for the whole batch through the blessed helper: it
|
||||
// fans out the dependent-key bundle AND forces the inactive digest +
|
||||
// snapshot to refetch. The batch button lives on `/medications`, where the
|
||||
// Today hero and dashboard are UNMOUNTED, so a bare bundle invalidation
|
||||
// (default `refetchType: "active"`) marked them stale but never refetched —
|
||||
// the taken doses stayed "due" on the hero until the 120 s poll or a hard
|
||||
// reload (the maintainer's exact repro, v1.32.19). A zero-success run
|
||||
// changed nothing server-side — skip the refetch and keep the due set for
|
||||
// the retry.
|
||||
if (taken > 0) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
|
||||
@@ -5,44 +5,18 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Invalidate every read that reflects a dose's taken/due state after an
|
||||
* intake write. `invalidateKeys` invalidates with the default
|
||||
* `refetchType: "active"`, which only refetches mounted queries — so the
|
||||
* dashboard snapshot AND the Today digest, both inactive while the user is on
|
||||
* the medication card or detail page, are marked stale but never refetched. On
|
||||
* navigating back each remounts under `refetchOnMount: false`, so the pre-write
|
||||
* cache is served and the "due" prompt (snapshot dose tally) plus the digest's
|
||||
* dose-window rail item both linger until a hard reload. Force the inactive
|
||||
* queries to refetch so the dashboard clears as soon as the dose is recorded.
|
||||
*
|
||||
* v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday`
|
||||
* reads the SAME server snapshot cell the intake route already hard-evicts, so
|
||||
* the refetch returns post-write dose state immediately (no server change
|
||||
* needed — the eviction seam is reused).
|
||||
* Every intake write in this hook clears the Today hero + dashboard through
|
||||
* the one blessed `invalidateMedicationReads` (in `@/lib/query-keys`): it
|
||||
* pairs the `medicationDependentKeys` bundle invalidation with a forced
|
||||
* inactive refetch of the digest + snapshot, so a taken / skipped / undone
|
||||
* dose repaints the moment it lands rather than on the 120 s poll. The helper
|
||||
* used to live inline here (v1.29.1); v1.32.19 lifted it into the factory as
|
||||
* the single canonical entry point every intake surface routes through.
|
||||
*/
|
||||
async function invalidateMedicationReads(
|
||||
queryClient: QueryClient,
|
||||
): Promise<void> {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
refetchType: "inactive",
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
refetchType: "inactive",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
type Translator = (
|
||||
key: string,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
||||
DASHBOARD_WIDGET_IDS,
|
||||
IOS_PIN_ONLY_WIDGET_IDS,
|
||||
buildRingMutationPayload,
|
||||
widgetWritesBusy,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
@@ -540,10 +542,72 @@ describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)"
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("buildRingMutationPayload(next)");
|
||||
// v1.32.16 — the ring body is built via `buildRingMutationPayload`,
|
||||
// now also threading the optimistic-concurrency base token.
|
||||
expect(src).toContain("buildRingMutationPayload({ ...next, baseUpdatedAt");
|
||||
// The pre-fix shape must never come back: spreading a query-cache
|
||||
// snapshot into the ring PUT body is exactly the stale-`widgets`
|
||||
// race issue #581 reported.
|
||||
expect(src).not.toMatch(/\.\.\.remote,/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.32.16 (issue #581) — a committed Save must stay authoritative against a
|
||||
* concurrent instant hero-ring write. The full DOM interaction (fire a ring
|
||||
* write, then a Save, assert the disabled/pending gate) can't run under this
|
||||
* suite's SSR-only convention, so the load-bearing client contract is pinned
|
||||
* as pure exported units plus source pins for the wiring the JSX depends on.
|
||||
*/
|
||||
describe("dashboard layout — Save cannot be clobbered by a ring write (#581)", () => {
|
||||
it("buildRingMutationPayload never carries widgets and threads the base token", () => {
|
||||
const payload = buildRingMutationPayload({
|
||||
selectedScoreRings: ["READINESS"],
|
||||
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
|
||||
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
|
||||
});
|
||||
// None of the fields a stale snapshot could carry and use to overwrite
|
||||
// a fresher Save.
|
||||
expect(payload).not.toHaveProperty("widgets");
|
||||
expect(payload).not.toHaveProperty("comparisonBaseline");
|
||||
expect(payload).not.toHaveProperty("chartOverlayPrefs");
|
||||
// The ring selection + the guard token DO ride.
|
||||
expect(payload.selectedScoreRings).toEqual(["READINESS"]);
|
||||
expect(payload.baseUpdatedAt).toBe("2026-07-24T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("buildRingMutationPayload omits the base-token key when none is known", () => {
|
||||
const payload = buildRingMutationPayload({
|
||||
selectedScoreRings: [],
|
||||
heroRingOrder: ["HEALTH_SCORE"],
|
||||
});
|
||||
expect(payload).not.toHaveProperty("baseUpdatedAt");
|
||||
});
|
||||
|
||||
it("widgetWritesBusy gates off EITHER write being in flight", () => {
|
||||
expect(widgetWritesBusy(false, false)).toBe(false);
|
||||
expect(widgetWritesBusy(true, false)).toBe(true); // Save pending
|
||||
expect(widgetWritesBusy(false, true)).toBe(true); // ring pending
|
||||
expect(widgetWritesBusy(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("the Save + ring controls bind the shared busy gate and serialise via one scope (source pin)", () => {
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/dashboard-layout-section.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
// Save button + ring controls both read `widgetWritesBusy(...)` so the
|
||||
// gate is one source of truth — the button can never disable on
|
||||
// `saveMutation.isPending` alone (the pre-fix hole).
|
||||
expect(src).toContain("widgetWritesBusy(");
|
||||
// Both writes share a mutation scope so they run serially at the client.
|
||||
expect(src).toContain("scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }");
|
||||
// Each write sends the optimistic-concurrency base token.
|
||||
expect(src).toContain("baseUpdatedAt: readBaseToken()");
|
||||
// A 409 is handled (refetch + gentle nudge), not swallowed as a hard error.
|
||||
expect(src).toContain("err.status === 409");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user