Commit Graph
453 Commits
Author SHA1 Message Date
Marc-André Bombeck 43be8ebd43 fix(jobs): hand the reaction line its new datum in the reader's units
The reaction line read the arrived weight with its stored unit (kg) and
a workout distance in metres, whatever the reader had chosen, so the
sentence written about the new reading could quote kilograms to a
reader on pounds. The evidence now converts the reading and the
distance into the reader's units before the prompt is built.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 383323b542 Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:29:08 +02:00
Marc-André Bombeck b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck 27ac4c1d63 Merge origin/main into fix/v1397-tz 2026-10-01 10:39:26 +02:00
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00
Marc-André Bombeck 1934b97663 test(guard): declare every queue's runtime and hold long ones to it
Every queue bound with createAndWork is now declared in
src/lib/jobs/queue-runtime.ts as short (cannot come near the default
fifteen minutes, with the reason), budgeted (stops itself at three
quarters of the default and resumes) or long (an explicit expiry, a
stop on the job's budget, and a lock or claim per identity; or a
declared one-shot with no retries).

The guard reads every binding, every boss.send and every schedule tuple
in the tree: each names a queue in the table, every entry is bound, a
long queue's sends and schedules all carry its expiry, its handler
stops on jobBudget / jobDeadline where declared and holds lockedPass or
the named marker, and a short or budgeted queue sets no expiry of its
own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and
planted long queues missing each protection in turn are caught. Run
against the tree before the previous commit it reports 59 findings;
dropping lockedPass and the cron expiry from pr-detection reports both.
2026-10-01 09:24:15 +02:00
Marc-André Bombeck ac3fdfccc3 fix(jobs): keep long passes inside their expiry and alone
pg-boss gives a job fifteen minutes unless the send says otherwise,
then fails it and retries it while the handler keeps running. Several
passes can legitimately need longer and had no protection at all: the
seven nightly status passes and the pre-generation (a provider call per
account), the period-narrative warm, the record-detection cron (every
account's history, every thirty minutes), the key rotation (every
encrypted column), the content-index backfill (up to 200
transcriptions) and the on-host backup.

Each of them now carries an explicit expiry on every send and schedule,
stops between units once three quarters of it is spent (jobBudget), and
runs under a session advisory lock keyed on queue and identity
(lockedPass), so the retry pg-boss starts after the expiry does nothing
beside a run that is still going. The lock is the one the full-history
imports already held, moved into job-lock.ts.

Where a pass stops early it says so (stopped_early) and the next run
continues: the status passes and the narrative warm leave the rest to
the next night, the pre-generation and the narratives walk the stalest
accounts first, the on-host backup now backs up the oldest copies
first, the key rotation reports a partial run in its audit row and
skips rotated rows when run again, and the dose-history import fails
the delivery so its retry resumes from the committed chunk. The
off-host backup, which already stopped on its budget, gets a four-hour
expiry and the lock.
2026-10-01 09:23:45 +02:00
Marc-André Bombeck a0c290e140 fix(jobs): let the nightly tombstone purge step around a running restore
A restore clears every row of the account, tombstones included, inside one
long transaction under the account's exclusive restore lock. The nightly
purge deleted expired tombstones across accounts, one statement per batch,
without that lock: on an account under restore it waited on the restore's
row locks for as long as the restore ran, past its statement timeout, or
deadlocked with it once each side held rows the other needed.

Each batch is now deleted one account at a time, in a transaction that
first takes the restore lock in shared mode, the same order the
consolidation passes keep. An account under restore is refused at once,
kept out of the rest of the run, and purged the next night. Still one
delete per account per batch, never one per row. The job reports how many
accounts it deferred.

The integration test holds a stand-in restore on one account while the
purge runs: the purge returns without waiting, leaves that account alone
and purges the other. With the lock call removed it waits out the hold.

Refs #1031
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 029d6d881c fix(jobs): keep a full-history import from running beside its own retry
The shared admission queue sent its jobs without an expiry, so every one
took pg-boss's fifteen minutes. Against pg-boss 12.34, a handler that runs
past its expiry is not stopped: the job is failed, its abort signal fires,
the worker and group slots are freed, and the retry of the same job starts
in the same worker beside the run it replaced. A per-minute heart-rate
history is thousands of pages, so on a slow link one import turned into up
to four copies of itself, on the one queue meant to run a single import at
a time.

