Commit Graph
1378 Commits
Author SHA1 Message Date
Marc-André Bombeck ff27faf3ef docs(google-health): rewrite the mapping notes for the verified v4 contract
The previous notes carried two wrong premises: that every launch type
supports dataPoints.list, and that the response payload is snake_case.
Document the three per-type encodings (kebab path, snake_case filter,
camelCase payload union), the int64-as-string fields, the per-shape
filter grammar, the dailyRollUp read for the cumulative activity types,
the corrected daily-* data-type ids, the sleep end-time filter, the
exercise metricsSummary payload, the AIP-193 error surfacing and the
structure probe.
2026-07-05 13:59:31 +02:00
Marc-André Bombeck f92e9fd840 feat(google-health): surface the API error body and add a structure probe
- On a non-2xx data-point response, extract the AIP-193 error envelope
  into a redacted STATUS: message detail (bearer tokens stripped, URL
  query strings dropped, 200-char cap) and thread it into the thrown
  GoogleHealthApiError plus the external-call annotation, so a
  field-grammar 400 is diagnosable from operator logs. The OAuth token
  endpoint keeps its flat invalid_grant handling.
- POST /api/integrations/google-health/test with {"probe":"structure"}
  now fetches one recent page/window per data type and returns only the
  payload STRUCTURE (field names + typeof leaves, never values) plus the
  per-type error verdict — paste-safe diagnostics for payload-shape
  drift. Same auth and rate limit as the plain connection check.
2026-07-05 13:59:24 +02:00
Marc-André Bombeck 295a6a8050 fix(google-health): parse the camelCase v4 payloads and correct the read methods
The v4 client read none of its documented response shapes: the DataPoint
value is a camelCase union keyed by the camelCase type name with camelCase
nested times, proto3 int64 fields arrive as JSON strings, and four fetchers
targeted wrong data types or illegal filter fields. Every HTTP-200 type
parsed zero measurements and four fetchers returned HTTP 400.

- Read every value/time path in camelCase; keep snake_case only inside the
  filter parameter. Pin path/filter/payload-key triples per data type.
- Coerce int64 JSON strings in every numeric extractor.
- weight.weightGrams -> kg, height.heightMeters -> cm,
  distance millimetres -> m, bodyFat.percentage, heartRate.beatsPerMinute.
- Move SpO2, HRV, respiratory rate and VO2 max to their daily-* data types
  (the bare sample types reject a .date filter).
- Move steps, distance, active energy and floors to POST :dailyRollUp with
  windowSizeDays 1 (list returns minute-grain buckets, not daily totals;
  floors has no list method), chunked at 90 civil days, day-keyed on
  civilStartTime.date at the UTC-midday anchor.
- Filter sleep on interval.end_time (the only legal sleep time field) and
  anchor sessions on interval.endTime; filter exercise on
  interval.civil_start_time with an offset-less civil bound in the user's
  zone; read exercise metrics from metricsSummary and take the workout id
  from the DataPoint name.
- Seed height from max(sampleTime) explicitly - list order is descending,
  so last-row-wins picked the oldest sample.
- Rewrite the mapper fixtures to the documented shapes.
2026-07-05 13:58:50 +02:00
Marc-André Bombeck 8579705fbf fix(insights): keep the briefing on a transport-failed grounding retry
When the corrective grounding retry dies on transport (provider outage,
timeout) the model never got its chance to self-correct — stripping the
briefing to null left the morning surface empty. The strip now falls
back to the previous payload's briefing, which passed this same gate
when it was written; a content-failed retry still strips hard so a
fabricated number never persists.
2026-07-05 13:27:30 +02:00
Marc-André Bombeck e754979abf feat(ai): allow lists and one bold takeaway in the formatting contract
The shared contract banned every structure beyond 1-3 paragraphs, so
longer replies read as unformatted blocks. It now asks for short
blank-line paragraphs (2-4 on longer replies), permits a plain "- "
list when a reply genuinely enumerates three or more parallel items,
and one bold key takeaway per reply. The Coach ground rule keeps
prose-first; a list is the exception, never the default shape.
2026-07-04 23:33:17 +02:00
Marc-André Bombeck 5b63cb9ac9 Merge main into release/v1.27.0
Reconciles the v1.26.1 Fitbit setup-guide fix with the Google Health
release: google-health.md stays the full runbook (with a pointer to the
classic fitbit.md guide), the doc-parity guard now asserts the Google
Health page against the google-health client's endpoints and scopes,
and the changelog carries both sections.
2026-07-04 14:57:39 +02:00
Marc-André Bombeck 422a6a7df0 fix(google-health): correct day-keying, timezone, and skin-temperature handling
Tighten the transport so device data lands on the right day and in the
right slot.

