Commit Graph
2379 Commits
Author SHA1 Message Date
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 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 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 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 e5072ba99e fix(reminders): bound a rolling reminder's re-anchor by the notify-hour slot's own day
A rolling reminder at the ten-year maximum interval lost its next due when
the satisfy instant fell in the late-UTC evening. The next-occurrence search
bounded the single deterministic rolling slot by the end of the anchor's UTC
day, but the applied notify-hour slot lands on the following UTC day whenever
the anchor sits in the evening (the local morning is the next calendar day in
a positive-offset zone). The anchor-day ceiling was then a day short and
clipped the very slot it meant to admit, so logging the next dose set nextDueAt
to null and the booster silently disappeared. Key the ceiling off the applied
slot instant instead; endsOn stays enforced inside expandRolling.
2026-08-09 00:21:39 +02:00
Marc-André Bombeck 4766828555 fix(reminders): keep a maximum-interval rolling reminder's next due after satisfy
A rolling reminder at the engine's ten-year maximum interval lost its next
due date the moment its cadence was satisfied. The rolling branch of
nextOccurrenceAfter bounded its search by the 10-year anti-spin hardCap, and
a satisfied reminder computes its next slot with after === lastSatisfiedAt,
so the single deterministic slot lands exactly after + 3650 days — equal to
the hardCap to the millisecond. The notify-hour slot then spilled a few hours
past the raw boundary and was clipped to nothing, leaving nextDueAt null.

The rolling slot is one deterministic instant, not an open-ended walk, so the
anti-spin cap must never gate it. Bound the rolling search by that slot's own
day instead; endsOn stays enforced inside expandRolling, so a course-window
reminder still self-expires.

This surfaced on a ten-year tetanus booster: logging the next dose silently
dropped the reminder instead of rolling it forward a decade.
2026-08-08 23:54:44 +02:00
Marc-André Bombeck 999206767f Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-2 2026-08-08 23:01:36 +02:00
Marc-André Bombeck 958f3bc50a test(vaccinations): reconcile the structural guards with the new surfaces
- freeze the booster-mint and upload-suggestion routes into the sharing
  surface guard with their why sentences; delegable-write and mutation
  inventories move by the two routes they add
- inventory the two new routes as module-exempt data routes (SURFACE-gated,
  like the medications posture)
- give the booster-plan audit action its own owner-facing verb in six locales
- register the dose delete as a destructive control; route the vaccination
  success toasts through the outcome module
- move the report leaf counts (structured 15→16, all 92→93) and the history
  group count in the panel; regenerate the OpenAPI selection bound
- seed the immunization rows in the export-route mock so the aggregator no
  longer throws for a fully-selected report
2026-08-08 21:43:28 +02:00
Marc-André Bombeck 2da0159577 feat(vaccinations): add the immunization history to the doctor report
- IMMUNIZATIONS report leaf in the history group, gated both ways: the
  vaccinations module and the leaf selection each exclude independently
  (WR-7 seen red on the selection gate, reverted by content hash)
- collector loads the whole live history (reference data, not windowed, like
  allergies) with server-resolved series positions; the PDF renders a compact
  per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis
- added to the default template so a new report offers it; stored selections
  and frozen share links never widen
- leaf label and section copy in six locales
2026-08-08 21:21:12 +02:00
Marc-André Bombeck e0692efba8 fix(sharing): widen the grant db slice for the expired-row sweep
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
2026-08-08 21:14:23 +02:00
Marc-André Bombeck 9b58394724 feat(vaccinations): link documents to doses, with the upload suggestion
- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
2026-08-08 21:11:21 +02:00
Marc-André Bombeck de83954a7f fix(sharing): let an expired grant be re-granted instead of colliding
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
2026-08-08 21:01:50 +02:00
Marc-André Bombeck b5ce44628a feat(vaccinations): mint the booster reminder a logged dose suggests
- rung 2: a prefilled, user-confirmed, editable, declinable prompt writes an
  ordinary VORSORGE reminder keyed on the dose's primary antigen; the
  published origin enum and reminder DTO are unchanged
