Commit Graph
5579 Commits
Author SHA1 Message Date
Marc-André Bombeck fd104dd0ac feat(documents): review and extract lab values on the document itself
The server chain existed end to end (extract stages PENDING facts, per-fact
edit clears the low-confidence gate, confirm is the only write into the
structured stores) but the document detail offered no control over any of
it: it counted observation facts and linked to Labs. A user whose automatic
staging run was skipped had to re-upload the same PDF through Labs > Scan a
report.

- The detail sheet gains a staged-facts block with honest states: pending
  facts open an inline review (approve/reject per fact; a low-confidence
  fact is checked and saved first via the existing per-fact edit); a
  lab-filed, already-read document with nothing staged offers "Extract lab
  values" over its stored text; otherwise nothing renders.
- The extract route gains { mode: "stored" }: the same text-structuring
  pass the automatic worker runs, over the document's own content index.
  No re-upload, no second read of the original; full gauntlet including
  the shared document-AI bucket with charge-at-dispatch honesty; 422
  notIndexed when no stored text exists.
- A manual "Read with AI" / "Read again" now continues into the same
  lab auto-staging the background worker performs; the response reports
  labFactsStaged and the toast names the values waiting for review.
- Unchecked values are rejected and the copy says so; an unverified
  low-confidence fact receives no decision and stays pending rather than
  being silently discarded. Confirmation remains the only write into
  Labs, conditions, and medications; approved facts carry the
  committedRecordId link back to the source document (existing model, no
  schema change).
- The "lab values from this document" link now counts APPROVED facts
  only — it claims values that actually exist in Labs.
- Copy in all six locales; OpenAPI regenerated for the new extract mode
  and the index response field.
2026-08-13 00:33:14 +02:00
Marc-André Bombeck 8e16df6894 feat(documents): make the document-AI rate limit an operator setting with honest accounting
The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.

- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
  (default 6, clamped 1-1000), wired end to end: compose environment
  whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
  it was documented but never listed, so an operator value could not reach
  the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
  existing X-RateLimit-Reset header); the client derives "try again in
  about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
  the local index/echo work): malformed bodies, unreadable originals,
  refused consent, and exhausted daily budgets refund the charged slot via
  a window-scoped refundRateLimit primitive. Consistent across all four
  routes sharing the bucket.
2026-08-13 00:33:14 +02:00
Marc-André Bombeck 592d1e9aa8 fix(documents): let index-all rasterise PDFs for image-input providers
The content-index backfill built its candidate MIME list from the
provider's native PDF support, so a provider that reads images but not
PDFs never saw a single PDF in "index all documents" — while the
per-document path already rasterises a PDF to page images for exactly
that provider class. The prefilter was stricter than the authority it
fronts.

PDFs are always candidates now; prepareVisionInput stays the per
document authority (native pass-through or rasterised pages). The run
summary also stops folding outcomes into one number: skipped (nothing
billable happened, retried later) and failed (a provider call missed)
are counted separately, carried through the wide event and the worker
log, and reruns stay idempotent because indexed documents drop out of
the candidate set.

Refs #776
2026-08-12 22:52:03 +02:00
Marc-André Bombeck 3254e44281 fix(documents): keep the read action visible for an unread document under auto-read
The AI section of the document detail sheet collapsed whenever auto-read
was on and nothing was pending review, without checking whether the
document actually has a content index. A document the automatic pass
never reached (uploaded before the preference, or the pass did not
complete) then had NO visible way to be read; turning auto-read off was
the only workaround.

Collapse only once the document is read (or when indexing is unavailable
on the transport, where no action could be offered anyway). An unread
document keeps the Read with AI action, introduced by a short honest
note in all six locales; the suggest and summarise extras stay hidden
because the auto-read promise still covers them. The three states stay
distinct: unread shows the note plus the action, an in-flight read shows
the reading label, a read document collapses the block.

Refs #777
2026-08-12 22:52:03 +02:00
Marc-André Bombeck 19c54082bf fix(score): read biomarker bounds by their real column names
The score reader's labs query selected referenceLow/referenceHigh on the
joined Biomarker, but the catalog's range columns are lowerBound and
upperBound. Prisma rejects a select against names a model does not have
at query-validation time, before any row is read, so every labs read
threw and both the Glycaemia and Lipids pillars reported a failed read
permanently, Retry included, even for accounts with valid lab results.