- Cumulative daily totals (steps, distance, energy, floors) now key their
  measuredAt and stats:<tag>:<day> external id off the interval's civil
  start date anchored at UTC-midday, not the physical instant. Keying off
  the physical instant put a positive-UTC-offset user's day one calendar
  day early and split the same local day across the Google, Apple, and
  Fitbit sources so neither won the per-day source pick.
- Thread the user's timezone through the sleep, workout, and interval
  parses so an offset-less wall-clock timestamp resolves in the user's
  zone rather than the process zone — the same fix the classic Fitbit
  sleep path already carries.
- Drop skin temperature from the launch set. Google reports a signed
  nightly deviation from baseline, not an absolute reading, so mapping it
  into the absolute wrist-temperature slot stored a delta as a
  temperature and dropped every cold-night negative. It returns once a
  signed-delta model exists.
- Remove the unused daily-rollup read path and field-map, and the
  experimental ECG and irregular-rhythm scopes that had no reader — the
  connect flow now requests exactly the four core scopes it uses.
2026-07-03 14:41:44 +02:00
Marc-André Bombeck 9f7947a729 feat(google-health): add the settings card, runbook, and translations
Surface the Google Health integration to the operator and document the
setup honestly.

- A settings card matching the other integration cards: the BYO Google
  client id and secret form, connect and disconnect, a manual sync, and
  a distinct reconnect prompt when the connection needs fresh consent.
- Register the card in the connections panel and extend the integration
  key, the docs-link provider, and the query-key factory.
- Translations for the card copy across all six locales. The copy states
  plainly that this reads Fitbit, Pixel Watch, and Fitbit Air through the
  user's Google account, that it succeeds the Fitbit connection Google
  retires in September 2026, that the operator registers their own Google
  client, that stress and readiness are not available, and that a
  periodic reconnect may be needed.
- Rewrite the Google Health runbook into the real setup: a Google Cloud
  project, the Health API, an OAuth consent screen kept in Testing, a
  Web Server client, the callback redirect, the four Restricted scopes,
  the hundred-test-user ceiling, and the reconnect caveat.
- Regenerate the OpenAPI contract for the new measurement source.
2026-07-03 14:13:39 +02:00
Marc-André Bombeck b26a18d35e feat(google-health): wire the OAuth routes, sync jobs, and source priority
Add the request surface and the background sync for the Google Health
provider, all built on the shared integration plumbing.

- Routes under /api/google-health: connect (mint the PKCE pair and state
  nonce, redirect to Google), callback (timing-safe CSRF, atomic state
  consume, token exchange, connection upsert, clear the reauth flag,
  enqueue backfill), credentials (encrypt the operator's BYO client id
  and secret field-by-field), disconnect, status, and a rate-limited
  manual sync.
- Jobs: a boot-time backfill, an hourly poll cohort, and a daily
  oauth-state cleanup, registered under the existing dead-queue contract.
- Rank GOOGLE_HEALTH just after Fitbit in the per-metric source ladders;
  it is the successor API for the same devices.
- Add GOOGLE_HEALTH to the readable measurement-source enum but leave it
  out of the writable set — the provider is server-owned, so a client
  cannot attribute a write to it.
- Register the two new encrypted token columns and the two BYO-client
  columns in the encryption-rotation registry and the rotation script.
2026-07-03 14:13:28 +02:00
Marc-André Bombeck fb17a4e9c6 feat(google-health): add the Google Health API transport
Add the transport that reads a user's device data from the Google Health
API (health.googleapis.com/v4): Google OAuth with PKCE and offline
access, the paginated dataPoints reader with a daily-rollup fallback for
the types that lack a list method, the metric/activity/sleep/workout
mappers onto the shared measurement shape, and a response classifier
that separates retryable errors, hard rejects, and the re-consent case.

Access tokens live an hour and refresh tokens are reused, not rotated. A
refresh that comes back invalid_grant raises a distinct reauth-required
signal so the app can prompt a fresh consent rather than fail silently —
the periodic re-consent a Testing-mode Google client needs. The four
core readonly scopes ship by default; the Pixel Watch ECG and
irregular-rhythm scopes stay behind an opt-in flag.