- idempotent by antigen — a second confirmation re-anchors the existing
  reminder rather than minting a second (WR-9 seen red on the reminder count,
  reverted by content hash)
- rolling day cadence so logging the next dose re-anchors the due date through
  the Phase 1 satisfy matcher; the whole loop proven through real routes
- booster prompt copy in six locales, plural-aware
2026-08-08 21:01:25 +02:00
Marc-André Bombeck e344ebb781 fix(settings): drop the score pillar group headings for a flat list
The score-composition settings grouped its topics under domain headings
(Heart and metabolism, Activity, Sleep, Body shape, Wellbeing). Remove
them and list the individual topics one under another with hairline
dividers, the way the health profile lists its facts. Each row keeps its
domain data attribute, so the flattening preserves the registry order and
tests still address a pillar by its domain. The now-orphaned domain-label
map and its six-locale strings go with it.
2026-08-08 20:56:50 +02:00
Marc-André Bombeck ff72917833 feat(vaccinations): add the dose capture and edit form
- one sheet for create and edit, saving with a date and one identity arm
  (catalogue pick or free text); nothing else can block the save
- catalogue picker searches localised names and generic synonyms over the
  static antigen list, never overwriting the free-text arm
- optional dose/series, lot, site, practice combobox, visit suggestion and
  note, all reusing the encounter-form primitives
- write hooks fan out through the dose dependent-keys and the reminder reads
- form/site/sheet copy in six locales
2026-08-08 20:40:40 +02:00
Marc-André Bombeck 1d07204293 feat(vaccinations): group the log by component antigen with resolved series
- read the immunization log through the query-key factory, unwrapping the
  envelope; the series arrive resolved per component antigen from the DTO
- render one group per antigen, a combined dose appearing in each of its
  component groups with that component's own position; free-text and dead-slug
  rows fold under their verbatim name
- neutral cards, meta in muted, no due status on the page
- add the dose-list dependent-keys bundle, evicting the preventive-care root
  alongside the dose list so a satisfied booster never lingers on checkups
- series/lot/error copy in six locales
2026-08-08 20:32:05 +02:00
Marc-André Bombeck eefc151617 feat(vaccinations): add the immunization log route, nav entry and shared-record family
- register /vaccinations in the clinical nav spine, module-gated on
  vaccinations (default-on) and part of the shared record
- join /vaccinations to the profile shared-record route family so a scoped
  profile grant can present the page it is allowed to read
- add the page shell: authenticated, module-gated, with the standard
  module-off redirect the medications surface uses
- add the nav label and the surface title/subtitle/empty copy in six locales
2026-08-08 20:24:42 +02:00
Marc-André Bombeck 4f2dd51390 Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-1 2026-08-08 19:35:18 +02:00
Marc-André Bombeck 08d3cd2c31 Split the ECG cap and one provenance entry off their label maps
The /insights overview crept a kilobyte over its ceiling: the ECG
teaser imported its overview cap from the classification module, which
dragged that module's label map and helpers into the eager page, and
the Health Score card pulled the whole metric-provenance map for its
one citation. The cap now lives in a value-only module and the single
provenance entry is split out, so the overview imports the bare number
and citation it renders. Both routes are back under budget. The
aggregate ceiling rises to hold the six-locale copy the new encounter,
mood-context, mood-prognosis and practitioner surfaces added, which the
whole-build sum counts independently and no single route downloads.
2026-08-08 17:56:40 +02:00
Marc-André Bombeck e1a4b7631c Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
2026-08-08 17:07:58 +02:00
Marc-André Bombeck 4c2dfa57b4 Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish 2026-08-08 16:17:40 +02:00
Marc-André Bombeck f28c6df861 Merge branch 'main' into feat/encounter-phase-2 2026-08-08 16:11:50 +02:00
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

