Commit Graph
2082 Commits
Author SHA1 Message Date
Marc-André Bombeck e03cf7c2c8 Merge branch 'feat/medication-record-only' into release/v1.39.1 2026-09-25 09:37:27 +02:00
Marc-André Bombeck e5236d2e6a fix(medications): keep a record-only schedule unless the write names trackIntake
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
2026-09-25 09:24:38 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck 700de6d228 feat(medications): add the track-intake switch to the medication pages
The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck d7bc873691 fix(backup): answer an upload too large to store with 413
An uploaded file whose encrypted copy passes the size one stored backup may
have on this host (a fifth of the heap limit) stopped the store with
BackupBlobTooLargeError, which the route let through as a 500 and reported
to the error tracker. It is now a 413 with the numbers and the remedy in the
message and meta.errorCode backup.upload.too_large, and the refusal is
audited like the others. The store rolls back, so nothing is kept.
2026-09-25 08:51:00 +02:00
Marc-André Bombeck c327ae81d5 Merge branch 'feat/procedure-history' into release/v1.39.1 2026-09-25 08:23:34 +02:00
Marc-André Bombeck ad0d50fa28 feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 08:11:37 +02:00
Marc-André Bombeck 934131ee3e Merge branch 'feat/restore-background-job' into release/v1.39.1 2026-09-25 08:10:06 +02:00
Marc-André Bombeck d0e321c54a Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/measurements.ts
2026-09-25 08:04:30 +02:00
Marc-André Bombeck f443ab6615 feat(measurements): accept sleep stages by name on POST /api/measurements
A bridge such as Tasker or Home Assistant could only send sleep stages
through the batch route, in HealthKit's terms: the category identifier,
a stage codepoint from 0 to 5, and a value it had to work out in
minutes. It can now post one segment to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`,
`CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or
`measuredAt` (the end) + `value` (minutes).

The row is the one the batch route writes for the same segment, and the
dedupe is the batch route's: the opposite-source same-reading check,
then one reconcile over both unique identities. Without an `externalId`
the server derives one from the stage and both instants, so a re-post
answers 200 `duplicate`, a corrected start answers 200 `updated`, and a
new segment answers 201 `inserted`. The narrow `measurements:write`
token is accepted and still writes only MANUAL rows. Every other body
on this route behaves as before.

Requested in #972.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck be112df972 fix(profile): report the time zone the server uses on /api/user/profile
`/api/auth/me` has reported the resolved zone, the stored one or the
instance default when the stored value is unusable, while
`/api/user/profile` returned the raw column. A client reading the
profile could cut days differently from the server, and sending the
value back in a PATCH was refused.

Both reads, and the profile PATCH answer, now go through one
`resolveStoredTimezone`. The field stays a string and always holds a
valid IANA name. The web account editor already reads the resolved
zone from `/api/auth/me` and saves through the validating timezone
route, so it shows the zone in use and can only write a valid one.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck a7682a9fed fix(measurements): name the batch validation refusal and the band an entry broke
The validation 422 from `POST /api/measurements/batch` now carries
`meta.errorCode: "measurement.batch.invalid"`, beside the existing
`too_large` and `source_not_permitted` codes, so a client can file the
rows as permanently refused instead of holding its anchor on a guess.

A `value_out_of_range` entry now also carries `convertedValue`, the
reading after conversion into the stored unit, and `range`
(`min`, `max`, `unit`), the band it was checked against. Both appear
only on that skip. A fraction sent where a percent was expected is now
visible in the response instead of looking like a sensor glitch.