Only the transport is new: the mapped-measurement output, the
upsert/dedup/rollup tail, and the source ladders are the shared ones.
2026-07-03 14:13:16 +02:00
Marc-André Bombeck 46db21dbeb fix(fitbit): correct the setup guide to the classic Fitbit Web API
The Fitbit integration connects through the classic Fitbit Web API, so a
self-hoster registers a Fitbit developer app at dev.fitbit.com — but the
setup guide walked users through creating a Google Cloud OAuth client,
whose client id the Fitbit sign-in rejects (unauthorized_client). Rewrite
the guide for the dev.fitbit.com flow (app type, HTTPS callback, scopes,
PKCE), leave the old page as a pointer, and add a test that keeps the
documented scopes and endpoints in sync with the client so the guide
cannot drift from the code again. Flags the September 2026 sunset of the
classic Fitbit Web API.
2026-07-02 09:10:38 +02:00
Marc-André Bombeck 7ca2657454 fix(api): keep logout resilient and drop the over-strict batch time bound
A transient database fault during logout must still clear the session
cookie; record the delete failure on the wide event instead of throwing
before the cookie is cleared, so a user can never stay authenticated
after logging out. Remove the batch measurement future/past instant
refine: cumulative HealthKit rows carry an end-of-day endDate that is
legitimately hours ahead for the current day, and a whole-array parse
would 422 the entire sync. Note the workout batch's deferred Withings
path must write its source directly.
2026-07-02 00:30:19 +02:00
Marc-André Bombeck fda0e504f0 fix(insights): compute the adherence storyline from the schedule-anchored engine
The Coach adherence storyline derived its rate from the raw coverage
rollup, whose denominator counts logged slots rather than scheduled
doses, so a user who logged only taken doses read ~100% and the dip gate
suppressed a real decline. Extract the dashboard tile's schedule-anchored,
cadence-aware compliance into one shared engine and route both surfaces
through it, so Coach and the dashboard report the same number.
2026-07-02 00:16:03 +02:00
Marc-André Bombeck 40859613aa fix(insights): bound the tiered rollup series to the requested window
The week/month/year rollup read anchored on now and always returned the
trailing window, so a historic from/to range (API and iOS callers) came
back with buckets outside the requested span. Thread the resolved from/to
bounds through so the read is bounded on both ends, matching the day-tier
and live-SQL fallback contract; tier selection is unchanged.
2026-07-02 00:16:02 +02:00
Marc-André Bombeck 336ce5aef3 fix(api): narrow swallowed database errors and redact the incident URL
The incident forwarder stripped the query string but not path-segment
secrets from the reported URL; route it through the same redaction the
message and stack already use. Several delete-and-swallow paths caught
every error as the expected unique/absent-row case, masking real
failures on security-notification, logout, and integration-disconnect
paths and the Telegram reply-consume guard; narrow each to the specific
Prisma code and let real failures surface.
2026-07-01 23:45:08 +02:00
Marc-André Bombeck 9aa0a3597a fix(api): constrain client-writable sources and batch timestamps
Narrow the workout create schema's source to the client-writable set
(MANUAL, APPLE_HEALTH), matching the measurement write surfaces, so a
client cannot forge a server-owned source into the cross-source picker.
Apply the single-entry plausibility bound to the measurement batch
timestamps so a backdated or future-dated row can no longer slip past
the batch path. Regenerate the OpenAPI contract.
2026-07-01 23:45:08 +02:00
Marc-André Bombeck c6dea5eb56 refactor(insights): route chart and classification colors through theme tokens
Reference-band fills, classification tones, and the trend-chart palette
carried hardcoded hex that rendered identically in both themes and
glared on the light surface. Route them through the semantic and chart
CSS variables, which already hold per-theme values, so the light theme
gets the muted set and the dark theme stays byte-identical.
2026-07-01 23:44:55 +02:00
Marc-André Bombeck bb72cbe092 fix(insights): stop the daily briefing vanishing on grounded vitals
The daily-briefing number-grounding gate strips the whole dailyBriefing block
to null when it finds a restated figure it cannot trace to a pre-computed
server value. Its allow-set covered signalsOfDay plus the glucose / labs /
preventive-care / workout / grip / waist / pain aggregates, but omitted the
core vitals blocks — weight, blood pressure, pulse, body fat, mood, sleep,
activity, rate-pressure product, seasonal variation, historical comparison —
even though the briefing prompt is fed all of them.

