Commit Graph
6870 Commits
Author SHA1 Message Date
Marc-André Bombeck a8cf4c1dad Merge branch 'feat/document-picker' into release/v1.39.3 2026-09-26 17:00:45 +02:00
Marc-André Bombeck 22339aa38c Merge branch 'feat/mcp-search-records' into release/v1.39.3 2026-09-26 17:00:45 +02:00
Marc-André Bombeck 58275aee1a Merge branch 'feat/fhir-condition-body-site' into release/v1.39.3 2026-09-26 17:00:45 +02:00
Marc-André Bombeck 656542b040 fix(documents): date Papra search rows by their document date only
Papra's date: filter matches the document date, so a row now shows that
date or none, and an undated document falls out of a date range in the
list as it does on the server. Import still files an undated document
under the day it was added.
2026-09-26 15:15:32 +02:00
Marc-André Bombeck bb5d0ab933 docs(documents): Papra organization ids and tags permission, deletion by content, the Paperless SQLite case limit 2026-09-26 14:47:40 +02:00
Marc-André Bombeck d69e12bac2 fix(scripts): skip documents the source already says are too large once HealthLog has named its limit, and show small sizes in KB 2026-09-26 14:47:12 +02:00
Marc-André Bombeck 057281998f fix(documents): read Papra as it answers
Papra organization ids are checked against org_ plus 24 characters
before anything is sent. A 403 user.not_in_organization and a 400
server.invalid_request.params read as no such organization instead of a
refused key; the error body is read for its code and never passed on.
The connection test also reads the tags, so a key without tags:read
fails at save (permissionMissing) instead of on the first filter. Name
searches quote each word, so a leading dash is not a negation, and the
date range goes to Papra as date:>= / date:<= with the per-row check
kept as a net.
2026-09-26 14:46:01 +02:00
Marc-André Bombeck c5a63934bd fix(documents): an import of bytes deleted in HealthLog stays deleted under any source key
Keyed uploads and picker imports also check deleted, not yet purged
documents by content and answer deleted, remembering the new key on the
tombstone so the purge carries it into the ledger. A hand upload without
a key still stores a new copy. No content hash of a deleted document is
kept past the purge.
2026-09-26 14:42:13 +02:00
Marc-André Bombeck 933553a7bd docs(documents): source instance in the workflow recipe and the key rules; token and removal rules for the picker 2026-09-26 14:33:23 +02:00
Marc-André Bombeck 6534c3f9e8 fix(documents): picker and settings card follow-ups
The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
2026-09-26 14:32:46 +02:00
Marc-André Bombeck 6a751bc51d docs(api): publish sourceInstance on the upload and lookup routes, and the picker's review changes 2026-09-26 14:28:46 +02:00
Marc-André Bombeck 1eac05b454 fix(documents): key imports by source instance, keep a token on its origin, and harden the import path
- Source keys carry the normalised origin of the Paperless-ngx or Papra
  they came from (source_instance on the document, the alias and the
  purge ledger; unique NULLS NOT DISTINCT). A key stored without one
  (v1.39.2 imports) matches any instance of its system, and a lookup
  without one matches any stored instance. The upload route, the lookup
  route and the import script accept or send it; backups carry it.
- Saving a connection reuses the stored token only for the same origin;
  a new origin needs the token again (422 tokenRequired).
- Document ids are checked against what each system issues (Paperless
  digits, Papra [A-Za-z0-9_-], never . or ..) before any URL is built;
  the Papra organization id likewise.
- Paperless API 9 filters dates on created__gte / created__lte, in the
  picker and the script. A search page the local re-check empties reads
  on, up to five source pages. Tags are read past the first 250.
- The import's key pre-check uses the lookup allowance, a failed fetch
  no longer charges the upload allowance, and one import per key runs at
  a time under an advisory lock, so a double click downloads once.
- Reading and deleting one's own connection works with the documents
  module off and the operator list unset.