- the account-access resolver publishes the owner's `fullName` alongside the
  greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
  on the argument so the grant-view and actor shapes that never carried it
  keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
  compares it, so the two published views of the active record cannot name
  the same owner differently
- the OpenAPI contract regenerates with the additive field

This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
2026-08-08 16:07:25 +02:00
Marc-André Bombeck 8f3e4b6387 fix(insights): cap the ECG teaser, never the ECG page
The five-strip cap belonged on the overview teaser — a glance on /insights —
not on the surface a user opens to read their history. It was living in the
shared EcgSection, which the dedicated /insights/ecg page reuses, so an
account with many recordings could only ever reach the newest five there and
the older strips became unreachable with no pagination behind them.

The cap is a prop now. The teaser passes ECG_OVERVIEW_LIMIT; the page passes
nothing and lists every recording. The constant lives in the React-free
classification module so the overview page can pass it without pulling the
dynamically-loaded ECG component into its bundle.
2026-08-08 16:06:57 +02:00
Marc-André Bombeck ab40fe6a6e Merge branch 'main' into feat/vaccinations-phase-1 2026-08-08 15:51:57 +02:00
Marc-André Bombeck 0a543abd18 refactor(vaccinations): drop the dependent-keys bundle nothing consumes yet
The dead-code gate is right: a bundle with no caller is dead code. The
capture surface brings it back with the mutation that needs it, and the
key module says what that bundle will have to evict.
2026-08-08 15:51:54 +02:00
Marc-André Bombeck 0bf3f5c8d9 Merge remote-tracking branch 'origin/main' into feat/mood-prognosis 2026-08-08 15:41:13 +02:00
Marc-André Bombeck 75c43c74bc docs(mood): state the validation's real leak boundary, drop the unused weekday floor, correct two comments
Four honesty fixes with no behaviour change to the math.

The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.

The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.

Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.

And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.

The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
2026-08-08 15:30:38 +02:00
Marc-André Bombeck d58edc5deb fix(mood): floor the fit on complete rows, not only on rated days
The ladder gate counts days carrying a rating. But a day missing one of
the fit's selected features is dropped rather than imputed, so a
sparse-context account can clear thirty rated days and be fitted on far
fewer complete rows — a forecast "built from twenty days" past a floor
meant to say thirty. n beside the value and a wide band on a small fit
keep it from being dishonest, but the floor should still hold.

The number of complete rows the fit actually used now has to clear the
same threshold as the rated-day count; below it the account gets the
absence shape, and the read path then reports "not enough of a pattern
yet" rather than a value resting on a handful of days.

Watched red with a fixture of forty rated days carrying the driving
feature on only the twenty-five most recent — enough to screen the
feature in and to score the recent days, so the refusal is about the
complete-row count and nothing else. Before the floor it wrote n=25.
2026-08-08 15:30:20 +02:00
Marc-André Bombeck 9a47b4db49 fix(mood): refuse a forecast below the data floor on the read path, not only in the job
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.

The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.

The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
2026-08-08 15:30:00 +02:00
Marc-André Bombeck dd04931c9d chore(query-keys): restore the visit invalidation bundle with its consumers
The bundle landed a release before the surfaces that read it and was removed
on trunk as dead. Both mutation hooks in this branch use it, and the merge took
trunk's deletion, so it comes back here together with the writes that need it.
The comment now names its consumers, so the next dead-code sweep can see them
without reading two files.
2026-08-08 15:22:13 +02:00
Marc-André Bombeck dc56f70961 fix(insights): give an ECG recording its own page and quiet the list
The ECG surface opened with three lines of muted text saying what HealthLog
does not do, before a single strip. Two of them said the same thing — one
from the page's explainer slot, one hand-rolled beneath it — and the third
repeated it under the list. The device attribution on the recording itself
already carries that claim where it means something, so the three go and the
page opens on the recordings.