A verdict-first briefing that restated a blood-pressure average or a resting
pulse (figures every BP-tracking user has) therefore tripped the gate on every
run: one ungrounded number strips the entire briefing while the rest of the
insight refreshes, so the surface reads as "every other text updated, only the
briefing is gone".

Admit every figure the server pre-computes for those blocks into the allow-set.
The anti-fabrication guarantee is unchanged — only server-computed numbers are
ever added, so a genuinely invented value is still flagged and stripped.
2026-07-01 18:51:36 +02:00
Marc-André Bombeck 5e691d3be2 fix(insights): give the comprehensive briefing a longer generation budget
The comprehensive briefing aborted its provider call at the 120s budget
on a full-history account — a heavier reasoning model spent longer than
that before the first token, so the chain timed out, fell through to no
fallback, and the briefing rendered its greeting with an empty body.
Raise the comprehensive timeout to 180s so a slow first token no longer
clips a generation that would otherwise complete; the per-user response
timeout still overrides it for operators who need more, and the worker
cap tracks this value.
2026-06-30 23:41:11 +02:00
Marc-André Bombeck 7e8e848eb2 fix(medications): drop the unused DoseUnitKey export
The shared dose-unit module exported a DoseUnitKey type alias that nothing
consumed, tripping the dead-code gate. Drop it; the const array is the
only export the wizard and formatDose need.
2026-06-30 21:45:56 +02:00
Marc-André Bombeck 45ca6ff24b fix(admin): name the configured geo provider in the system snapshot
The admin system snapshot hardcoded "ipwho.is" for the offline-geo-off
state, so an operator who points IP_GEO_LOOKUP_URL at another provider
still read the wrong host. Resolve the actual provider host from the env
and surface it through /api/version, so the snapshot reads
"Online (ip-api.com)" — the host it really queries.
2026-06-30 18:08:57 +02:00
Marc-André Bombeck 6fe552ffdf fix(medications): localise the dose unit and lift it onto its own line
A medication's dose is stored as free text the wizard composes from an
amount and a unit token ("1 pieces", "2 tablets"), and the card rendered
that token verbatim glued onto the name — so the unit showed in English
on every locale and the title read "Test ABC 1 pieces".

Translate the dose at display time: a formatDose helper splits the stored
string and runs a known unit token through the existing unit messages, so
"1 pieces" reads "1 Stück" / "1 piece(s)" per locale while a custom
free-text dose passes through untouched. The card header now carries the
name alone on its title line with the dose on a muted line beneath it,
above the category badge; the detail view, table and intake dialogs
localise the same way. Vorsorge and lab rows pass an empty dose so they
render no extra line.
2026-06-30 13:39:23 +02:00
Marc-André Bombeck b3e6ba03d7 feat(measurements): add custom metrics (define, log, chart)
Add a user-defined custom-metric store alongside the built-in measurements
on the Measurements surface. A user defines an arbitrary metric (name, unit,
optional target window, decimals, description), logs values, and charts them.

The store is a separate generic catalog + values pair, isolated from the
closed measurement-type system: not synced, not in FHIR, not in insights —
log and chart only. Names and units are plaintext, mirroring the biomarker
catalog. The inline list lives beneath the measurement list (no new nav
entry); tapping a metric opens a detail page with the trend chart, target
band, stat strip, and an editable value history.

Migration 0224 adds custom_metrics and custom_metric_entries.
2026-06-30 08:54:39 +02:00
Marc-André Bombeck 9b097b0553 test(audit): type the geo fixture so strict tsc accepts a non-null location 2026-06-30 00:00:24 +02:00
Marc-André Bombeck bdac5b2c0d feat(admin): resolve the sign-in carrier online + give it its own column
The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.

Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.

A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.