2026-09-26 14:20:17 +02:00
Marc-André Bombeck 0a58edc87a fix(fhir): code the mean glucose only when it is a continuous-stream mean
97507-8 is the mean of interstitial glucose in mass/volume. A spot-reading
mean is neither, so it now goes out text-only in the account's unit. A
continuous-stream mean keeps the code and is sent in mg/dL, the unit it is
computed in, whatever unit the account displays.
2026-09-26 13:55:41 +02:00
Marc-André Bombeck 6029c7c30d fix(documents): keep a picker row at full contrast once it shows its import result; two date fields per row on phones 2026-09-26 13:45:15 +02:00
Marc-André Bombeck 0ac5377cc2 fix(fhir): correct the LOINC and SNOMED CT terms the export sends
A run of the HL7 validator against every code the export can emit found
terms that do not exist, are inactive, mean something else, or carry a
display the code system does not publish.

- Body water and bone mass use 101683-1 and 101685-6; the old codes are a
  nasogastric fluid output and a calculated body-fat mass.
- Step length, walking speed, walking asymmetry and VO2 max ride their
  HealthKit identifiers: LOINC has no fitting term, and two of the old
  codes do not exist.
- Mood, estimated A1C, average cycle length and average period length are
  text-only. The old codes did not exist or were questionnaire items.
- LDL, vitamin D and eGFR use the method-less and total terms (2089-1,
  62292-8, 69405-9) a recorded value can honestly claim.
- Resting heart rate and pulse-oximetry SpO2 also carry the heart-rate and
  oxygen-saturation codes the R4 vital-signs profiles require.
- Blood pressure, adherence, glucose-panel and injection-site codings carry
  the published displays; ATC codings carry none, since the user's
  medication name is not the ATC name. The name stays on the text.
- Mood is a survey finding, not a vital sign, so it no longer rides the
  vital-signs panel.
2026-09-26 13:44:10 +02:00
Marc-André Bombeck 44abc6f1a0 fix(fhir): code allergy and injury conditions with active SNOMED CT concepts
Allergy used 106190000, inactive since 2013; it is now 473011001 Allergic
condition. Injury keeps 417163006 with the display SNOMED CT publishes,
Traumatic or non-traumatic injury; the old display was rejected.
2026-09-26 13:44:09 +02:00
Marc-André Bombeck 1e54f291a7 docs(mcp): notes, held-back documents and module switches across every read 2026-09-26 13:39:11 +02:00
Marc-André Bombeck 9e38ee3a99 fix(documents): report picker and card results through the shared outcome line, and register the disconnect as a destructive control 2026-09-26 13:32:52 +02:00
Marc-André Bombeck 35327b84c1 fix(fhir): leave out the vital-signs DiagnosticReport when there are no vital signs
A window without a vital sign emitted the panel with result: [], which R4
rejects. The DiagnosticReport now goes out only when it has a result, and
the vital-signs section drops an empty entry list rather than sending one.
2026-09-26 13:31:38 +02:00
Marc-André Bombeck 87526e3df6 fix(fhir): code the body-site side with active SNOMED CT qualifiers
The HL7 validator reports 419161000 and 419465000, the left and right
concepts of the R4 bodysite-laterality value set, as inactive. Use the
active qualifier values 7771000 Left and 24028007 Right, with 51440002
Right and left. The side word in bodySite.text stays English, the language
of every other generated text in the Bundle; the builder takes no locale.
2026-09-26 13:29:45 +02:00
Marc-André Bombeck 9b8c5ab44c test(mcp): notes and held-back document text stay off the wire against real Postgres 2026-09-26 13:29:36 +02:00
Marc-André Bombeck 836f72605f fix(mcp): keep notes and held-back document text off the wire, gate every read
Follow-ups to the clinical-record search, test first:

- Condition and vaccination notes are no longer searched or returned.
  Both reads use an explicit column list without the note, as the Coach
  illness snapshot and the doctor report already do. A structural guard
  fails when any MCP read of either model names noteEncrypted or has no
  explicit select.
- A document uploaded with AI reading deferred is not searched by its
  text and returns no excerpt (metadata.reason "ai_read_deferred"); its
  title, kind and links stay findable.
- Documents older than the newest 500 are found by title and file name
  through a bounded SQL lookup on those plaintext columns.
- Query words shorter than four letters match whole words or word starts
  only, so "arm" no longer finds "Darm".
- A deleted practitioner is no longer named or matched, in search, fetch,
  get_visits and the appointments in get_preventive_care. The empty-query
  listing shows visits that happened, not booked ones.