- The admission job now carries a sixteen-hour expiry, sized for the
  longest legitimate import and under pg-boss's 24-hour ceiling.
- Each run holds a session advisory lock on its identity (kind, provider,
  account) on a connection of its own. A delivery that finds it taken does
  nothing and says so in the log; the running import stamps its marker or
  is offered again at the next boot. Ending the connection or the process
  releases the lock.
- The Google Health backfill runs under the job's budget and abort signal.
  The page walk asks before each request, stops there, and reports the run
  truncated, so it throws without the completion stamp and is retried.
- The drain moves out of the registrar into its own module so it can be
  driven against a real pg-boss.

The integration test pins the pg-boss behaviour and runs the lane's
handler with a two-second expiry: without the lock the import ran twice.

Refs #1023
2026-09-30 23:11:03 +02:00
Marc-André Bombeck 6f6c4ffff0 test(jobs): re-pin the pg-boss retention assumptions to 12.34.0 2026-09-27 19:51:46 +02:00
Marc-André Bombeck 5e9c4fe8d8 fix(jobs): allow the free-text backfill's address and audit counts as run facts 2026-09-27 13:32:59 +02:00
Marc-André Bombeck 874d02294e fix(backup): let the restore job's budget admit large accounts
A restore is refused before it deletes anything when its transaction limit
would run past three quarters of the job's expiry. At two hours that refused
a same-size replacement of about four million readings. The expiry is now six
hours, which admits ten million over ten million; a dead worker still frees
the account within minutes through the heartbeat, so the longer expiry does
not hold an account longer.

The refusal now names both counts, the readings in the backup and the ones the
account holds now (removed readings included until the nightly clean-up),
since clearing the latter costs time too.
2026-09-27 13:13:50 +02:00
Marc-André Bombeck 722945702a fix(privacy): clear the last readable copies of practitioner contact details
The encryption backfill now also removes the two copies of a practitioner's
contact details that outlived the sealing. An appointment reminder's copy of
the address is cleared, since the reminder resolves it from the practitioner
when it fires. An address-book edit's audit row written before the sealing
kept the old phone number and address in its previous values; those values
leave the row and the changed fields stay named. Rows of a deleted account,
which no per-account job reaches, are scrubbed by the discovery pass itself.

A route whose readable geometry is a JSON null rather than SQL NULL had
nothing to seal but matched discovery every night. The pass now sets it to
SQL NULL so it drops out.
2026-09-27 13:07:17 +02:00
Marc-André Bombeck 3482581ce1 fix(encounters): name the practice address on an appointment nudge again
Sealing the practitioner's address left the appointment arm reading the old
readable column, which is now empty, so a booked visit's push lost the address
on booking, rescheduling and restoring a deleted visit.

The reminder no longer keeps its own copy of the address. The reminder tick
resolves it from the owning visit's practitioner when it builds the push and
decrypts it there, so an edit of the address also reaches a nudge that is
already booked. MeasurementReminder.location stays NULL on appointment rows:
booking writes none, a re-anchor clears one an earlier release wrote, and a
backup neither carries nor restores one. A checkup's own free-text location is
unchanged.
2026-09-27 13:07:10 +02:00
Marc-André Bombeck 02801bdfda Merge branch 'fix/restore-large-account-clearing' into release/v1.39.4 2026-09-27 12:14:04 +02:00
Marc-André Bombeck 93b6c086b4 fix(restore): let a large restore outlast the request statement timeout
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).

Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.

The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
2026-09-27 11:24:41 +02:00
Marc-André Bombeck 5eb7956bad fix(privacy): encrypt workout GPS tracks and practitioner contact details at rest
The GPS track of an outdoor workout was stored readable, and its first and
last points are usually the person's front door. The phone number and address
in the address book of doctors and practices were readable too, while the
note beside them was already encrypted.

Migration 0361 adds workout_routes.geometry_encrypted (the geometry loses
its NOT NULL) and practitioners.phone_encrypted / location_encrypted, plus
partial indexes on the backfill's discovery predicates. A track is sealed as
one binary AES-256-GCM value bound to its purpose; the contact fields use the
same Bytes codec as the note. The practitioner's name, practice and specialty
stay readable, because the picker searches and sorts on them.