In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
2026-06-29 23:51:40 +02:00
Marc-André Bombeck e51c6dc2ad feat(geo): allow an opt-in plain-HTTP location provider
A self-hoster can set IP_GEO_ALLOW_INSECURE=true alongside an http:// URL to
use a free HTTP-only provider — the free ip-api.com endpoint is HTTP-only (its
HTTPS form needs a paid key) but its geolocation is often more accurate and it
carries the ISP. The default stays HTTPS-only with ipwho.is; plain HTTP is
refused unless the operator explicitly opts in, since the looked-up IP then
travels unencrypted over the server's own egress. Whitelisted in compose and
documented in the env example.
2026-06-29 20:38:04 +02:00
Marc-André Bombeck abb4c71d0c feat(geo): make ipwho.is the default IP-location provider again
ipwho.is is free, needs no key, and resolves a city/country for the login
overview out of the box. It is the default online lookup again; the offline
GeoLite2 MMDB tier stays strictly optional (consulted only on an online miss,
skipped when the databases are absent). Operators whose egress ipwho.is
rejects can point IP_GEO_LOOKUP_URL at ip-api.com — the parser accepts both
response shapes, so the swap needs no code change.
2026-06-29 20:00:53 +02:00
Marc-André Bombeck 7e901e674a fix(ui): own the time picker so it follows the 24-hour preference
The native datetime-local / time picker renders its clock and AM/PM
toggle by the browser UI language, not the page lang or the app's
time-format preference — so an English-language browser showed AM/PM
even with 24-hour selected. Replace the native time control with an
app-controlled TimeField (text entry + a dependency-free popover of
hour/minute, plus AM/PM in 12-hour mode) whose hour cycle always tracks
the preference. DateTimeField now composes the native calendar (date)
with TimeField (time); the committed value stays a local
yyyy-MM-ddTHH:mm string, and min/max clamp the recombined value.

Also thread the time-format preference into the doctor-report PDF so its
generated-at timestamp honours it instead of the locale default.
2026-06-29 19:03:47 +02:00
Marc-André Bombeck 0d45d0ab53 fix(format): honour the time-format preference on every clock renderer
The H24 / H12 preference only reached makeFormatters().time. Surfaces that
build their own Intl.DateTimeFormat — the sleep hypnogram axis, the workout
list and detail headers — omitted the hour cycle, so an en-locale (or AUTO)
user saw AM/PM even with H24 selected. The security activity feed and session
list rendered raw toLocaleString(), bypassing both the locale and the
preference entirely.

Export hourCycleOptions from format-locale as the single source for the
H12 / H24 / AUTO Intl options and spread it into the self-built formatters,
preserving each surface's locale and timezone. Route the security cards
through useFormatters().dateTime. Drop the date-time field's local copy of
the helper.

Guards: the helper contract, the en-US hour/minute call-site pattern (no
AM/PM under H24, AM/PM under H12), and formatUpdatedLabel fed an H24
formatter for the briefing freshness label.
2026-06-29 15:47:48 +02:00
Marc-André Bombeck d3057e4a92 Merge v1.25.3: v1253-briefing-timeout 2026-06-29 15:30:33 +02:00
Marc-André Bombeck 4ad4707be9 fix(insights): derive briefing warm budget from the response timeout
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.

Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.

Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.

Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
2026-06-29 15:08:04 +02:00
Marc-André Bombeck 7e83af5701 fix(modules): switch the inbound-documents module off in code
The inbound-documents surface is withdrawn from this release and returns
later with more depth. Park its module key (`inboundDocuments`) in a new
`CODE_DISABLED_MODULE_KEYS` list so it is hard-off for every account ahead
of both the operator-availability and the per-user opt-in layer.

The gate short-circuits a code-disabled key to disabled, so the resolved
module map reports it off everywhere: the nav entry stays born-gated and
hidden, the `/documents` page redirects, and every `/api/documents/inbound/*`
route returns the 403 module-disabled envelope. The Modules hub and the
operator-availability panel drop the row, and the per-user PATCH surface
refuses to write the key (a clean 422 instead of an inert preference).

The whole implementation stays intact — routes, the inbound-documents view,
the registry entry with its opt-in marker, the validation schemas, and the
migration are all preserved. Re-enabling the module is a one-line removal
from the list once the rebuild lands.

Regenerate the OpenAPI contract: the module-preferences PATCH body no
longer advertises the parked key.
2026-06-29 14:58:30 +02:00
Marc-André Bombeck 2321ce29db fix(insights): route alert-card compliance through the cadence-aware engine + deep data cleanups
The insights alert cards computed medication compliance with the naive
`schedules.length × days` denominator, so a fully-adherent weekly
injectable read ~14% and fired a false low-compliance alert (and a paused
medication mis-fired the same way). Route the alert path through the
canonical `calculateCompliance` engine like every other compliance
surface — pause eras, schedule revisions and dose windows honoured.