- get_labs, get_medication_schedule, get_medication_compliance and the
  lab and medication resources (read, list, completion) answer
  { present: false, reason: "module_disabled" } when their module is off,
  without reading. get_visits withholds condition labels when illness is
  off and says so in conditionsReason.
- The consent screen and the tool descriptions name what a connection
  can read, the indexed text of stored documents included.
2026-09-26 13:26:55 +02:00
Marc-André Bombeck d109d67889 feat(documents): pick documents from Paperless-ngx or Papra in the vault and wherever documents are linked
A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
2026-09-26 13:26:18 +02:00
Marc-André Bombeck 92f2cc729f fix(documents): name the document-AI capability in each route that can queue it, and catalogue the picker's error codes
The ingest path takes the capability from its caller, so the upload and
the import route each read getAiCapability("documentAi") in their own
source, where the AI route inventory classifies them.
2026-09-26 13:18:48 +02:00
Marc-André Bombeck caf3eb323d docs(api): publish the document picker routes as cookie-only, and freeze them as owner-only 2026-09-26 13:13:51 +02:00
Marc-André Bombeck c840d1012a test(documents): exercise the picker's routes against the database with the source faked at the fetch layer 2026-09-26 13:07:44 +02:00
Marc-André Bombeck cab67b4c06 test(documents): pin the picker's origin allowlist and both source adapters 2026-09-26 13:03:45 +02:00
Marc-André Bombeck fdc60f69c2 feat(documents): search and import from Paperless-ngx and Papra on request
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.

The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
2026-09-26 13:02:36 +02:00
Marc-André Bombeck 639a30e133 docs(mcp): describe what search and fetch cover for clinical records 2026-09-26 13:02:05 +02:00
Marc-André Bombeck 1dbab4a912 test(mcp): search and fetch over clinical records against real Postgres 2026-09-26 12:59:31 +02:00
Marc-André Bombeck 5d80b194ac feat(mcp): search and fetch cover visits, conditions, documents and vaccinations
In its default mode ChatGPT can call only search and fetch, and until now
those two covered metric domains, medications and lab analytes. A question
about a knee operation or a discharge letter had nothing to land on.

search now also returns visit:<id> (procedures included), condition:<id>,
document:<id> and vaccination:<id>. Matching runs after the decrypt over
the account's own rows, bounded per kind, folded the way the body-sites
view folds sites, with German/English body-part and vault synonyms.
Documents match on title, file name, kind and whole words of the blind
content index; the body is never read for a search. Results rank by
matched query words, then score, kind, date and id, so the offset cursor
stays stable. Record results are capped at 50.

fetch returns each record's key fields and its one-hop links through the
link service, every link carrying an id fetch accepts. Free text is fenced;
short labels are scrubbed. A document returns metadata and at most a
1500-character excerpt of its indexed text, never the file or its stored
summary.

A switched-off module's records are neither read nor returned: illness,
inboundDocuments and vaccinations for the new kinds, and now medications,
labs and the owning module of a discovery signal for the older ones.
2026-09-26 12:55:28 +02:00
Marc-André Bombeck 89b18e001e refactor(documents): move the upload's store path into one ingest function
The upload route keeps authentication, its rate buckets, multipart parsing
and the response shape; the source-key answer, size and type checks,
pre-link narrowing, content-hash dedup with alias memory, the quota-gated
insert, the audit row and the background jobs move to
src/lib/documents/ingest.ts unchanged, so a second way in can store
through the same rules.
2026-09-26 12:53:32 +02:00
Marc-André Bombeck 8934a2900e feat(fhir): export the body site and side of a condition
Condition.bodySite carries the free-text site as text. The side rides a
contained BodyStructure reached through the core bodySite extension, coded
with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is
repeated in the text so a receiver that ignores extensions still reads it.
2026-09-26 12:45:15 +02:00
Marc-André Bombeck 4af6b87a2a Merge pull request #1039 from MBombeck/release/v1.39.2
Release v1.39.2
v1.39.2
2026-09-26 11:57:44 +02:00
Marc-André Bombeck cec7fd2b9c test(e2e): tamper with a piece of the stored backup, now that copies are kept in pieces 2026-09-26 11:40:50 +02:00
Marc-André Bombeck bd75c851f1 chore(release): v1.39.2 — document import, body sites, check-ups that stay due, and backups that fit in 1 GB 2026-09-26 11:25:03 +02:00
Marc-André Bombeck 226baa8636 Merge branch 'fix/checkup-stays-due' into release/v1.39.2 2026-09-26 10:48:00 +02:00
Marc-André Bombeck f3df0023b2 fix(migrations): tighten and widen the 0355 repair where the evidence allows
A snooze that had already run out before the send is history, not a
choice about the reminded slot, so it no longer keeps a row from being
repaired. The "no due date" arm matched nothing, because a reminder
cannot be created without a cadence; it now covers a rule whose last
occurrence was the one sent, which is open unless it was done or
skipped since, while a course whose window closed before the send stays
closed through the end-date guard. The start-page step reads a stored
item list only when it is a list, so a malformed layout can no longer
fail the migration.
2026-09-26 10:41:02 +02:00
Marc-André Bombeck 7aefde67e2 fix(reminders): an edit that does not change the cycle keeps the open slot
Renaming an overdue booster check-up on the web still moved it to next
year. The edit sheet sends the first due date as the browser's local
midnight on every save, a booster row stores the dose instant plus N
days, and the route read the different instant as a new first due date.