Every writer stores ciphertext only: workout batch ingest, practitioner
create and edit, and both restore formats. Readers prefer the ciphertext and
fall back to the old column for rows not yet migrated: the workout detail
(map, splits, GPX), the activity insight's climb figure, the practitioner
DTO behind the address book, visits and vaccinations. The wire shape does not
change. An edit's audit row now names a changed phone number or address
without recording either value.

free-text-encryption-backfill covers the three columns in the same pass,
finding a route through its workout. All three are registered for key
rotation (registry, CLI script, and the in-app rotation). A disaster-recovery
backup carries the contact fields as ciphertext only; a portable export
carries them readable. Workout routes are still not part of a backup, as
before. The old columns are marked for removal in a later release.
2026-09-27 09:59:34 +02:00
Marc-André Bombeck 72a34b3b47 Merge branch 'fix/v1393-review-data' into release/v1.39.3 2026-09-27 00:18:47 +02:00
Marc-André Bombeck f4b9d8a0e1 fix(backup): list the purge job's awaiting-run count among its outcome facts 2026-09-26 22:35:49 +02:00
Marc-André Bombeck 382c44ee88 fix(import): keep a queued Apple Health upload when sweeping staged files
The staging sweep removed any staged upload older than seven hours. An
import can wait in the queue behind another account's import for longer
than that, and when its turn came the file was gone and the import failed.
The sweep now keeps every upload a queued or running import still names,
and the extracted XML while an import is past the queue. When the queue
cannot be read, it removes nothing that tick.
2026-09-26 22:21:46 +02:00
Marc-André Bombeck b6b51fcb5e fix(medications): let a snooze set before a dose's window hold that dose
Since a snooze holds only the slots whose window had opened when it was
set, a snooze set ahead of a dose, such as "remind me later" in the app
before the dose is due, held nothing and the reminder came anyway. A
snooze set while no window of the medication is open now holds the next
slot to open. One set while a window is open still holds only that slot,
so a morning skip that snoozes to the end of the day leaves the evening
dose reminded.
2026-09-26 22:17:14 +02:00
Marc-André Bombeck 612eedcbbb fix(medications): warn when an as-needed medication runs low
Since the supply runway stopped counting an as-needed schedule as a daily
dose, a medication taken only as needed had no runway and never raised a
low-supply notice. It now warns once when two doses or fewer are left,
with its own line naming what is left, and re-arms after a refill like any
other medication.
2026-09-26 22:14:03 +02:00
Marc-André Bombeck bb12d942c0 fix(backup): keep a wipe's purge request until the next off-host run
A data wipe during the nightly upload could leave the pre-wipe copy in
the bucket: the purge the wipe started removed its request at once, and
the upload still in flight checked that request after finishing and found
nothing. A wipe's request now stays until a nightly run has walked the
account since the wipe. Until then each purge removes only the copies
dated up to the wipe's day, so the copy of what the account holds after
the wipe stays, and the admin card no longer counts the waiting request
as copies still in the bucket.
2026-09-26 22:08:51 +02:00
Marc-André Bombeck e9cf3c1b15 fix: carry the cross-branch rules through offhost keys, the Telegram test mock and the OIDC reason list 2026-09-26 22:07:11 +02:00
Marc-André Bombeck 66b1aef40c Merge branch 'fix/encrypt-free-text-and-local-ai' into release/v1.39.3
# Conflicts:
#	src/app/api/import/csv/route.ts
2026-09-26 21:50:05 +02:00
Marc-André Bombeck 4409529c89 Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/jobs/apple-health-import-worker.ts
2026-09-26 21:48:20 +02:00
Marc-André Bombeck 7da778760e fix(backup): leave the admin wipe's off-host copies in place, and correct the backup and rotation runbooks
The admin wipe of every account is the operator clearing their own host,
and the bucket's copies are the documented way back from a wipe pressed
by mistake, so it writes no off-host deletion request. Deleting an
account, deleting a managed profile and "Delete all data" still do.

The backup runbook no longer says the worker never deletes an object: it
lists the grant the worker needs (DeleteObject included), when objects
are deleted, the version-4 envelope, the lifecycle check and how to
rotate the off-host key. The rotation runbook no longer says backups are
covered: rotation reaches a backup's envelope, not the content a restore
writes back, and a retired key stays needed until no backup lists it.
The privacy notice beside account deletion says the same about off-host
copies.