Document confirm: committing a numeric observation against an existing
biomarker silently replaced the stated unit with the catalog's, with no
conversion — a stated "6.1 mmol/L" against a mg/dL marker committed
"6.1 mg/dL". Reject on a unit mismatch (fail closed) so the user
reconciles on the review screen, the same posture as the unit-required
guard.

Wire grip strength, waist (with waist-to-height) and pain into the
briefing feature set and the grounding allow-set so the clinical-depth
signals reach the daily narrative; the mental-health screeners stay
excluded by construction.

Biomarker detail: fall back to a generic description for catalog-less
markers, and keep the heading + description with a retry on the error
state. Collapse the medication External-API and Lifecycle groups by
default. Re-enable the insights scroll-to-top regression and assert the
shipped main-content reset.

Align the EN haemoglobin/haematocrit display names to British spelling.

Drop the redundant `(userId, deletedAt)` index on inbound documents — a
strict prefix of the new three-column index. Trim the dead
MedicalDisclaimer variants to the one live `dataPosture`. Move the
crisis-resources card off the retired amber tint to a calm
destructive-subtle border. Add a registry invariant asserting every
biomarker signal key resolves to a real catalog slug.
2026-06-29 00:37:39 +02:00
Marc-André Bombeck eb1779d5c1 fix(documents): close v1.25.1 library QA findings (backend)
Default documentDate at store time. A store-only upload no longer lands
with a NULL filing date: when the client omits one, default it to the
upload day so the column is always set on a stored row. The library list
sorts, groups, and range-filters on the same effective date the UI shows,
so undated uploads no longer mis-sort under nulls-last, break date-group
headers, or fall through the range filter. The date stays user-editable
and may be cleared back to NULL via PATCH.