The route now compares the first due date by calendar day in the
profile timezone and drops a resent same-day value from the write. The
edit sheet sends only the fields that changed. Re-enabling a reminder
that stays due after it is sent keeps the slot it was on instead of
scheduling from now; a short-cycle reminder is still scheduled from
now. Moving the notify hour later on the day a reminder was already
sent carries the last-sent cursor along, so the slot does not go out a
second time at the new hour.
2026-09-26 10:38:47 +02:00
Marc-André Bombeck 31e4c95487 fix(reminders): one cycle rule for the tick and the start page
The start page read every reminder due before the end of the day, so a
daily weigh-in at 20:00 or a twice-daily blood-pressure course showed
"check-up due" from the morning on and took a pinned place on the rail.

`holdsOpenAfterReminder` is now the one definition of a reminder that
stays due after it is sent: an interval longer than seven days, a rule
whose next gap is longer than seven days, a rule with no occurrence
left, or no cadence; never an appointment. The tick uses it to decide
whether a delivered reminder rolls on, and the start page uses it to
show such a reminder for its whole due day and keep it under the cap.
A short-cycle reminder shows only once its time has come and is not
pinned.

The tick also skips a reminder already sent for its current slot
outside its notify hour before the auto-resolve read. The eventful
satisfy worker still resolves it the moment a reading or lab result
lands, and the poll still runs for it once a day, in the notify hour;
the weekly hold stays after that poll so the safety net is daily.
2026-09-26 10:36:15 +02:00
Marc-André Bombeck fb960fb72e fix(reminders): decide what stays due by the cycle, not the measurement type
The first cut kept only free-text check-ups due after their reminder
and let every reminder with a measurement type roll on. A fortnightly
PHQ-9 or GAD-7 reminder is a measurement reminder, and each one still
jumped two weeks forward the morning it was sent without the
questionnaire being filled.

The rule is now the cycle length. A reminder whose next slot is more
than seven days away, or that has no cadence, stays due after its
reminder until it is satisfied (a matching reading, a lab result or
"done"), skipped or snoozed, and is sent again at most once a week. A
reminder on a weekly or shorter cycle rolls on as before. Appointments
stay one-shot. A reading that lands on an open measurement reminder
still sets lastSatisfiedAt and schedules the next cycle from itself.