A failed import records its failure in a plain try block, and the
extraction test that inflates past the 64 MiB floor gets the time it
needs on a loaded runner.
2026-09-26 20:33:30 +02:00
Marc-André Bombeck 849748c4cd fix(backup): bind new off-host objects to their key and account, and cap what an uploaded backup may inflate to
Off-host objects are now written as envelope version 4: the header names
the off-host key by fingerprint, and the object's key in the bucket
(<date>/user-<id>.json.enc) is authenticated as GCM associated data, so
a copy moved to another account or another night no longer opens.
Objects of versions 1 to 3 still read. BACKUP_ENCRYPTION_PREVIOUS_KEYS
(comma-separated, on the compose whitelist and in the env manifest)
keeps retired off-host keys readable, which is the rotation path the
off-host key did not have.

The admin backup upload counted the bytes that arrived and none after
the gunzip, so a small gzip could expand without limit into the JSON
scanner and take the app down. The inflated bytes are now held to 100
times the compressed bytes read (at least 256 MB, at most 4 GB) and a
file past that is refused with 413 before anything is stored.
2026-09-26 20:18:55 +02:00
Marc-André Bombeck 1b4594e69f fix(import): bound what an export may inflate to, allow one import per account, and never leave the upload behind
The extractor held the inflated export.xml to a flat 8 GiB whatever the
member occupied, and the header ratio check reads sizes the archive
writes itself: a member declaring an honest ratio could inflate at
1000:1, so an 8 MB upload could fill the temp directory. The streamed
byte count is now held to 200 times the member's compressed size on
disk (never less than 64 MiB, never more than 8 GiB), and a stored
member may not grow at all.

An account may have one Apple Health import queued or running. A
second upload with different bytes is refused with 409
import.apple_health.busy and the running job's id; the check and the
new row are one transaction under an advisory lock.

The staged upload is the person's export in plain text. It is now
removed on every exit that leaves no worker to read it: a refused or
broken multipart body, no worker running, a queue that takes no job
(the row is failed as well), the admin route's missing or unknown
target account, and an account deleted before the worker reached it.
The worker removes the files before recording a failure, so a row that
went with a deleted account no longer strands them. The 15-minute
import reconcile pass also sweeps staged files older than the longest
an import may run.
2026-09-26 20:11:45 +02:00
Marc-André Bombeck 1c7785c23b fix(backup): take off-host copies out with the account, and say which keys backups still need
Deleting an account, deleting a managed profile, "Delete all data" and the
admin wipe now write an off-host deletion request in the same transaction
as the deletion. A new job (offhost-backup-purge, kicked straight away and
run nightly as a backstop) lists the bucket once, deletes every
<date>/user-<id>.json.enc object of each pending account and removes the
request only when nothing of the account is left. A refused delete keeps
the request with the bucket's answer and is retried; the off-host card
shows how many deletions are outstanding and why. A copy the nightly run
was writing while the account was deleted is taken back after the upload.

The off-host card also reads the bucket's lifecycle rule and says plainly
when there is none (every nightly copy kept forever) or when the
credential may not read it.

Every stored backup now records the key ids its content was written
under, and every off-host upload records the same per key. The admin
encryption view lists, per key, the stored copies that need it with the
oldest date, and when the last off-host copy needing it expires. The
"safe to drop" badge now requires that no backup still needs a retired
key, instead of only that every row was rotated.

Migration 0358 adds data_backups.inner_key_ids, the key-use table and the
deletion requests.
2026-09-26 19:54:40 +02:00
Marc-André Bombeck f7b3ce6ee0 fix(privacy): encrypt Coach conversation titles and custom-metric notes at rest
The title of a Coach conversation is the first 80 characters of the
opening message, and the note on a custom-metric reading is free text;
both were stored readable while every other note was encrypted.

Migration 0357 adds coach_conversations.title_encrypted and
custom_metric_entries.note_encrypted (the title loses its NOT NULL) plus
partial indexes on the backfill's discovery predicate. Every writer now
stores ciphertext only: conversation create, proactive nudge, reminder
surfacing, rename, entry create and edit, and both restore formats.
Readers prefer the ciphertext and fall back to the old column for rows
not yet migrated; a ciphertext that does not open throws instead of
falling back. The history title search decrypts in memory, since SQL can
no longer match the title.

free-text-encryption-backfill is a boot-time, idempotent pg-boss job
(staggered past the startup storm, daily discovery tick) that seals each
remaining readable value and nulls it in one transaction per row. A pass
that sees a row twice stops with an error instead of looping.