Block re-extraction once any fact is APPROVED. A partially-confirmed
document stays EXTRACTED, which the CONFIRMED guard does not catch;
re-extracting would delete the approved facts (severing the committed
record's provenance) and re-stage them as PENDING, letting a second
approval duplicate the committed lab / condition / medication. The
extract route now refuses with 409 (documents.inbound.alreadyPartlyConfirmed)
when approved facts exist; the user must finish reviewing or discard first.

RFC 5987-encode the original-download filename. The Content-Disposition
now carries both an ASCII filename= fallback and a filename*=UTF-8'' value
so a non-ASCII name survives intact; the stored set is inline-safe by
construction, so the dead attachment branch is removed.

Add a light per-user rate-limit backstop to the original-read, PATCH, and
DELETE routes (generous; self-DoS only — the routes are owner-scoped).

Lean the list counts: tally facts for the whole page in one grouped count
instead of materialising fact-id rows, and exclude REJECTED facts from
factCount. Extend the documentDate index with deleted_at to cover the
list scan. Document the deliberate plaintext-title PHI tradeoff at the
column and the store site.
2026-06-28 23:48:43 +02:00
Marc-André Bombeck c6a7de8f2a feat(documents): build the Documents library UI
Evolve the inbound-documents screen into a real document library on top of
the store-first backend.

- Upload stays store-only and always works: the form now files a document
  with an optional title, category, and document date in one step. Extraction
  is a separate, explicit action.
- List becomes an organized library: documents grouped by date, with a title
  (filename fallback), category and status badges, and pending/fact counts. A
  toolbar adds title/filename search, a category filter, and a sort control
  (document date / date added / title, asc/desc). Keyset pagination loads more
  via the returned cursor through useInfiniteQuery.
- Detail panel: view or download the original, edit metadata (rename,
  recategorise, set the document date), optionally run extraction, then the
  existing review-then-confirm flow. When no scan provider is configured,
  extraction surfaces a calm inline note instead of a hard error; the stored
  document is untouched. Discard stays.
- Add a parameterised list key to the query-key factory so each filter/sort
  combination keeps its own cache bucket while staying under the documents
  prefix for invalidation.
- Rename the surface to "Documents" across all six locales (title, subtitle,
  module label/description) and add the new toolbar, edit, extract, and
  date-group strings. Route, model, and module key stay internal.

Add component and unit coverage for the grouped list, the detail/review
surface, the metadata edit form, the provider-unsupported note, and the
search-param/grouping/error-classification helpers.
2026-06-28 23:26:01 +02:00
Marc-André Bombeck cdd66b6620 Merge v1.25.1: documents library backend — store-without-AI, optional extract, edit, list
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/documents/store.ts
#	src/lib/openapi/routes/documents.ts
2026-06-28 23:08:54 +02:00
Marc-André Bombeck 05465a35a5 feat(documents): store-first library with optional extraction
Invert the inbound-documents surface from an OCR inbox into a document
library: a file can be stored without any AI provider, and extraction
becomes an opt-in action on top.

- Migration 0222: add `title` + `document_date` columns, a
  `(user_id, document_date)` index, the richer kind taxonomy
  (lab/imaging/prescription/insurance/vaccination), and a STORED status
  made the column default. The status enum is recreated so the new value
  can be set as the default within the migration transaction; kind values
  use ADD VALUE. Nullable columns, no backfill.
- POST /api/documents/inbound is now store-only: bounded multipart read,
  magic-byte MIME sniff, AES-256-GCM at rest, persisted as STORED with no
  provider/consent/budget call and no egress.
- POST /api/documents/inbound/[id]/extract carries the relocated
  extraction gauntlet (resolve provider, consent, rate-limit, budget, run,
  reconcile, stage facts) against an already-stored row; absent a provider
  it 422s the enhancement only and leaves the stored document intact.
- PATCH /api/documents/inbound/[id] edits title/kind/documentDate
  field-by-field, owner-scoped by session userId.
- GET /api/documents/inbound gains search, kind filter, documentDate
  range, sort, and keyset pagination via the parameter-bound query builder.
- Extend the validation schemas + DTO, regenerate the OpenAPI contract,
  and add route tests for the store/extract/edit/list paths.
2026-06-28 23:04:19 +02:00
Marc-André Bombeck 6b7e6f3332 Merge v1.25.1: wellbeing check-in as a standalone module page 2026-06-28 21:30:53 +02:00
Marc-André Bombeck 37c977e3a4 feat(mental-health): make the wellbeing check-in a standalone module page
Move the PHQ-9 / GAD-7 screener off `/insights/mental-wellbeing` to a
top-level `/mental-wellbeing` route so it no longer borrows the Insights
tab strip and layout shell. The page now uses the standard module-page
frame at the wider content width the other module pages use.

Replace the standing screening disclaimer block and the opt-in/private
line in the header with an info icon after the page heading whose
hover/focus tooltip carries the screening-not-a-diagnosis note. Drop the
now-orphaned `disclaimer` / `optInNote` strings across all six locales
and add the `infoTooltip` string in their place.

The questionnaire, scoring, severity result, PHQ-9 item-9 crisis-resource
handling, focus-on-submit, and 44px touch targets are unchanged. Repoint
the module nav entry and the registry comments at the new route.
2026-06-28 20:48:39 +02:00
Marc-André Bombeck 08314c7c09 feat(documents): view and download the original inbound document
The inbound-document store keeps the uploaded doctor letter encrypted at
rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way
to read the original back — the decrypt helper had been dropped as unused.

- Restore decryptDocumentFromBytes in the document store, fail-closed on a
  bad / missing key id.
- Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed
  query), module-gated, returns the decrypted bytes with the stored MIME
  type. A PDF / image is served inline so the browser can render it; other
  types download as an attachment. The filename is sanitised before it
  reaches the Content-Disposition header. The bytes are PHI — never logged,
  never served cross-user, and a decrypt error fails closed (500) rather than
  returning ciphertext. Cache-Control is private, no-store.
- Surface a 'View original' action in the document review screen.
- Document the route in the OpenAPI registry and regenerate the spec.
- Cover the route with a test: owner-scoped success, attachment fallback,
  404 for another user's document, and fail-closed on decrypt error.

i18n: viewOriginal across all six locales.
2026-06-28 20:33:29 +02:00
Marc-André Bombeck 6e204d054a feat(insights): age-stratify grip strength and waist reference bands
Grip strength and waist circumference resolved against a single sex-aware
bracket for every adult age. Replace each with age x sex norm bands:

- Grip strength: age x sex bands from Dodds et al. 2014 (PLOS ONE
  9(12):e113637) British normative data — band low is the 10th centile
  floored at the EWGSOP2 probable-sarcopenia cut-off (men 27 kg, women
  16 kg), band high is the 50th-centile median. Strength peaks in the 30s
  and declines from midlife, so the bands step down with age.