The 0355 repair drops its "no measurement type" condition for the same
cycle rule, so the fortnightly questionnaires in the reported shape
are put back on the day they were reminded.
2026-09-26 10:33:13 +02:00
Marc-André Bombeck b2bfeb57c8 Merge branch 'fix/checkup-stays-due' into release/v1.39.2 2026-09-26 10:21:57 +02:00
Marc-André Bombeck 4d4a527c0d test(reminders): pin the snooze guard of the 0355 repair on its own
The snoozed fixture sat inside a week of the send, so the one-week clause already excluded it and dropping the snooze guard from the migration left the suite green. Snooze it further out, a minute after the send, so only the snooze cursor tells it apart from a roll-on.
2026-09-26 10:21:22 +02:00
Marc-André Bombeck 8b78e5486d docs(daily): note that a new rail kind needs its stored lists extended
The start-page item choice stores the enabled kinds, so a list saved before a kind existed reads as that kind switched off. Say so next to the kind list, where the next kind will be added.
2026-09-26 10:05:55 +02:00
Marc-André Bombeck cd79cfaae9 fix(reminders): keep a check-up's due date when only its details are edited
Every edit of a reminder recomputed its due date from now, so fixing
the label or the location of an open, overdue check-up moved it to its
next slot, the same "gone until next year" the reminder tick caused.
The due date is now recomputed only when the edit changes when the
reminder recurs: the interval, the rule, the first due date (compared
by value, since the edit forms resend every field), or switching a
disabled reminder back on. A notify-hour edit keeps the due day and
moves the hour on it, together with a snooze pinned to that slot.
2026-09-26 10:05:54 +02:00
Marc-André Bombeck 93881c1f93 fix(daily): keep due check-ups and today's visits on the start page
The start page's short list left out things a reminder had just told
the person about. A check-up counted only once its due hour had
passed, a booked visit was read one at a time and dropped out at its
start time, which is when its reminder fires, and both sat below doses,
sync problems and the Coach check-in under a three-item cap, so a busy
morning pushed them off.

The check-up item now covers every check-up due today or overdue and
names up to three of them. Visits are read for the whole local day,
also once started, and for the next 48 hours: everything booked today
shares one item, the next day with a visit gets a second, and the
today / tomorrow / day-after wording follows the calendar rather than
an hour count. A due check-up and today's visits always keep a place
under the cap; the displayed order is unchanged. A Coach cadence label
is translated here as it is on the checkups page.

The digest's wire shape is unchanged: visits reach it only as
`upcoming_visit` items, which may now appear twice.
2026-09-26 10:01:32 +02:00
Marc-André Bombeck 54b46f4679 fix(reminders): keep an open check-up due after its reminder
The reminder tick rolled every delivered reminder on to its next slot.
For a check-up (a reminder with no measurement type, made by the person
or suggested by the Coach) that filed an open task under next month or
next year the moment it was reminded, although nobody had done it. The
start page and the check-up card then showed a yearly check-up due
today as scheduled for next year, and a check-up with no cadence
dropped off every surface.

A check-up now keeps its due date after the reminder until it is
marked done, skipped or snoozed, and is reminded again at most once a
week while it stays open. A new `last_notified_at` column records the
last delivered reminder; a repeat is held while that send was for the
current slot and fewer than seven calendar days have passed, so a
snooze, a skip or a completion opens a fresh slot that fires on its own
day. The per-day claim stays the guard against two sends in one day.
Measurement reminders keep rolling on (the next reading satisfies them
and their next slot comes round soon), as do check-ups on a weekly or
shorter cadence, whose next slot is the next reminder anyway.
Appointment reminders stay one-shot.

Migration 0355 adds the column and puts back check-ups the old tick
rolled on: a live, enabled check-up whose most recent reminder claim is
followed within ten minutes by its last write, carrying a due date more
than a week past that send (or none, for a one-shot), not done, skipped
or snoozed since. Its due date returns to the instant of that send.
Anything else is left as it is. The same migration adds `upcoming_visit`
to start-page item choices stored before visits were one of them, which
otherwise read as visits switched off.
2026-09-26 10:01:27 +02:00
Marc-André Bombeck f5bab3258a fix(checkups): say "overdue since yesterday" instead of "overdue by 1 days"
The due phrase on the check-up card and the checkups page counted a
check-up one day past its date as "Overdue by 1 days", and the same
counted form read wrongly in German. One day over now has its own
phrase in all seven languages. Both overdue phrases keep the warning
colour through a shared `isOverdueDue` helper, so a surface no longer
compares against a single key.
2026-09-26 10:01:04 +02:00
Marc-André Bombeck 69d501fbb8 docs(integrations): keep the import tokens out of the crontab line 2026-09-26 09:39:30 +02:00
Marc-André Bombeck 5fb5145d7a docs(integrations): the importer uploads when its lookups are spent 2026-09-26 09:25:57 +02:00