- the device's verdict rides the timestamp as a tag instead of taking a line
  of its own beneath it. It qualifies the recording; it is not a second fact
- a recording is a route now, `/insights/ecg/[id]`, with the back link ABOVE
  the heading exactly like a workout detail. The detail used to swap itself
  into the list card with its back button under the page title, and the
  recording had no address at all: nothing to deep link, nothing for the
  browser's own Back to return to. It reads by id, so a cold link works
- the list paints the five most recent strips. A watch that records every
  morning turns an unbounded list into a scroll, not a history

`ecg-classification.ts` holds the verdict label map the list, the detail and
the cross-link card each used to carry a copy of.
2026-08-08 15:17:05 +02:00
Marc-André Bombeck 0160052289 refactor(settings): retire Research Mode — the switch governed nothing
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.

The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.

Settings → Advanced is now what it reads as: reset the data, delete the
account.
2026-08-08 15:17:05 +02:00
Marc-André Bombeck 13b4a0be0b refactor(settings): thin the Account tree down to the profile
Two cards on Settings → Account were asking questions that already have a
home somewhere else.

- cycle tracking is a module, and the Modules hub carries the switch that
  drives the same `cycleTrackingEnabled` column. The Account card was a
  second door to one decision, so it goes, and the "manage in Account"
  deep-link beside the module switch goes with it. That link pointed at a
  `#cycle-tracking` anchor no element ever rendered, so it never landed
  anywhere in the first place
- changing a password belongs with the second factors and the passkeys.
  The card moves to Settings → Security and leads that page. It renders
  before the factor status resolves: it is now the only way to change a
  password and must not depend on a read it does not need

Account keeps the profile, the photo, the identity fields and the AI
context. The `settings.cycleTracking.*` strings leave all six bundles.
2026-08-08 15:17:05 +02:00
Marc-André Bombeck bfdc2ae025 Merge remote-tracking branch 'origin/main' into feat/encounter-phase-2 2026-08-08 15:08:17 +02:00
Marc-André Bombeck 2d69842994 fix(encounters): the visit list carries its links
Two defects behind one omission. The card counts what a visit produced, so a
list that dropped the links made every card read zero for a visit with three
documents. And the edit sheet seeds its pickers from the row it was handed: an
edit opened from a linkless list row would save a replace-set of three empty
arrays and silently unfile everything the visit had collected.

The links now ride the list, resolved in three grouped queries for the whole
page through the batched arm of the same service the detail uses, with the same
per-family redaction from the same predicate. The single-visit read hook had no
caller and is gone with it — the list is where the surfaces read from.

Both defects get a test that would have caught them: the card counts assert
against a populated links object, the draft asserts the arrays survive a round
trip through draftToBody, and the integration file asserts the list response
itself carries every family.
2026-08-08 15:06:10 +02:00
Marc-André Bombeck b97988279e feat(vaccinations): wire the query keys, the contract and the consent copy
- a query-key module and a dependent bundle that also evicts the
  preventive-care root, because logging a dose settles a booster and the
  checkups list goes stale in the same tick
- the OpenAPI module publishes the resolved series with the contract
  written into the description: render text from these numbers, never
  recompute them
- the health-background consent sentence now names the immunization
  history in all six languages, so the widening is stated where it is
  agreed to rather than inferred from a code comment
- no server-side cache helper: nothing cached reads a dose, and a sweep
  over caches nobody reads would look like coverage without being it
2026-08-08 14:43:59 +02:00
Marc-André Bombeck 4d2f706dad feat(doctor-report): a report can mention a visit, and know why it exists
The VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.

The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.

No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.

Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.

The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
2026-08-08 14:38:26 +02:00
Marc-André Bombeck 82982038cb feat(vaccinations): let a logged dose clear the boosters it answers
- the dose is expanded to its component antigens first, so one Tdap
  entry settles a tetanus booster and a pertussis booster at once;
  matching the preparation's own slug would leave both standing