- Waist circumference: WHO increased-risk threshold (men 94 cm, women
  80 cm) held flat through midlife and raised for older adults toward the
  WHO substantially-increased-risk line, since the WHO cut-points derive
  from cohorts aged 20-74. The standard threshold is never lowered.

The norms resolver gains an age-independent clinical cut-off row per sex,
used only as the no-age fallback so a profile with a known sex but no date
of birth keeps the sex cut-off rather than the coarse registry anchor. The
signal registry structure is unchanged — only the resolver tables move.
2026-06-28 20:22:24 +02:00
Marc-André Bombeck 0f2b548138 style: apply prettier 3.9.1 formatting
Mechanical reflow from the prettier 3.9.1 bump; no behaviour change.
2026-06-28 19:26:16 +02:00
Marc-André Bombeck 4ae783064d fix(ci): clear the v1.25 release-branch gate failures
- Reword two LOINC verification comments in the signals registry so they
  no longer trip the forbidden-marker grep, keeping the meaning intact.
- Resolve the dead-code gate: gate the seeded-question opener on the
  per-user ambient-suggestions opt-out (wiring the previously orphaned
  resolveCoachAmbientSuggestionsEnabled, server-authoritative), drop the
  unread decryptDocumentFromBytes helper and its now-unused import, and
  remove the unused SignalKind type alias.
- Delegate undici's fetch to the global stub in the provider-chain
  integration test so the egress-pinned client HTTP is mocked again.
- Defer the JSON example-download cleanup a tick so revoking the blob URL
  no longer races the browser download start (stabilises the e2e).
- Sync the service-worker version fallback to v1.25.0.
2026-06-28 19:23:57 +02:00
Marc-André Bombeck 930cb227eb feat(mental-health): accept client source + externalId with durable dedup
The screener POST hardcoded source=WEB and ignored any client-supplied
externalId, so the native client could not tag provenance and an outbox
replay beyond the 24h idempotency-key window minted duplicate
administrations plus duplicate trend points.

Accept an optional closed source enum (WEB|IOS, default WEB) and an optional
bounded externalId, and persist both. Deduplicate on (userId, externalId)
when present: the route returns the existing administration on replay, and a
partial unique index over (user_id, external_id) WHERE external_id IS NOT
NULL (migration 0221) backstops a concurrent insert race (P2002 resolved to
the winner). The derived *_SCORE measurement keeps its stable per-assessment
externalId, so dedup at the assessment level prevents duplicate trend points.
Item answers stay encrypted and out of AI/MCP/export. Regenerate OpenAPI.
2026-06-28 19:06:21 +02:00
Marc-André Bombeck e93f74381e feat(measurements): map HealthKit waist circumference to WAIST_CIRCUMFERENCE
HealthKit emits HKQuantityTypeIdentifierWaistCircumference as a length
quantity in metres, but the mapping table had no entry for it, so a batch
upload of that identifier was silently dropped (mapAppleHealthEntry returned
null). Add the bidirectional mapping with metre-to-cm scaling so the round
trip lands on the canonical WAIST_CIRCUMFERENCE measurement (cm), matching
getUnitForType and the WHO display bands. Extend the mapping test.
2026-06-28 18:59:11 +02:00
Marc-André Bombeck 3d304acf1e merge: QA fix polish into release/v1.25.0 2026-06-28 18:36:56 +02:00
Marc-André Bombeck 8e4c107a18 merge: QA fix mental-health into release/v1.25.0 2026-06-28 18:36:26 +02:00
Marc-André Bombeck a1df33352a merge: QA fix physical-signals into release/v1.25.0 2026-06-28 18:36:00 +02:00
Marc-André Bombeck a69697b50f fix(insights): wire grip / pain / waist / waist-to-height across web and MCP
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.

Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).

Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.

Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).

MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
2026-06-28 18:33:53 +02:00
Marc-André Bombeck 13ee5e2df4 fix(labs): paginate the biomarker reading feed
The detail page fetched a single limit=500 window and sorted client-side,
silently truncating any marker that crossed 500 readings. Switch to an
offset-paginated useInfiniteQuery: it loads a page at a time, a 'Load more'
control reveals the rest, and the chart and stat strip render the
accumulated set so loading more extends the trend.
2026-06-28 18:30:39 +02:00