Status codes, the envelope and every existing field are unchanged.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 47e9f7e81c feat(backup): run a restore as a background job with progress
Restoring a large account was one request: 94 s for 1.25 million readings.
A reverse proxy that cuts a request at 60 s (nginx's default) showed the
operator an error for a restore that went on to finish on the server.

The restore request now checks the typed confirmation, that the backup
exists and that its stored copy opens, admits one job for the account and
answers 202 with its id. The `backup-restore` queue runs the restore and
records its phase, counts and outcome on a `backup_restore_jobs` row
(migration 0347), which `GET /api/admin/backups/restores` and
`GET /api/admin/backups/restores/{id}` read back.

What a restore checks and writes is unchanged. The body moved from the
route into `restoreBackup` (src/lib/export/restore-backup.ts): every
refusal still happens before the first delete and the replacement is still
one transaction, so a failure part-way leaves the account as it was. While
it runs the account keeps showing its current data, and other writes are
neither refused nor held, as before.

- One restore per account: a partial unique index admits one queued or
  running job, and a second request gets 409 `backup.restore.active` naming
  the running job. A per-account advisory lock inside the transaction holds
  even if a worker thought gone is still writing.
- Time budget: the job's expiry is two hours and a file whose transaction
  limit would run past three quarters of it is refused before anything is
  deleted (`jobDeadline`, beside `jobBudget`).
- Resuming: the job writes a heartbeat every two seconds. A boot sweep
  queues a job whose worker stopped again from the start, at most twice,
  then fails it as interrupted. The admission check releases an abandoned
  job so it never holds the account's slot.
- The staged file is the stored `DataBackup` row, already encrypted at
  rest. It stays whatever the outcome, so a retry needs no new upload. The
  job records a digest of the copy and refuses one that was replaced while
  it waited.
- Failures carry a stable code and a sentence that says what to do.
- The console follows the job: phase and counts while it runs, the outcome
  after, and after a reload it finds the job again from the server. A poll
  that fails on the network is never shown as a failed restore. A web
  process separate from the worker evicts the account's caches when it
  first reports the restore finished. The success line goes through the
  outcome module, so an empty file is not drawn as a tick.

The model is registered in the backup plan (not carried) and the wipe plan
(wiped with the account); the queue in the maintenance registrar, the
subsystem surface and the job-fact allowlist; the new code in the error
catalogue; the routes in the OpenAPI contract. The tests that pin restore
semantics now drive the real route and the real job through one helper, and
`backup-restore-job.test.ts` pins the asynchronous contract itself.
2026-09-25 07:39:06 +02:00
Marc-André Bombeck ee23409b77 Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1 2026-09-25 07:38:34 +02:00
Marc-André Bombeck 702a13873d Merge branch 'main' into release/v1.39.1 2026-09-25 07:21:34 +02:00
Marc-André Bombeck 6e05f86e7b fix(vaccinations): show a dose's linked documents and keep them on edit
A dose opened from the list carried no document links, since only the
detail read has them. The edit sheet showed an empty picker, and saving
any change sent that empty set, which the route reads as unlink
everything. The sheet now reads the dose's own links before it shows
them, and a save leaves the links alone unless they were changed.

A link to one dose or visit (/vaccinations?dose=, /checkups?visit=) now
scrolls to it and opens it for someone who may edit it.
2026-09-25 07:10:56 +02:00
Marc-André Bombeck adec5bd045 fix(cycle): carry every logged day entry on the calendar read
The calendar grid carried flow, symptoms, BBT, the ovulation test and the
mucus reading, and dropped the rest of the day log: intercourse, spotting,
the pregnancy and progesterone tests, contraception and whether a note
exists. The published contract also promised the three cervix signs while
the route never sent them.

The intent fields resolve through the same reader the day-log read uses,
so an encrypted envelope reads the same on both, and the grid is served
under the same cycle grant as the day log. Additive fields; the contract
test pins the wire keys to the published schema.
2026-09-25 07:02:14 +02:00
Antiheld86 f94d358ade feat(measurements): write EXTERNAL from both ingest write paths
Under a `measurements:write` credential the row's source is now decided by
the server on both routes, and the body may not name one at all.

The 422 widens from "names APPLE_HEALTH" to "names any source". It was
narrow only because the forced value was MANUAL, so a body saying MANUAL
was never actually overridden. Now it would be — and overriding a client's
explicit assertion quietly is the thing both routes' comments rule out,
while honouring it would defeat the change: a bridge that kept sending
MANUAL would stay indistinguishable from a typed reading. Status and
errorCode are unchanged, and the documented Home Assistant payload sends no
source at all.

The single-POST predicate keeps reading the raw body: the create schema
defaults `source` to MANUAL, so after parsing "said MANUAL" and "said
nothing" are the same value. Both of that route's create sites resolve the
source, the array arm included — that is the combined BP + Pulse path a
bridge uses, and missing it would leave exactly those rows unbadged.

Two consequences worth naming. Bridge rows no longer join the
MANUAL/APPLE_HEALTH cross-source merge, so they can't be swallowed by a
rule written for the iOS adopt-on-pair case. And a bridge posting at the
same instant as a hand entry now lands both rows instead of a 409, which is
honest and does not double-count: the rollup collapse is per
(type, day, source) and MANUAL outranks the unlisted EXTERNAL.
2026-09-25 06:55:17 +02:00
Antiheld86 5ec361abec feat(measurements): mirror EXTERNAL in Zod, keep those rows correctable
Adds the `EXTERNAL_SOURCE` leaf beside `scopes.ts` and mirrors the value in
`measurementSourceEnum` so the read shapes and the sync decoder see it. It
stays out of `WRITABLE_MEASUREMENT_SOURCES`: a source a client could assert
would prove nothing about where a reading came from, and that constant is
published to clients as `ingest.writeAllowlist`.

The value-edit gate in `PUT /api/measurements/[id]` needs the other question
answered, though — whether the number is the user's own to fix — so it now
reads a separate `USER_CORRECTABLE_MEASUREMENT_SOURCES`. The 409 that
protects a Withings row exists because the value is the provider's reading;
the hardware behind an ingest token is the user's own scale, and locking
those rows would contradict what the settings card and the Home Assistant
guide promise. That is the only site reading the wider set.
2026-09-25 06:55:17 +02:00
Marc-André Bombeck d81518b5bf Merge branch 'fix/large-account-jobs' into release/v1.39.1 2026-09-25 06:49:16 +02:00
Marc-André Bombeck 237acc0002 fix(backup): export, upload and restore a large account as streams
An account of 1.25 million measurements could not export its data or be
restored from any backup (#1031). Measured in a 1 GB container:

- The full export and the passphrase export built the whole payload and
  its JSON string in memory; the app ran out of heap and restarted.
- The weekly backup's JSON is 662 MB, longer than any string V8 can hold,
  so `unpackBackupBlob` threw on every read. Restore, download and the
  restore preview all answered "could not be decrypted".
- Upload accepted 10 MB, a fraction of a large export.

Every reader and writer of a backup file now streams it:

- `scanBackupJson` reads a backup's bytes once, keeps every section but
  the named bulk arrays, and hands their elements out one at a time.
  `readStreamedBackup` uses it twice: a first pass that validates each
  measurement against the element schema and keeps the rest of the file,
  and a second pass that hands the measurements to the writer in batches.
  Stored copies and off-host objects are authenticated whole on their
  compressed ciphertext before any plaintext is read; only the
  decompression is streamed.
- Restore writes the measurements with one `INSERT … FROM unnest` per
  batch. Prisma caches a query plan per `createMany` row count, and one
  1 000-row plan kept about 145 MB alive, which alone put the restore past
  the heap limit. The transaction budget grows with the measurement count.
  The awards and ECG sections are handed the ids they reference that were
  written, not every written id.
- The export routes write the file into the response as it is produced.
  The passphrase export spools its ciphertext to a temporary file because
  the HLX1 format puts the tag first; the format is unchanged, so existing
  archives open as before.
- Upload accepts the raw body, JSON or gzip, up to the 512 MB request
  ceiling, checks it as it arrives and stores it in the same pass; the admin
  page compresses the file in the browser before sending it. Multipart stays
  for small files.
- Download streams the stored JSON; the preview counts measurements from
  the stream. The monthly restore drill and `scripts/restore-backup.ts`
  read the off-host object the same way; the script can write `.json.gz`.

On the seeded account in a 1 GB container: export 25 s at 389 MB peak,
passphrase export 25 s, both uploads 19 s, restore of the weekly backup
94 s at 554 MB peak, and export, upload and restore round-trip to exactly
the same rows. The encrypted-column guard now also recognises the raw SQL
statement that writes `DataBackup.data`.
2026-09-25 02:05:19 +02:00
Marc-André Bombeck fe7cccfd46 fix(medications): attribute doses to slots from before the medication existed
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.

The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.

The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.

Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.

Refs #1028
2026-09-24 19:40:27 +02:00
Marc-André Bombeck 53bd7098da Merge branch 'fix/v139-review-server' into release/v1.39.0 2026-09-24 12:48:05 +02:00
Marc-André Bombeck 8a09adc1c0 fix(cycle): answer a delete of an already deleted cycle without moving anything
The cycle delete now runs the shared removal, which finds live starts only.
A second delete of the same cycle read nothing to remove; the route now says
so explicitly and answers 204 before the transaction, and the tombstone the
first delete left stays as it was.
2026-09-24 12:43:41 +02:00
Marc-André Bombeck e822154936 fix(cycle): give back a start that a mis-tapped earlier start folded in
A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.

Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
2026-09-24 12:30:39 +02:00
Marc-André Bombeck f28153c832 fix(tz): refuse bare UTC offsets as a timezone
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through,
but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east
of UTC is negative), and the day-grain SQL aggregates would cut every day on
the wrong side of UTC.

isValidTimezone now accepts named zones only: whatever the runtime resolves
to a bare offset is refused, while named zones, their aliases, UTC and the
Etc/GMT family stay accepted. Every write that already validated through it
refuses offsets now, and the screener and mood linked-context request schemas
validate their zone too. A value stored before this change can still sit in
the database, so the SQL call sites that read the stored zone directly (the
dashboard summary, achievements, the doctor report, the tiered AI context)
fall back rather than splice it, as the resolver and the day aggregates
already did.
2026-09-24 12:23:00 +02:00
Marc-André Bombeck a15a148667 fix(insights): hold the scores strip until the cycle ring is decided
For a cycle-tracking account the strip painted Strain first and swapped
it for the cycle ring once the calendar read landed, moving the row
under the reader. The ring's decision now reports while it is pending,
and the strip keeps its skeleton until then.
2026-09-24 12:22:52 +02:00
Marc-André Bombeck 091e4b84cb fix(backup): fold a pre-0343 Coach availability key on instance restore
Migration 0343 moved the operator's Coach answer from module availability
into the Coach switch and folded every live row. Restoring the instance
settings from an older file wrote the dead coach key back into module
availability and left the Coach switch on, so a Coach the operator had off
came back.

The restore now applies the same fold: coach false in the file's module
availability switches the Coach off, and the key is dropped either way.
2026-09-24 12:18:00 +02:00
Marc-André Bombeck 6e45acfbc6 feat(coach): keep stored reminders readable and erasable with the Coach unavailable
Reminders are the person's own record, like the stored facts,
conversations and plans. The list and delete routes no longer ask the
Coach module; creating, confirming, resolving or rescheduling one stays
Coach use and is refused by POST and PATCH as before.

Settings -> Coach now always mounts the reminders card (shown with Hide
Coach on only when reminders exist), and Settings -> AI lists them through
the stored-memory block while Settings -> Coach is out of reach. With the
Coach unavailable the card is read-only: every reminder can be read and
deleted, while keep / done / dismiss are not offered.
2026-09-24 11:57:42 +02:00
Marc-André Bombeck 5e5d33ecf8 feat(coach): keep stored plans readable and erasable with the Coach unavailable
Plans are the person's own record, like the stored facts and
conversations. The list and delete routes no longer ask the Coach
module, and the plans page turns read-only instead of redirecting to
Insights: every plan can be read and removed, while confirming or moving
one stays Coach use and is refused by the PATCH route as before.
2026-09-24 11:48:15 +02:00
Marc-André Bombeck e0ece41e21 Merge branch 'feat/ai-web-surfaces' into release/v1.39.0 2026-09-24 11:37:10 +02:00
Marc-André Bombeck a6c65bd37e fix(insights): give the cycle ring a score cell only when it has a dial
The scores strip reserved a cell for the cycle ring, and dropped Strain to
make room, whenever the cycle module was on. Without an active cycle the
ring renders nothing, so the row showed a gap where Strain used to be. The
overview now asks the same calendar read whether there is a dial today.
2026-09-24 11:10:48 +02:00
Marc-André Bombeck 4449b34ac7 feat(documents): gate document, lab and medication reading on their capabilities
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.

Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
2026-09-24 11:05:49 +02:00
Marc-André Bombeck 48d97ef70d feat(insights): render AI parts from the capability map, never as errors
The daily briefing, the hero's briefing paragraph, the trend sentences and
the status notes follow the briefing and statusText capabilities: hidden
while unavailable, never an error card or a per-card "connect a provider"
state. The advisor and status reads only fire while their capability is
available; a 403 settles as an outcome instead of throwing.

A failed comprehensive read no longer replaces the overview, and every
overview section is fenced so a section that throws becomes one error row.
The period review always renders its composed narrative. Cycle, breathing
and lab-change sections go through the surface map. One calm setup hint
appears on the overview while no provider is configured and one could be.

Signals, rhythm events and the mood patterns card say a failed read with a
retry, the score anatomy no longer calls a failed read insufficient data,
the catalog marks only the rows whose read failed, and workouts are not
probed while the module is off.
2026-09-24 11:05:44 +02:00
Marc-André Bombeck 61d912627d feat(coach): follow the coach capability at every entry point
The floating button, the drawer, the launch pills, the nav entry and the
Coach pages read the coach capability from /api/auth/me instead of
combining the operator switch with Hide Coach on their own. A missing
provider or consent now hides the Coach too, instead of opening one that
can only answer with an error.

Stored conversations stay reachable: while the Coach is unavailable the
conversation list turns read-only, opens a transcript in place and keeps
delete. Refusals before the stream opens are read by meta.errorCode and
each code has its own copy.
2026-09-24 11:05:38 +02:00
Marc-André Bombeck f893dae004 Merge branch 'test/v139-flag-mocks' into release/v1.39.0 2026-09-24 10:52:30 +02:00
Marc-André Bombeck 00f6eb6dcb test(measurements): mock the status re-warm where the mutation tail imports it
The bulk-delete, restore and by-external-ids suites mocked
`invalidateStatusInsightsForTypes` on the comprehensive-generate barrel,
while the shared post-mutation tail imports it from status-invalidation.
The mock was never called, the real re-warm ran the capability loader
against a stub database, and the "no invalidate" assertion could not
fail. The mock now sits on the imported module; the positive cases
assert the re-warm with the distinct types, the zero-row cases assert
its absence.
2026-09-24 10:52:04 +02:00
Marc-André Bombeck 2c4dec08f1 test(ai): answer both switch readers from one source in flag mocks
Eight files mocked `@/lib/feature-flags` with `getAssistantFlags` alone.
The capability loader reads `loadAssistantSwitches`, so wherever it ran
it threw and every capability resolved to `check_failed`. A shared
factory now points both readers at the function the test drives.

Only the config-change chain reached the loader. Its fixture means AI
switched off by the operator; it now supplies the loader's other inputs
and asserts `operator_disabled` on the digest, so a starved loader turns
it red instead of passing on the failure path.
2026-09-24 10:52:03 +02:00
Marc-André Bombeck 48bfc1f987 refactor(navigation): name the full-document loads the client router cannot serve
Nine places assigned window.location.href directly: OAuth connects and
sign-in hand-offs that land on API routes answering with a redirect, and
the reload after account deletion. The newer Next lint rule reads each as
an in-app page change. They now go through one helper that says why a
document load is the right tool there.
2026-09-24 10:37:21 +02:00
Marc-André Bombeck 9c7ddc13b3 Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
2026-09-24 10:26:56 +02:00
Marc-André Bombeck 2143445da2 feat(ai): show stored model text inside a shared record as the owner would
Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.

The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.

Refs #975
2026-09-24 10:17:00 +02:00
Marc-André Bombeck c509afeb2e Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
2026-09-24 10:16:53 +02:00
Marc-André Bombeck ab0d1b4b71 Merge branch 'feat/module-surfaces' into release/v1.39.0 2026-09-24 10:10:11 +02:00
Marc-André Bombeck 2594045bef test(modules): hold every surface list against the surface map
Each family of ids must name a surface that exists (nav hrefs, sub-page
directories, section ids, widget ids, derived ids, Settings slugs,
correlation channels). Sub-pages, trend slots and correlation channels
must follow the owner of the types they plot, joined against the
dashboard's summary owners, and a pinned table covers the owners no
join can derive. The replaced per-surface keys and the redirecting page
guard are searched for in source, and the derived route's gate is
compared with the map entry by entry.

Broken on purpose before committing: dropping the sleep sub-page entry,
the mood trend entry or a nav entry, drifting a derived owner, and
reintroducing a per-destination key each fail by name.
2026-09-24 09:56:50 +02:00
Marc-André Bombeck 8ae0f75ecd fix(insights): leave switched-off modules out of the correlation statistics
The correlation scan still paired mood, sleep, glucose, adherence,
symptoms and weather with everything else after their modules were
switched off. The discovery matrix now drops every channel whose module
is off, read through the surface map, and the correlations route passes
the record's module map, skips the lab draws with labs off, and keys its
cache on the switched-off set.
2026-09-24 09:54:25 +02:00
Marc-André Bombeck e89f41710a Merge branch 'feat/v139-targets-and-links' into release/v1.39.0 2026-09-24 09:53:41 +02:00
Marc-André Bombeck 9f98636a43 fix(settings): read section and overview-block owners from the surface map
Settings sections and Layout groups no longer declare their own module
keys; they ask the surface map. The Insights overview arrange list marks
the breathing, labs changes and cycle blocks unavailable when their
modules are off, as the overview itself would not show them. The rows
stay in the list so a save keeps each block's place.
2026-09-24 09:51:55 +02:00
Marc-André Bombeck 39ae034b8c fix(modules): route nav entries and module pages through the surface map
Navigation destinations, the bottom bar's fixed slots and the tour stops
no longer carry their own module keys; each asks the surface map by its
href. Insights has no owner there, so switching AI analysis off leaves
the Insights entry, slot and tour stop in place.

The shell's page gate now resolves Insights sub-pages by slug as well
as top-level pages, so /insights/mood, /insights/medications and the
recovery metric pages show the module notice when their module is off,
worded from the access reason, with the Settings link only for the
record's own switch. The per-page redirect guard, the achievements
redirect and the nutrients page's own notice are gone; nothing
redirects any more.
2026-09-24 09:48:12 +02:00