Both columns are registered for key rotation (registry, CLI script, and
the in-app rotation that walks the registry). A disaster-recovery backup
carries ciphertext only, sealing a not-yet-migrated row on the way out;
a portable export carries readable text. The old columns are marked for
removal in a later release.
2026-09-26 19:46:01 +02:00
Marc-André Bombeck 405c996b6a fix(backup): refuse a restore that needs a dropped key or reaches into another account
A backup carries every encrypted column as the stored ciphertext, and a
restore writes it back verbatim. Key rotation re-seals the stored copy's
envelope but cannot reach inside it, so a copy taken before a rotation
still needs the retired key. Without it the restore went through and
left rows no reader can open.

The restore, the restore preview, the admin upload and the monthly
restore drill now collect the key ids of every inner ciphertext value,
hold them against ENCRYPTION_KEYS and decrypt one value per key. A
missing key, or an id configured with different key material, is
refused before anything is deleted, with the key id named. The restore
status and the preview say which key to put back.

The restore also checks, inside its transaction, that it added no
reference from the account's rows to rows another account owns. The
check is derived from the schema's foreign keys rather than written per
section, so the tag and category upserts keyed on a global key are
covered along with every link table. The illness parent update is
scoped to the owner as well.

The off-host listing now follows every page: the first page held only
the oldest dates, so the drill could read an old object and report the
chain as stale.
2026-09-26 19:22:03 +02:00
Marc-André Bombeck 314e9c1aa3 refactor(tz): take the medication and reminder trees off the zone literal
The medication and measurement-reminder code now imports DEFAULT_TIMEZONE
from the tz module like every other fallback, so the lint rule no longer
exempts those paths.
2026-09-26 17:03:19 +02:00
Marc-André Bombeck e2814336e5 Merge branch 'fix/aggregation-day-boundaries' into release/v1.39.3
# Conflicts:
#	src/app/api/medications/[id]/route.ts
2026-09-26 17:00:54 +02:00
Marc-André Bombeck 026e8fc1ed Merge branch 'fix/compose-env-and-claims' into release/v1.39.3 2026-09-26 17:00:46 +02:00
Marc-André Bombeck bd8cf5d83f Merge branch 'fix/medication-schedule-timezones' into release/v1.39.3 2026-09-26 17:00:46 +02:00
Marc-André Bombeck 9d2fd1db14 fix(tz): one default zone, and the user's own calendar where Berlin was hard-coded
The status assessments folded their recent days, weeks, months and years on
Berlin days whatever the user's zone, the mood and cycle crosstabs keyed
their metric days in Berlin, the resting-pulse proxy defaulted to Berlin
days, the briefing's opener rotated at Berlin midnight, and every
assessment prompt labelled today's date as Berlin's. They now use the
user's zone. Rollup months and years are labelled by their UTC start,
which is how they are cut; read in a zone west of UTC the label named the
month before.

"Europe/Berlin" was written out in some eighty places. DEFAULT_TIMEZONE in
lib/tz/format is now the one definition: the second copy in mood/date-key,
DISPLAY_TIMEZONE, the Berlin-only day key helpers and the per-route copies
are gone. An in-repo lint rule rejects the literal outside lib/tz and
tests; four areas another change owns are listed as pending.
2026-09-26 16:23:14 +02:00
Marc-André Bombeck c88754a0b5 fix(measurements): re-warm status assessments from every measurement write
The MCP writes, the Telegram reply, the CSV and backup importers and the
Apple export import refreshed the rollups by hand and never re-warmed the
cached status assessments, so a reading logged there kept the day's old
assessment until the nightly pass. They now run the shared post-write tail,
and the export import re-warms every type it wrote. The WHOOP body sync
removed its legacy weight row without refolding that day; it does now.

A structural test lists every file that writes measurements and how it keeps
both readers current, so a new write site fails until it says.
2026-09-26 16:07:51 +02:00
Marc-André Bombeck 62cef974bf fix(medications): scope a snooze to its slot and key report adherence by id
A snooze lives on the medication row, and the reminder tick applied it to
every slot of that medication. A Telegram "skip" of the morning dose
snoozes to the end of the day, so the evening dose of the same medication
was neither reminded nor marked missed by the reminder path. A snooze now
holds only slots whose reminder window had opened when it was set, dated by
the row's updatedAt that the snooze write stamps (the same reading the skip
and pin suppression already uses). A later edit to the row can only widen
the hold back to the old behaviour.