Select the real columns, map the catalog bounds onto the reader's range
fallback, and pin two contracts: a real-Postgres round trip that scores
both pillars through the biomarker join (including a lipid row whose
range lives only on the catalog), and the honest empty state, where a
labs account with no rows reads as not tracked rather than read_failed,
which stays reserved for a read that genuinely failed.

Refs #780
2026-08-12 22:52:03 +02:00
Marc-André Bombeck d21fe48e3c Merge pull request #773 from MBombeck/release/v1.37.12
v1.37.12 — the public privacy policy matches the app
v1.37.12
2026-08-11 18:59:00 +02:00
Marc-André Bombeck 5191f213f4 chore(release): v1.37.12 — the public privacy policy matches the app 2026-08-11 18:20:18 +02:00
Marc-André Bombeck 40e2c71f42 Merge pull request #772 from MBombeck/fix/public-privacy-support
Align the public privacy data-flow disclosure
2026-08-11 17:56:18 +02:00
Marc-André Bombeck 92898b5271 docs(privacy): align the public data-flow disclosure 2026-08-11 17:36:15 +02:00
Marc-André Bombeck ca88d8c1a9 Merge pull request #771 from MBombeck/release/v1.37.11
v1.37.11 — one save for the lifestyle context
v1.37.11
2026-08-10 10:43:54 +02:00
Marc-André Bombeck 1e89b8de9c chore(release): v1.37.11 — one save for the lifestyle context 2026-08-10 10:23:59 +02:00
Marc-André Bombeck 6e85cbb2f1 test(e2e): scan the mood insights page and fix its stale a11y mock
The `/api/mood/insights` mock still returned the pre-heatmap shape, so
`summary.totalEntries` was undefined, the empty-state guard never
short-circuited, and the page threw reading `heatmap.cells`. Correct the
mock to the current MoodInsightsResponse and add `/insights/mood` to the
a11y route matrix so the page is scanned henceforth.
2026-08-10 10:23:24 +02:00
Marc-André Bombeck cfda1d9a78 refactor(charts): route the mood chart header through TileHeader
The mood chart hand-rolled a CardTitle where every other insights tile
header goes through TileHeader. Swap it for the shared primitive, which
renders the same CardTitle at text-base, and drop the now-unused CardTitle
import. Like-for-like; the surrounding control rows are untouched.
2026-08-10 10:23:24 +02:00
Marc-André Bombeck 6130f6ee6f feat(records): save the whole lifestyle-context tile with one action
The lifestyle-context tile carried a Save button per fact kind. Collapse
them into one footer Save that writes only the dirty kinds, firing the
existing bitemporal call per kind (POST a first value, PATCH a revision)
and settling them together so a single conflict cannot take the healthy
writes down with it. A kind that 409s keeps its draft on screen and names
itself, instead of vanishing behind one green toast. The per-kind Remove
stays.
2026-08-10 10:23:24 +02:00
Marc-André Bombeck b81e3928d8 Merge pull request #770 from MBombeck/release/v1.37.10
v1.37.10 — one drug, a different dose at each time
v1.37.10
2026-08-10 08:45:18 +02:00
Marc-André Bombeck 61e5c36d0a style(medications): prettier-format the per-slot consumption test 2026-08-10 08:22:42 +02:00
Marc-André Bombeck 196b37fa78 chore(release): v1.37.10 — one drug, a different dose at each time 2026-08-10 07:59:29 +02:00
Marc-André Bombeck dbb3f216d8 test(medications): type the consumption stamp reduce accumulator 2026-08-10 07:58:48 +02:00
Marc-André Bombeck 88763aaf7f test(medications): update consume mock for per-slot units reads
The consume hook now loads the user's zone, the schedules, and the
intake's scheduled anchor to resolve per-slot units, so the fake
client grows the schedule read and the batch scheduledFor lookup. The
Italian per-dose card label reworded so it no longer echoes English.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 83d31dc9c1 style(medications): prettier formatting for the per-slot override step 2026-08-10 07:58:48 +02:00
Marc-André Bombeck 33769de372 test(medications): cover per-slot units consumption and wizard round-trip
Integration: a medication with a morning slot at 1 unit and a noon slot
at 0.5 draws the right count for each taken dose against a real
Postgres. Unit: a per-slot dose + units set in the wizard survives
create and round-trips on edit-hydrate.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 3d6770ddb6 chore(api): regenerate OpenAPI for schedules[].unitsPerDose 2026-08-10 07:58:48 +02:00
Marc-André Bombeck 08a1dbccac feat(backup): carry per-schedule units-per-dose through backup and restore
The schedule exporter enumerates columns, so the new per-schedule
units-per-dose needs adding by hand: export it as a Decimal string in
the disaster-recovery payload, accept it in the wire schema, and write
it back on restore (NULL stays NULL to inherit). Extend the round-trip
test to prove a half-tablet slot survives export and re-import.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 80958ab307 feat(medications): show per-slot units on both med cards
Surface the per-schedule units-per-dose beside the per-slot dose the
card already prints, identically on the standard and the GLP-1 card,
so a split plan reads at a glance which time takes a half. Muted
addendum, no width movement.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 64093d2124 feat(medications): per-slot dose and units in the create wizard
Give each parallel schedule its own optional dose and units-per-dose
input on the times step, so a split plan (a whole tablet in the
morning, a half at noon) is ONE medication with three schedules
instead of two drugs that double-count. Leaving both blank inherits
the medication-level values, so a single-dose plan is unchanged. An
as-needed slot that sits alongside scheduled ones now rides the same
medication as a PRN schedule rather than flipping the whole-medication
as-needed flag. New keys land in all six locales.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 88123c53da feat(medications): accept and return per-schedule units-per-dose
Add schedules[].unitsPerDose to the create/update contract with the
same whole-number-or-curated-fraction validation as the medication
level, built into the Prisma data field-by-field on both the create
and the schedule-replace path. Unwrap the nullable Decimal to a JSON
number at every medication read/write response through one shared
serializer so the wire shape cannot drift, keeping NULL as NULL to
mean inherit-the-medication-level.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 820dde2365 feat(medications): consume per-schedule units for a taken dose
The inventory consume hook counted the medication-level units_per_dose
for every dose, so a split plan could not decrement a whole tablet in
the morning and a half at noon. Resolve the units from the taken
intake's own slot: match its scheduled wall-clock time in the user's
zone to a schedule and read that schedule's units_per_dose, inheriting
the medication level on NULL or no match. A medication whose schedules
all leave the column NULL resolves exactly as before. The batch-import
path binds each dose by its scheduled anchor the same way.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 0e34f9edda feat(medications): add per-schedule units-per-dose column
Add a nullable units_per_dose to medication_schedules so a split-dose
plan can consume a different unit count at different times of day (a
whole tablet in the morning, a half at noon) on ONE medication instead
of two. NULL inherits the medication-level units_per_dose, so every
existing row keeps its current behaviour. Migration 0332 adds one
nullable Decimal(10,4) column with no backfill.
2026-08-10 07:58:48 +02:00
Marc-André Bombeck 9a74bfb629 Merge pull request #769 from MBombeck/release/v1.37.9
v1.37.9 — the record you want a paramedic to have
v1.37.9
2026-08-10 05:55:03 +02:00
Marc-André Bombeck 0de59a7360 chore(release): v1.37.9 — the record you want a paramedic to have 2026-08-10 05:33:51 +02:00
Marc-André Bombeck 57938982ea feat(settings): edit emergency profile under Anamnese
Add a Notfalldaten card to the Anamnese settings section with a manager
for the three enum facts and the three free-text fields, writing through
the new emergency route. Add every string across the six shipped locales.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 310a9a4690 feat(doctor-report): put emergency data on page one
Add an EMERGENCY leaf to the identity group of the report catalogue,
default-on, no module gate. When the leaf is admitted and the profile
holds emergency data, the aggregator collects it and the PDF renders an
emergency sheet as page one (banner, blood type, severe allergies, active
medications, chronic conditions, implants, advance directive and organ
donor, contacts, notes), then breaks to send the rest of the report to
page two. The section is withheld when the leaf is not admitted or no
data is present. Guards updated for the new leaf counts.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 6770bf3b7f feat(api): add emergency profile read and write route
GET/PATCH /api/anamnesis/emergency, wrapped in apiHandler. The PATCH
validates a partial body with per-field length caps, builds the Prisma
data field-by-field from the parsed input (never a spread), narrows the
user from requireAuth rather than the body, encrypts the free text on
write, and audits the change. The GET decrypts fail-soft for the form
prefill. Registers the OpenAPI operations and schema. An integration
test reads the row back to prove the write lands.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 772da42348 feat(backup): carry emergency profile through rotation and backup
Register the three encrypted emergency columns in the rotation registry
and the key-rotation script so a legacy-key drop cannot strand them.
Carry all six new columns through the profile backup builder and restore
(the enums by value, the free text as ciphertext in a DR payload or
decrypted plaintext in a portable export) and extend the wire schema.
The full-backup round-trip integration test seeds the six columns and
asserts each survives an export and restore.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 9e52285acc feat(profile): add emergency profile storage
Fold six nullable columns into user_health_profiles: three closed-set
enums (blood type, organ-donor status, advance-directive status) read as
plaintext, and three AES-256-GCM free-text columns (emergency contacts,
implants, ICE note). Migration 0331 is additive only: three CREATE TYPE
plus one ALTER TABLE with nullable ADD COLUMNs. An integration test
asserts the columns and enum types exist on the migrated schema.
2026-08-10 05:32:58 +02:00
Marc-André Bombeck a4bea8b0ce Merge pull request #768 from MBombeck/release/v1.37.8
v1.37.8 — the assistant can answer for your visits
v1.37.8
2026-08-10 02:47:30 +02:00
Marc-André Bombeck 4d37ebfc8a chore(release): v1.37.8 — the assistant can answer for your visits 2026-08-10 02:19:59 +02:00
Marc-André Bombeck a38b3bd1df Redact a warning on the way into the wide event
addWarning pushed the raw string while setError, setHttp and
addExternalCall all scrub through redactSecrets. A warning routinely
carries an outbound error string, and for several integrations that
string is the request URL with a credential in it, so a secret could
reach stdout and the log store unredacted. It now scrubs on the way in
like the other entry points, and a test asserts a licence-key-bearing
warning is masked.
2026-08-10 02:18:45 +02:00
Marc-André Bombeck 5933a2dad0 test(links): freeze the link tables to their one gateway
A structural guard asserts that no file outside src/lib/links touches
the five link tables the service owns, with the two backup restore
writers as the only, frozen exemption. The matcher is whitespace
tolerant and proven non-vacuous: it must match every owned table inside
the service, so it can never pass by matching nothing.