- it runs inside the create transaction and nowhere else: no cron, no
  ingest hook, and an edit never re-runs it, so correcting a typo in a
  date cannot move a reminder
- forward-only, which is what makes transcribing an old record safe: a
  page from decades ago never clears a booster that is due now
- a free-text-only dose matches nothing; a name string is not evidence
  about what was in the syringe
- an ordinary preventive-care reminder carries no antigen key and is
  structurally out of reach
2026-08-08 14:36:08 +02:00
Marc-André Bombeck 7ac81f3eed feat(vaccinations): publish the routes, classify them, gate the module
- four route files: list and create, read and edit and delete, restore,
  and the link pair; every delegable one is classified as health
  background, in each leg of the sharing guard, with its reason written
- link and unlink stay bare, consistent with the document and visit link
  routes; the three are revisited as one decision
- vaccinations becomes the nineteenth module key, default-on and
  surface-gated like medications: the data routes stay exempt so a
  restore keeps working and re-enabling finds every dose intact
- a delegate's dose entries get their own sentence in the owner's
  activity view rather than reading as a generic change
2026-08-08 14:27:36 +02:00
Marc-André Bombeck 4dc3b1aa27 feat(vaccinations): add the wire schemas and the resolved DTO
- a dose needs a day and one way of naming what it was, and nothing
  else; a required catalogue pick would make an old Pass line unloggable
- the write path refuses a future date and a slug the catalogue does not
  offer, while storage stays tolerant so an older backup restores whole
- the DTO publishes the practitioner, the visit, the linked pages, the
  catalogue entry and the derived series already resolved, in numbers
  rather than localised text
- a linked page's name and date are withheld from a delegate whose grant
  does not reach the vault, and the withholding says so rather than
  looking like a page with no name
2026-08-08 14:09:52 +02:00
Marc-André Bombeck c47122ccfe feat(encounters): the lab moment, the condition side, and closing a checkup
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.

The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.

A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.

An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.

A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.

OpenAPI regenerated in the same commit.
2026-08-08 14:08:09 +02:00
Marc-André Bombeck e22c7ddfcc feat(vaccinations): teach the link facade the dose-to-document pair
- one new source and one new pair; the module's invariants carry over
  unchanged, so ownership, idempotency, the batch cap and the
  soft-delete refusal need no new code
- each invariant gets its own case against the new table, because every
  one of them is a where clause on a different table and a pair wired to
  the wrong column would pass all the existing ones
- the ceiling is written down and asserted: the vaccination source owns
  exactly one pair, and asking for a second is refused loudly
2026-08-08 14:05:58 +02:00
Marc-André Bombeck e44b1615e1 feat(vaccinations): derive the series position per component antigen
- one pure function resolves "dose N of M" over a person's whole
  history, so the number rides the DTO already computed and no client
  re-derives it
- a combined dose counts into every antigen it contains, at whatever
  position each of those series is at: one Tdap line is a tetanus
  booster and a first pertussis dose in the same act
- the Pass's own dose number wins over the count, and the record's own
  series length wins over the catalogue's
- a seasonal dose has no series to be part of, so nothing reads as a
  booster on a length it never had
- interval adjudication is deliberately absent; this is arithmetic on
  what a person wrote down
2026-08-08 14:00:39 +02:00
Marc-André Bombeck e190c32c77 feat(vaccinations): seed the antigen-level vaccine catalogue
- 35 entries across the childhood, standard, later-decade, indication
  and travel sets, so a Pass covering a whole life has somewhere to land
- ATC-coded from the WHO J07 subtree, component-aware so one combined
  dose counts into every antigen series it contains, and each entry
  cites the document its numbers come from
- no manufacturer column and no trade name in any shipped string; the
  one place a trade name belongs is the person's own transcription
- the resolver is the only lookup path and answers null rather than
  throwing, so a slug a later release drops still renders as what the
  person wrote
2026-08-08 13:58:17 +02:00