The doctor report kept adherence in a map keyed by medication name, so two
medications with the same name (an ended course and a new one, two
strengths) collapsed into one row and the second overwrote the first. The
map is keyed by medication id now and carries the name as the label; the
report's medication list carries the id. The PDF, the FHIR adherence
observations, the MCP visit summary, the clinician view and the GLP-1
section read it that way.
2026-09-26 15:38:20 +02:00
Marc-André Bombeck 91571d4674 fix(medications): count the whole cadence in the supply runway
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.

Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
2026-09-26 15:32:29 +02:00
Marc-André Bombeck 888b738546 fix(reminders): send a multi-hour protocol at every hour it names
A reminder with FREQ=DAILY;BYHOUR=7,19 (the morning-and-evening blood
pressure course) was only ever sent in the morning. The tick opened its
send window at notifyHour alone, so the 19:00 slot stayed due until 07:00
the next day, and the claim was keyed per local day, so a second send that
day would have been refused anyway.

The window is now the hour of the current slot: the local hour of
nextDueAt when it is one of the rule's BYHOUR values, else notifyHour. The
early skip for a reminder already sent for its slot uses the same hour, so
it stays consistent with the hold-open rule and lastNotifiedAt. A
multi-hour reminder claims per slot hour; a single-hour reminder keeps the
per-day key.
2026-09-26 15:27:25 +02:00
Marc-André Bombeck 13db7935f6 fix(image): ship the operator scripts the runbooks run in the container
The password-reset runbook tells an operator to run
`docker compose exec app node scripts/reset-password.mjs`, and the
intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts`
inside the container. The runner stage copied nothing from scripts/, so
both failed with "Cannot find module", the reset at the moment an admin
is locked out.

The runner stage now copies both scripts and the Argon2 parameter module
the reset imports, and proves at build time that each reaches its own
first check with every import resolved. A structural test holds every
in-container command in docs/ and the Dockerfile copies in step.

Key rotation is documented the way it works on the published image: the
Rotate now button under Admin > Encryption is the primary path. The
rotation script imports the application graph and runs from a source
checkout of the same release; the runbook, the admin hint in all seven
languages and the decrypt error now say so.
2026-09-26 15:26:03 +02:00
Marc-André Bombeck 3c3e5ceab7 fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
2026-09-26 15:20:42 +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 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 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 e5214265f6 Merge branch 'feat/document-import-token' into release/v1.39.2
# Conflicts:
#	docs/api/openapi.yaml
#	docs/integrations/document-import.md
2026-09-26 08:55:30 +02:00
Marc-André Bombeck 39b56ee4e2 Merge branch 'fix/backup-chunked-storage' into release/v1.39.2 2026-09-26 01:49:22 +02:00
Marc-André Bombeck 8ad4610370 fix(backup): make rotation, concurrent writes and old rows safe for pieces
Key rotation re-sealed a single-value backup in place, which holds the
stored string, its bytes, the plaintext and the new ciphertext at once; a
copy can be over a hundred megabytes and the in-app rotation walked 25 of
them per page, enough to run a 1 GB container out of memory. Rotation now
converts each such copy into pieces under the active key instead, one row
at a time. The ~hlgcm1. stream of v1.38.6 to v1.39.1 is read a megabyte
at a time with its tag checked at the end, inside one transaction, so a
copy that fails the check is left untouched; the copy keeps its date. The
scan reads only the first characters of each value. Measured on a copy of
a 2.6 million-reading account (126 MB stored): converted in 25 s with the
heap flat at 57 MB under a 200 MB limit, where re-sealing in place ran out
of heap.

The weekly and manual backup leave an account alone while a restore of
its current copy is queued or running, and count it as skipped, not
failed. A reader whose copy is replaced mid-read answers 409
backup_changed, "replaced by a newer one while it was being read", rather
than the undecryptable error; a restore rolls back with the same code.

Pieces are sealed with a fixed associated-data label, so a value the same
key sealed for another purpose never opens as a piece; rotation re-seals
under the same label. A row holding both a single value and pieces, which
only an older release writing after a downgrade produces, is refused with
a message saying so, and the next weekly run replaces it. Rotation treats a
row deleted between its read and its write as gone rather than an error.
2026-09-26 01:49:02 +02:00