This makes provable the property the link module was built for, that the
later absorption of these narrow tables into one join model is a storage
change behind the module rather than a sweep across every call site.
2026-08-10 02:18:45 +02:00
Marc-André Bombeck 448008ac93 feat(coach): give the snapshot a small visits context block
The Coach can now see that a visit is coming and what the last one was
about, without being handed a visit archive. The block is deliberately
small: the appointments booked within the next 14 days plus the single
most recent visit that happened. A full history is a get_visits
question, not a prompt-budget one.

Built as a peer of the illness and labs blocks and registered for
degradation EXPLICITLY against the lowest-priority cluster, so it is
among the first blocks shed when the character cap binds, before any
clinical cluster. Encrypted reason and outcome decrypt fail-soft and
every free-text string routes through the prompt sanitiser first.
2026-08-10 02:18:45 +02:00
Marc-André Bombeck a730ad188b feat(mcp): answer the visit history over the assistant wire
Add get_visits, a bounded read of past visits (default the last 12
months, optional practitioner-name filter) returning date, status,
kind, practitioner, specialty, the visit's own reason and outcome, and
the linked condition labels, newest first. Absence is explicit: an
account that has never recorded a visit reads { present: false }, never
{ present: true, visits: [] }.

Fold upcoming appointments into get_preventive_care as their own named
field rather than minting a second due-list tool, so a question about
what is coming up gets one answer. The Vorsorge arm stays free of
ENCOUNTER-origin reminders; the appointments are read from Encounter
directly. Reason and outcome ride the USER_TEXT wrapping, and every row
is scoped to the caller by construction.
2026-08-10 02:18:45 +02:00
Marc-André Bombeck 79de1181d8 Merge pull request #766 from MBombeck/feat/geolite2-runtime
feat/geolite2-runtime — runtime GeoLite2 fetch (#659)
v1.37.7
2026-08-09 23:22:24 +02:00
Marc-André Bombeck 4757e0588c Allow the POSIX tar the GeoLite2 fetch test uses
The runtime fetch extracts databases in pure Node, but its unit test
builds a MaxMind-shaped tar.gz fixture with the system tar, which the
dead-code gate flagged as an unlisted binary. It is available in CI and
dev, alongside the docker and pg_isready entries already listed.
2026-08-09 22:58:09 +02:00
Marc-André Bombeck 0d496aaf86 chore(release): v1.37.7 — runtime GeoLite2 fetch for self-hosters
A self-hoster on the published image can set MAXMIND_LICENSE_KEY at
runtime and the app fetches the GeoLite2 databases itself, no rebuild or
manual mount. Refs #659.
2026-08-09 22:31:51 +02:00
Marc-André Bombeck 8bad747770 Keep the MaxMind licence key out of the runtime-fetch error path
safeFetch embeds the full target URL in its thrown error, and the
GeoLite2 download URL carries license_key=<secret> in its query string.
The wide-event warning and job-failure paths do not redact it, so a
timeout or network error on the runtime fetch printed the key to stdout,
Loki and GlitchTip in clear. The download timeout is 120s for slow
self-host links, so that path is expected, not rare.

The fetch now re-throws with the edition and the error kind only, never
the URL. As defence in depth, the central query-param redactor also
learns license_key, so any other path that logs such a URL through
redactSecrets is covered too. A regression test asserts the returned
error carries neither the key nor the parameter name.
2026-08-09 22:25:48 +02:00
Marc-André Bombeck 0a2cacb8b3 Document the runtime GeoLite2 fetch path
Cover the new MAXMIND_LICENSE_KEY route in the self-hosting guide as the
first of the three ways to get the offline databases (fetch, mount, bake),
and note that the monthly runtime refresh keeps a keyed host current on its
own. Add the variable to both env examples and to the env-check manifest so
the optional group lists it.

Refs #659
2026-08-09 22:09:12 +02:00
Marc-André Bombeck 869d91c4c2 Fetch the GeoLite2 databases at runtime when a licence key is set
The offline geo tier only reached a self-host two ways: baked into the
image at build time, or mounted from the host. Both need something the
operator of a stock `:latest` pull does not have. Add a third: set
MAXMIND_LICENSE_KEY and the worker downloads GeoLite2-City + GeoLite2-ASN
itself into GEOLITE2_DIR, so the offline tier starts working with no
rebuild and no manual mount.

A worker-only pg-boss queue drives it. Boot discovery fetches once when a
key is set and no database is present yet; a monthly cron (past MaxMind's
first-Tuesday reissue) keeps a keyed host current. The fetch is fail-soft:
each edition extracts to a temp file in the target directory and is
promoted with an atomic rename only after both are in hand, so a bad key
or an upstream error leaves the previous databases exactly as they were
and never writes a partial `.mmdb`. After a successful place the reader
cache resets so a running process picks up the files without a restart.

Extraction is pure Node (zlib + a minimal tar reader), not a shell-out:
this module is reachable from the worker-boot import chain, and a
child_process import there makes Next's file tracer pull the whole project
into the standalone output. zlib keeps the trace bounded and drops the
busybox runtime dependency. The download goes through safeFetch with the
public-host pin and redirect-following, since the MaxMind permalink 302s
to a signed CDN.

The runtime image now owns /opt/geolite2 as the unprivileged app user so
the worker can write it; a read-only bring-your-own mount at that path
still wins, because the fetch cannot write over it and fails soft.

Refs #659
2026-08-09 22:09:02 +02:00
Marc-André Bombeck 9e20b32154 Merge pull request #765 from MBombeck/release/v1.37.6
release/v1.37.6
v1.37.6
2026-08-09 19:05:26 +02:00
Marc-André Bombeck 976c14b223 chore(release): v1.37.6 — the server-side safety pass
Two hardening changes collected on the trunk: a screening reminder can no
longer be marked done through a crafted request, and editing a custom mood
tag, tag group or cycle symptom now leaves an audit entry the way deleting
one already did.
2026-08-09 18:01:26 +02:00
Marc-André Bombeck fe17d06572 Give mood-tag and cycle-symptom edits an audit row
Editing a custom mood tag or group, and editing a custom cycle symptom,
wrote nothing to the audit ledger, while their delete siblings did. Two
of these paths destroy data: a custom mood tag or cycle symptom purged
with ?purge=true cascades its entry links and unpicks historical
entries, so a destruction left no trace of who ran it or when.

Every vocabulary-mutating verb now records an audit row: create, edit,
and delete for both custom tags and custom groups, and edit for a custom
cycle symptom. Encrypted labels are never logged; the row carries only
the field names touched and the resulting active state. The two
display-only writes on this surface, hiding a catalogue tag and
reordering the layout, stay ledger-free by design and say so inline,
since a per-user display preference is not a health-data write and a row
on every drag-to-reorder would be noise rather than a record.
2026-08-09 14:57:12 +02:00