mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
344 files changed
+24574
-4211
No files matched your search
+216
@@ -1,5 +1,221 @@
|
||||
# Changelog
|
||||
|
||||
## [1.39.1] — 2026-09-25
|
||||
|
||||
Medication history stops counting misses from before a medication existed,
|
||||
and large accounts can run their nightly jobs, export and restore again. A
|
||||
restore now runs in the background with progress. New: a procedure
|
||||
history, medications kept as a record without tracking intake, and
|
||||
readings from home-automation bridges marked as coming from an external
|
||||
device.
|
||||
|
||||
### Added
|
||||
|
||||
- **Keep a medication as a record.** Every medication has a new Track
|
||||
intake switch at the top of its Schedule tab and on the last page of the
|
||||
medication form. With it off, the medication keeps its dose, start and
|
||||
end dates and schedule, and you can still read and change them, but
|
||||
nothing is due: no reminder on any channel, no dose on the doses card or
|
||||
in the today list, no Taken and Skip buttons. It is left out of
|
||||
adherence on the medication card, the dashboard, the doctor report and
|
||||
in the Coach, and the list marks it "Record only". Doses you logged
|
||||
before stay in its history. Switched back on, it is tracked from that
|
||||
moment and the days it was off never count as missed. Every medication
|
||||
starts with tracking on. Thanks to @Cnote43 for #1033.
|
||||
- **A history of procedures and surgeries.** A visit can be of the new
|
||||
kind "Procedure or surgery", with a body site in your own words and an
|
||||
optional side (left, right or both). The body site is stored encrypted.
|
||||
Checkups has a third tab, Procedures, listing every procedure newest
|
||||
first, with a search by body site that also looks at the reason, so an
|
||||
operation recorded earlier as a general visit still turns up. An
|
||||
existing visit can be switched to the new kind without typing anything
|
||||
again. The doctor report has a new Surgical history section covering
|
||||
your whole history; the standard selection includes it, while a
|
||||
selection you saved earlier keeps what you chose. A share link shows it
|
||||
when the link includes it. Thanks to @Cnote43 for #1025.
|
||||
- **Sleep stages can be sent by name.** A bridge such as Tasker or Home
|
||||
Assistant can post one stage to `POST /api/measurements` with
|
||||
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`,
|
||||
`AWAKE`, `CORE`, `DEEP` or `REM`, and either `startDate` and `endDate`
|
||||
or `measuredAt` (the end) and `value` (minutes). It is stored and
|
||||
deduplicated like the stages the iPhone app sends, and a repeat answers
|
||||
`200` with `status: duplicate`. Sent with the ingest token, the stage is
|
||||
stored with the source `EXTERNAL`. Thanks to @ocurero for #972.
|
||||
- **Long background jobs show up in the app log.** A job that runs for
|
||||
more than a minute writes a line naming its queue when it finishes, and
|
||||
one every two minutes while it runs, with how far it has got and how
|
||||
much memory the app uses. A job past its time limit writes a warning,
|
||||
and after a restart the log names every job the restart cut off.
|
||||
- **Out-of-range upload entries say which limit applied.** An entry in
|
||||
`/api/measurements/batch` skipped as `value_out_of_range` now carries
|
||||
`convertedValue` and `range` (`min`, `max`, `unit`), so a value sent as
|
||||
a fraction where a percentage was expected is easy to spot.
|
||||
- **A rejected measurement upload names its reason.** The validation
|
||||
`422` from `/api/measurements/batch` carries
|
||||
`meta.errorCode: "measurement.batch.invalid"`, so a client can tell the
|
||||
batch will never be accepted as sent.
|
||||
|
||||
- **Units per dose like 1½.** Units per dose used to take a whole number
|
||||
or one of ¼, ⅓, ½, ⅔ and ¾, so one and a half tablets could not be
|
||||
saved. Any value above 0 and up to 100 with up to four decimal places
|
||||
now works, for the medication, for a single dose time and for the
|
||||
packaging settings on the supply tab, which before could not be saved
|
||||
for a medication taken as half a tablet. The form keeps its buttons and
|
||||
adds Other, which reads 1.5, 1,5, 1 1/2 or 1½ and shows what will be
|
||||
saved. The card shows 1½. Thanks to @gesus14 for asking in #1034.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Readings sent with the ingest token are marked as coming from an
|
||||
external device.** They used to be stored as manually entered, so later
|
||||
nobody could tell a reading from the scale from one typed in by hand.
|
||||
They now carry the source `EXTERNAL`, show an "External device" badge
|
||||
and can be picked out with the source filter in the measurements list.
|
||||
You can still correct them. A request that names any `source` at all is
|
||||
refused with `422`; the server decides the source from the token, and
|
||||
the Home Assistant example sends none. Readings pushed before this
|
||||
update stay manual, because nothing tells them apart. On a day with both
|
||||
a typed and a bridged reading, the daily totals, charts, health score
|
||||
and doctor report count the typed one; before, both counted and could
|
||||
inflate a running total. A re-push of entries sent before the update can
|
||||
create duplicates once. Thanks to @Antiheld86 for #892.
|
||||
- **A restore runs in the background.** Restoring a large account takes a
|
||||
few minutes, and a reverse proxy that closes a request after 60 seconds
|
||||
(nginx does by default) showed an error while the restore finished on
|
||||
the server. After you confirm a restore, the backups page shows the
|
||||
current step and how many readings have been written, and the progress
|
||||
survives leaving or reloading the page. The account shows its current
|
||||
data until the restored data is written in one go. Only one restore per
|
||||
account runs at a time. A failure says why and what to do next, and the
|
||||
backup stays on the list so you can start again without uploading the
|
||||
file. A restore interrupted by a server restart before its data is
|
||||
written starts again once by itself; a second interruption is left for
|
||||
you to restart. A failure after the data is written, while charts are
|
||||
rebuilt, is never run again: the data is restored and the charts catch
|
||||
up by the next night.
|
||||
- **A job that runs out of time says more on the admin page.** It names
|
||||
how long the job had and that it was either too slow for that limit or
|
||||
the app restarted while it ran, instead of only "job timed out".
|
||||
- **Backups of several hundred megabytes can be uploaded.** The admin page
|
||||
compresses the file before sending it, `.json.gz` files are accepted as
|
||||
they are, and the limit is 512 MB. The off-host restore script can write
|
||||
a compressed file for this.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **All-time averages and counts no longer count some days twice.** Since
|
||||
1.37.19 the all-time figures combined the stored daily summaries with the
|
||||
raw readings older than five years, and a day that had both was counted
|
||||
in each. Long histories were affected most, and one more day every day,
|
||||
as days aged past the five-year mark. The all-time mean and count on a
|
||||
metric's insights page, the device and resilience tiles, the glucose
|
||||
reading count and the dashboard summaries now count every reading once,
|
||||
so some of these figures change after the update. Nothing needs to be
|
||||
repaired in the stored data.
|
||||
|
||||
- **Medication history before a medication was added.** If you added a
|
||||
medication at 4 pm and then logged the 2 pm dose, "Last 90 days" showed
|
||||
it as off-schedule and due at the evening slot, and listed a morning slot
|
||||
from before the medication existed. The dose now counts for its own slot,
|
||||
and slots from before you added the medication appear only if you logged
|
||||
something for them. The adherence rate, the calendar, the doctor report
|
||||
and the Coach follow the same rule; the doctor report and the Coach
|
||||
used to count those earlier slots as missed. The morning slot no longer
|
||||
turns into a forgotten dose a day later and breaks your miss-free
|
||||
streak, and migration `0350` puts back the ones already marked that way.
|
||||
Thanks to @sreeramachandramurthy for the report and the screenshots
|
||||
(#1028).
|
||||
- **A portable restore keeps when a medication was created.** It used to
|
||||
date every medication to the day of the restore, and missed doses in the
|
||||
restored history dropped out of the adherence rate. Backups exported
|
||||
from 1.39.1 on carry the date. For older ones the restore uses the
|
||||
earliest date the backup records for that medication, such as its first
|
||||
dose or the start of its first schedule, and never a date after the
|
||||
restore.
|
||||
- **The adherence calendar no longer counts a skipped or missed entry
|
||||
that belongs to no scheduled time as a dose taken.**
|
||||
- **A dose attributed to a slot by hand reads the same as one logged
|
||||
there.** A dose taken shortly before the slot's window counted as on
|
||||
time on its own but as late once attributed. The confirmation now says
|
||||
what attributing does.
|
||||
- **Nightly jobs finish on large accounts.** On an account with more than
|
||||
a million readings, for example after a multi-year Apple Health import,
|
||||
the nightly clean-up of heart-rate, HRV, blood-oxygen, step and energy
|
||||
samples and the backup could fail with "job timed out": the clean-up
|
||||
loaded a type's whole history into memory and the backup built its whole
|
||||
copy in memory, and the app ran out of memory and restarted. The
|
||||
clean-up now works one day at a time with about a third of the memory,
|
||||
and when it runs short of time it stops and continues where it left off.
|
||||
The backup saves its copy piece by piece and keeps the previous one if a
|
||||
run fails. On an account of 1.25 million readings in the standard 1 GB
|
||||
container both finish in a few minutes while the app keeps answering.
|
||||
Thanks to @mills1975 for the report (#1031).
|
||||
- **Large accounts can export and restore.** Downloading the full export
|
||||
of such an account, with or without a passphrase, ran the app out of
|
||||
memory, and its weekly backup could not be restored, opened or
|
||||
downloaded; the admin page wrongly said it "could not be decrypted".
|
||||
Exports now stream out as they are written and backups are read in small
|
||||
parts. On the same account the export takes about 25 seconds and a
|
||||
restore about two and a half minutes, and the restored readings match
|
||||
the backup.
|
||||
- **An Apple Health export import needs much less memory.** An import of
|
||||
300,000 readings held 12 MB instead of 53 MB and ran in 35 seconds
|
||||
instead of 55. After an import, chart summaries are rebuilt only for the
|
||||
period the import covered.
|
||||
- **Smaller memory fixes for large accounts.** The half-hourly check for
|
||||
personal records no longer loads a type's whole history. Rotating the
|
||||
encryption key no longer reads every measurement at once, and its report
|
||||
counts only rows that hold encrypted data. The one-time repair of step,
|
||||
energy and distance records can no longer lose them when interrupted.
|
||||
A retried off-host backup continues with the accounts it has not reached
|
||||
yet.
|
||||
- **The cycle calendar shows intercourse and other logged entries.** A day
|
||||
with intercourse carries a small diamond, explained in the legend. A
|
||||
pregnancy, progesterone or ovulation result, contraception, spotting, a
|
||||
temperature, a mucus or cervix reading or a note shows as a small ring,
|
||||
so such a day no longer looks empty. Screen readers name each entry.
|
||||
Someone who sees your cycle through a shared account sees these marks
|
||||
only if they could already open the day. Thanks to @Nazza01 for the
|
||||
report and the screenshot (#1032).
|
||||
- **Editing a vaccination dose no longer removes its documents.** Ever
|
||||
since documents could be linked to doses (1.37.3), a dose opened for
|
||||
editing did not know its linked documents: it showed "Link a document"
|
||||
with nothing under it, and saving any change, such as a batch number,
|
||||
quietly unlinked every document. That was our bug. The dose now shows
|
||||
its linked pages and saving leaves them in place.
|
||||
- **Document links are easier to tell apart and harder to remove by
|
||||
accident.** On a document, each linked dose or visit shows its date, so
|
||||
three COVID-19 doses no longer read as three identical chips. Clicking a
|
||||
chip opens that dose, visit, condition or document; only the small x
|
||||
removes a link, and you can undo right after. Thanks to @Cnote43 for
|
||||
writing up every step (#1024).
|
||||
- **`/api/user/profile` reports the time zone the server uses.** When the
|
||||
stored zone is unusable it shows the instance default, as `/api/auth/me`
|
||||
already did.
|
||||
- **`upsert_failed` in the nutrient and cycle uploads means "send it
|
||||
again".** The API description said to drop such an entry. A retry with
|
||||
the same `Idempotency-Key` now writes again instead of getting the
|
||||
earlier failure back.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- Take a database backup before upgrading.
|
||||
- Migrations: `0346` adds the `EXTERNAL` measurement source, `0347` adds
|
||||
the table that tracks restore jobs, `0349` adds body site and side to
|
||||
visits, `0350` returns misses wrongly stamped on slots before a
|
||||
medication's creation to pending (nothing is deleted), and `0351` adds
|
||||
the Track intake switch, on for every medication.
|
||||
- `POST /api/admin/backups/{id}/restore` now answers `202` with a `jobId`
|
||||
and a `statusUrl`. `GET /api/admin/backups/restores/{jobId}` returns the
|
||||
job's status and result, `GET /api/admin/backups/restores` lists recent
|
||||
restores, and a second restore of the same account answers `409` with
|
||||
`meta.errorCode: backup.restore.active`.
|
||||
- Everything else in the API is additive, and iPhone app 1.0.3 and 1.0.4
|
||||
keep working. For a record-only medication the shipped app stops
|
||||
reminding, but its card still shows Taken and Skip, and a schedule
|
||||
change made on the phone is not applied. Change that schedule on the
|
||||
web for now.
|
||||
|
||||
## [1.39.0] — 2026-09-25
|
||||
|
||||
AI is optional everywhere now: your data loads whatever the AI settings
|
||||
|
||||
+895
-127
File diff suppressed because it is too large.
Load diff
@@ -60,8 +60,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
Read three things off the output before going further:
|
||||
|
||||
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`. Treat
|
||||
`errors > 0` as a hard failure and re-run after fixing the cause. A
|
||||
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
|
||||
`scanned` counts the rows that hold ciphertext in that column, not every
|
||||
row of the table. Treat `errors > 0` as a hard failure and re-run after
|
||||
fixing the cause. A
|
||||
`dropped` count is only ever a cache row the run could not read and
|
||||
deleted rather than leave unreadable.
|
||||
- `Columns walked: N/M registered`. The two numbers must match.
|
||||
|
||||
@@ -532,7 +532,38 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
|
||||
await openBackupsConsole(page);
|
||||
const response = await restoreThroughDialog(page, requireSnapshot());
|
||||
expect(response.status()).toBe(200);
|
||||
// The restore runs on the `backup-restore` queue: the request answers 202
|
||||
// with the job and its status route, and the worker inside the server
|
||||
// under test does the work. Nothing below is read until the job says it
|
||||
// is done, so every assertion is about the finished restore rather than
|
||||
// a moment in the middle of it.
|
||||
expect(response.status()).toBe(202);
|
||||
const queued = (await response.json()) as {
|
||||
data: { jobId: string; status: string; statusUrl: string };
|
||||
};
|
||||
expect(queued.data.status).toBe("queued");
|
||||
expect(queued.data.statusUrl).toBe(
|
||||
`/api/admin/backups/restores/${queued.data.jobId}`,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(queued.data.statusUrl);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
status: string;
|
||||
failure: { code: string } | null;
|
||||
} | null;
|
||||
};
|
||||
// A failed job ends the wait with its code in the message instead
|
||||
// of running the clock out on a state that will never change.
|
||||
return body.data?.status === "failed"
|
||||
? `failed: ${body.data.failure?.code ?? "unknown"}`
|
||||
: body.data?.status;
|
||||
},
|
||||
{ timeout: 60_000, intervals: [250, 500, 1_000] },
|
||||
)
|
||||
.toBe("succeeded");
|
||||
|
||||
const afterRestore = await storedMeasurements();
|
||||
|
||||
@@ -564,7 +595,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
|
||||
|
||||
// And the reading that arrived after the snapshot is gone.
|
||||
// `docs/ops/backup-restore.md` says the restore "replaces the account's
|
||||
// data tables", and the route's own words are "one transaction replaces
|
||||
// data tables", and `restore-backup.ts` puts it as "one transaction replaces
|
||||
// every serialized owner-scoped class". Replacing is not merging: whoever
|
||||
// restores yesterday's copy loses today's readings, and that is the
|
||||
// documented behaviour rather than an accident of the implementation.
|
||||
|
||||
+99
-11
@@ -293,6 +293,11 @@
|
||||
"visitsColReason": "Anlass",
|
||||
"visitsColOutcome": "Ergebnis",
|
||||
"visitsColConditions": "Erkrankungen",
|
||||
"surgicalHistoryTitle": "Operationen und Eingriffe",
|
||||
"surgicalHistoryColDate": "Datum",
|
||||
"surgicalHistoryColProcedure": "Eingriff",
|
||||
"surgicalHistoryColBodySite": "Körperstelle",
|
||||
"surgicalHistoryColOutcome": "Ergebnis",
|
||||
"immunizationsTitle": "Impfungen",
|
||||
"immunizationsColDate": "Datum",
|
||||
"immunizationsColVaccine": "Impfstoff",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Gestern",
|
||||
"tomorrow": "Morgen",
|
||||
"asNeededMarker": "Bei Bedarf",
|
||||
"recordOnlyBadge": "Nur dokumentiert",
|
||||
"trackIntake": {
|
||||
"title": "Einnahmen erfassen",
|
||||
"label": "Einnahmen erfassen",
|
||||
"helperOn": "Dosen werden nach diesem Plan fällig, mit Erinnerungen und Therapietreue.",
|
||||
"helperOff": "Nur dokumentiert: nichts wird fällig, keine Erinnerungen, keine Therapietreue.",
|
||||
"enabledToast": "Einnahmen werden erfasst",
|
||||
"disabledToast": "Einnahmen werden nicht erfasst",
|
||||
"toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden."
|
||||
},
|
||||
"withoutNotification": "Ohne Benachrichtigung",
|
||||
"pausedSince": "Pausiert seit",
|
||||
"takeNow": "Jetzt einnehmen",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Zuordnung gelöst",
|
||||
"dueContext": "fällig gewesen: {time} ({delta})",
|
||||
"confirmTitle": "Slot {time} zuordnen?",
|
||||
"confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
|
||||
"confirmBody": "Die Einnahme zählt für diesen Slot genau so, als wäre sie dort erfasst worden: pünktlich oder verspätet, je nachdem, wann sie genommen wurde. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
|
||||
"confirmCancel": "Abbrechen",
|
||||
"confirmAction": "Zuordnen"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Nächste Dosis",
|
||||
"nextDoseNone": "Keine anstehende Dosis.",
|
||||
"nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.",
|
||||
"nextDoseNotTracked": "Nicht erfasst, nur dokumentiert.",
|
||||
"pausedHint": "Pausiert — keine Erinnerungen.",
|
||||
"reminderLabel": "Erinnerung",
|
||||
"reminderOn": "Aktiv",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Bearbeiten",
|
||||
"packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.",
|
||||
"packagingUnitsPerDoseLabel": "Einheiten pro Dosis",
|
||||
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis (1–100). Bereits erfasste Einnahmen bleiben unverändert.",
|
||||
"packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis oder 1,5 für anderthalb (über 0, bis 100). Bereits erfasste Einnahmen bleiben unverändert.",
|
||||
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
|
||||
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
|
||||
"packagingSuccess": "Packungsangaben gespeichert",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen",
|
||||
"reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis",
|
||||
"reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Andere…",
|
||||
"inputLabel": "Einheiten pro Dosis, anderer Wert",
|
||||
"placeholder": "z. B. 1,5 oder 1 1/2",
|
||||
"invalid": "Gib eine Zahl über 0 und bis 100 ein, zum Beispiel 1,5 oder 1 1/2.",
|
||||
"preview": "Zählt als {value} pro Dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} ist angelegt.",
|
||||
"doneUnnamed": "Dein erstes Medikament ist angelegt.",
|
||||
"nextDose": "Nächste Dosis: {when}.",
|
||||
"reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast."
|
||||
"reminders": "Erinnerungen folgen dem Plan, den du festgelegt hast.",
|
||||
"recordOnly": "Nur dokumentiert: keine Erinnerungen, nichts wird fällig.",
|
||||
"asNeeded": "Bei Bedarf: keine Erinnerungen, erfasse eine Dosis, wenn du sie nimmst."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Einen Wert eintragen: {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Eine Backup-Datei validieren und als manuellen Snapshot speichern. Das Wiederherstellen ist ein separater Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
|
||||
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"restore": "Wiederherstellen",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.",
|
||||
"age": "vor {hours} h",
|
||||
"scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Wiederherstellung gestartet. Der Fortschritt steht über der Liste.",
|
||||
"restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
|
||||
"restoreJobQueuedTitle": "Wiederherstellung von {username} wartet auf den Start",
|
||||
"restoreJobQueuedNote": "Sie beginnt, sobald der Hintergrunddienst sie übernimmt.",
|
||||
"restoreJobRunningTitle": "{username} wird wiederhergestellt",
|
||||
"restoreJobRunningNote": "Das Konto zeigt seine bisherigen Daten, bis die Wiederherstellung fertig ist. Du kannst die Seite verlassen; die Wiederherstellung läuft auf dem Server weiter.",
|
||||
"restoreJobResumed": "Nach einem Neustart des Servers erneut gestartet.",
|
||||
"restoreJobPhaseValidating": "Backup-Datei wird geprüft: {checked} Messwerte geprüft",
|
||||
"restoreJobPhaseClearing": "Bisherige Daten des Kontos werden entfernt",
|
||||
"restoreJobPhaseMeasurements": "Messwerte werden geschrieben: {done} von {total}",
|
||||
"restoreJobPhaseSections": "Übrige Einträge werden geschrieben: {done} von {total} Bereichen",
|
||||
"restoreJobPhaseRebuilding": "Diagramme und Zusammenfassungen werden neu berechnet",
|
||||
"restoreJobSucceededTitle": "Wiederherstellung von {username} abgeschlossen",
|
||||
"restoreJobSucceededDescription": "{count} Datensätze wiederhergestellt. Fertig {when}.",
|
||||
"restoreJobFailedTitle": "Wiederherstellung von {username} nicht abgeschlossen",
|
||||
"restoreJobFailedKept": "Es wurde nichts geändert. Das Backup bleibt erhalten, du kannst die Wiederherstellung also über seine Zeile erneut starten, ohne es noch einmal hochzuladen.",
|
||||
"restoreFailureNotFound": "Das Backup wurde gelöscht, bevor die Wiederherstellung es lesen konnte.",
|
||||
"restoreFailureChanged": "Das Backup wurde durch eine neuere Kopie ersetzt, während die Wiederherstellung wartete. Prüfe die neue Kopie und starte die Wiederherstellung dann erneut.",
|
||||
"restoreFailureUndecryptable": "Das Backup ließ sich nicht entschlüsseln. Der Schlüssel, mit dem es geschrieben wurde, ist nicht mehr eingerichtet, oder die gespeicherte Kopie wurde verändert.",
|
||||
"restoreFailureSchema": "Die Backup-Datei ist kein gültiges HealthLog-Backup.",
|
||||
"restoreFailureVersion": "Das Backup stammt von einer neueren HealthLog-Version. Aktualisiere zuerst diesen Server.",
|
||||
"restoreFailureOwnerMismatch": "Die Datei im Backup gehört zu einem anderen Konto als die Backup-Zeile.",
|
||||
"restoreFailureOwnerMissing": "Das Konto, zu dem dieses Backup gehört, gibt es nicht mehr.",
|
||||
"restoreFailureSectionMissing": "Die Datei ist unvollständig: Ihr Manifest nennt einen Bereich, den sie nicht enthält.",
|
||||
"restoreFailureDocument": "Die Datei enthält Dokumente ohne deren Inhalt, wie ein portabler Export. Nimm eine Notfall-Sicherung.",
|
||||
"restoreFailureTimeBudget": "Dieses Backup ist zu groß, um innerhalb des Zeitlimits des Hintergrundauftrags wiederhergestellt zu werden.",
|
||||
"restoreFailureTransaction": "Die Datenbank hat die Wiederherstellung mittendrin abgelehnt, deshalb wurde alles zurückgerollt. Das Server-Log nennt den Fehler.",
|
||||
"restoreFailureInterrupted": "Die Wiederherstellung wurde durch einen Neustart des Servers unterbrochen und zurückgerollt. Bricht sie erneut ab, reicht dem Server wahrscheinlich der Arbeitsspeicher nicht.",
|
||||
"restoreFailureNotStarted": "Der Hintergrunddienst hat die Wiederherstellung nicht übernommen. Prüfe, ob er läuft.",
|
||||
"restoreFailureEnqueue": "Die Wiederherstellung konnte nicht an den Hintergrunddienst übergeben werden. Prüfe, ob er läuft.",
|
||||
"restoreFailureAfterCommit": "Die Daten wurden wiederhergestellt, aber ein Schritt danach ist fehlgeschlagen. Diagramme holen das womöglich erst beim nächsten nächtlichen Lauf nach.",
|
||||
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "Schmierblutung",
|
||||
"flowLight": "leicht",
|
||||
"flowMedium": "mittel",
|
||||
"flowHeavy": "stark"
|
||||
"flowHeavy": "stark",
|
||||
"legendIntercourse": "Geschlechtsverkehr",
|
||||
"legendOtherEntries": "Weitere Einträge"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Was als Nächstes kommt",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} Einträge aus {groups} Bereichen",
|
||||
"scopeSensitive": "inkl. {names}",
|
||||
"leafVisits": "Arztbesuche",
|
||||
"leafSurgicalHistory": "Operationen und Eingriffe",
|
||||
"leafImmunizations": "Impfungen",
|
||||
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Notfall",
|
||||
"hospital": "Krankenhaus",
|
||||
"therapy": "Therapietermin",
|
||||
"other": "Arzttermin"
|
||||
"other": "Arzttermin",
|
||||
"procedure": "Eingriff oder Operation"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Links",
|
||||
"right": "Rechts",
|
||||
"both": "Beidseitig",
|
||||
"none": "Keine Angabe"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Termine",
|
||||
"sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
|
||||
"add": "Termin",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Anlass",
|
||||
"outcome": "Ergebnis",
|
||||
"outcomeReveal": "Ergebnis eintragen",
|
||||
"bodySite": "Körperstelle",
|
||||
"bodySiteHint": "Zum Beispiel Knie oder Gallenblase.",
|
||||
"laterality": "Seite",
|
||||
"linkDocuments": "Dokumente",
|
||||
"linkLabResults": "Laborwerte",
|
||||
"linkConditions": "Erkrankungen",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Nächster Termin: {what} am {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Eingriffe und Operationen",
|
||||
"description": "Alle Eingriffe und Operationen, die du hattest, die neuesten zuerst.",
|
||||
"add": "Eingriff",
|
||||
"searchLabel": "Nach Körperstelle filtern",
|
||||
"searchPlaceholder": "Zum Beispiel Knie",
|
||||
"sitesLabel": "Körperstellen",
|
||||
"allSites": "Alle",
|
||||
"count": "{shown} von {total}",
|
||||
"emptyTitle": "Noch kein Eingriff erfasst",
|
||||
"emptyDescription": "Erfasse einen Termin der Art Eingriff oder Operation, oder stelle einen vorhandenen Termin darauf um.",
|
||||
"noMatchTitle": "Nichts passt zu diesem Filter",
|
||||
"clearFilter": "Filter zurücksetzen",
|
||||
"loadError": "Die Eingriffe konnten nicht geladen werden."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Vorsorge oder Termine anzeigen",
|
||||
"viewSwitchLabel": "Vorsorge, Termine oder Eingriffe anzeigen",
|
||||
"tabVorsorge": "Vorsorge",
|
||||
"tabVisits": "Termine"
|
||||
"tabVisits": "Termine",
|
||||
"tabProcedures": "Eingriffe"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Praxen und Ärzte",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Alle auswählen",
|
||||
"searchPlaceholder": "Suchen",
|
||||
"searchNoMatch": "Kein Treffer",
|
||||
"remove": "Entfernen",
|
||||
"suggested": "Um dieses Datum",
|
||||
"loadError": "Die Liste konnte nicht geladen werden."
|
||||
"loadError": "Die Liste konnte nicht geladen werden.",
|
||||
"removeNamed": "{name} entfernen",
|
||||
"removed": "Verknüpfung mit {name} gelöst"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -329,6 +329,11 @@
|
||||
"visitsColReason": "Reason",
|
||||
"visitsColOutcome": "Outcome",
|
||||
"visitsColConditions": "Conditions",
|
||||
"surgicalHistoryTitle": "Surgical history",
|
||||
"surgicalHistoryColDate": "Date",
|
||||
"surgicalHistoryColProcedure": "Procedure",
|
||||
"surgicalHistoryColBodySite": "Body site",
|
||||
"surgicalHistoryColOutcome": "Outcome",
|
||||
"immunizationsTitle": "Immunizations",
|
||||
"immunizationsColDate": "Date",
|
||||
"immunizationsColVaccine": "Vaccine",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow",
|
||||
"asNeededMarker": "As needed",
|
||||
"recordOnlyBadge": "Record only",
|
||||
"trackIntake": {
|
||||
"title": "Intake tracking",
|
||||
"label": "Track intake",
|
||||
"helperOn": "Doses fall due on this schedule, with reminders and adherence.",
|
||||
"helperOff": "Kept as a record: nothing falls due, no reminders, no adherence.",
|
||||
"enabledToast": "Intake tracking on",
|
||||
"disabledToast": "Intake tracking off",
|
||||
"toggleFailed": "Could not change intake tracking."
|
||||
},
|
||||
"withoutNotification": "Without notification",
|
||||
"pausedSince": "Paused since",
|
||||
"takeNow": "Take now",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Attribution released",
|
||||
"dueContext": "was due: {time} ({delta})",
|
||||
"confirmTitle": "Attribute to slot {time}?",
|
||||
"confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.",
|
||||
"confirmBody": "The intake counts for this slot just as it would if it had been logged there: on time or late depending on when it was taken, so the rate is never flattered. You can release the attribution at any time.",
|
||||
"confirmCancel": "Cancel",
|
||||
"confirmAction": "Attribute"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Next dose",
|
||||
"nextDoseNone": "No upcoming dose.",
|
||||
"nextDoseAsNeeded": "As needed — no planned dose.",
|
||||
"nextDoseNotTracked": "Not tracked, kept as a record.",
|
||||
"pausedHint": "Paused — no reminders.",
|
||||
"reminderLabel": "Reminder",
|
||||
"reminderOn": "On",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Edit",
|
||||
"packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.",
|
||||
"packagingUnitsPerDoseLabel": "Units per dose",
|
||||
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose (1–100). Doses already recorded stay unchanged.",
|
||||
"packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose, or 1.5 for one and a half (above 0, up to 100). Doses already recorded stay unchanged.",
|
||||
"packagingDefaultPackLabel": "Default pack size (units)",
|
||||
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
|
||||
"packagingSuccess": "Packaging details saved",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged",
|
||||
"reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose",
|
||||
"reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Other…",
|
||||
"inputLabel": "Units per dose, other value",
|
||||
"placeholder": "e.g. 1.5 or 1 1/2",
|
||||
"invalid": "Enter a number above 0 and up to 100, for example 1.5 or 1 1/2.",
|
||||
"preview": "Counts as {value} per dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} is added.",
|
||||
"doneUnnamed": "Your first medication is added.",
|
||||
"nextDose": "Next dose: {when}.",
|
||||
"reminders": "Reminders follow the schedule you set."
|
||||
"reminders": "Reminders follow the schedule you set.",
|
||||
"recordOnly": "Kept as a record: no reminders, nothing falls due.",
|
||||
"asNeeded": "As needed: no reminders, log a dose when you take it."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Enter one reading: {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Validate a backup file and store it as a manual snapshot. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "JSON file matching the current backup schema. Max 10 MB.",
|
||||
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
"uploadFailed": "Upload failed",
|
||||
"restore": "Restore",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "No nightly run has recorded this account yet.",
|
||||
"age": "{hours} h ago",
|
||||
"scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Restore started. Its progress shows above the list.",
|
||||
"restoreActive": "A restore of this account is already running. Wait for it to finish.",
|
||||
"restoreJobQueuedTitle": "Restore of {username} is waiting to start",
|
||||
"restoreJobQueuedNote": "It starts as soon as the background worker picks it up.",
|
||||
"restoreJobRunningTitle": "Restoring {username}",
|
||||
"restoreJobRunningNote": "The account keeps showing its current data until the restore finishes. You can leave this page; the restore continues on the server.",
|
||||
"restoreJobResumed": "Started again after the server restarted.",
|
||||
"restoreJobPhaseValidating": "Checking the backup file: {checked} readings checked",
|
||||
"restoreJobPhaseClearing": "Clearing the account's current data",
|
||||
"restoreJobPhaseMeasurements": "Writing readings: {done} of {total}",
|
||||
"restoreJobPhaseSections": "Writing the other records: {done} of {total} sections",
|
||||
"restoreJobPhaseRebuilding": "Rebuilding charts and summaries",
|
||||
"restoreJobSucceededTitle": "Restore of {username} finished",
|
||||
"restoreJobSucceededDescription": "{count} records restored. Finished {when}.",
|
||||
"restoreJobFailedTitle": "Restore of {username} did not complete",
|
||||
"restoreJobFailedKept": "Nothing was changed. The backup is kept, so you can start the restore again from its row without uploading it again.",
|
||||
"restoreFailureNotFound": "The backup was deleted before the restore could read it.",
|
||||
"restoreFailureChanged": "The backup was replaced by a newer copy while the restore waited. Check the new copy, then start the restore again.",
|
||||
"restoreFailureUndecryptable": "The backup could not be decrypted. The key that wrote it is no longer configured, or the stored copy was changed.",
|
||||
"restoreFailureSchema": "The backup file is not a valid HealthLog backup.",
|
||||
"restoreFailureVersion": "The backup was written by a newer version of HealthLog. Update this server first.",
|
||||
"restoreFailureOwnerMismatch": "The file inside the backup belongs to a different account than the backup row.",
|
||||
"restoreFailureOwnerMissing": "The account this backup belongs to no longer exists.",
|
||||
"restoreFailureSectionMissing": "The file is incomplete: its manifest names a section it does not carry.",
|
||||
"restoreFailureDocument": "The file carries documents without their content, as a portable export does. Use a disaster-recovery backup.",
|
||||
"restoreFailureTimeBudget": "This backup is too large to restore within the background job's time limit.",
|
||||
"restoreFailureTransaction": "The database refused the restore part-way, so everything was rolled back. The server log names the error.",
|
||||
"restoreFailureInterrupted": "The restore stopped when the server restarted, and was rolled back. If it stops again, the server is likely running out of memory.",
|
||||
"restoreFailureNotStarted": "The background worker did not pick the restore up. Check that the worker is running.",
|
||||
"restoreFailureEnqueue": "The restore could not be handed to the background worker. Check that the worker is running.",
|
||||
"restoreFailureAfterCommit": "The data was restored, but a step after it failed. Charts may take until the next nightly run to catch up.",
|
||||
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "light",
|
||||
"flowMedium": "medium",
|
||||
"flowHeavy": "heavy"
|
||||
"flowHeavy": "heavy",
|
||||
"legendIntercourse": "Intercourse",
|
||||
"legendOtherEntries": "Other entries"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "What comes next",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visits",
|
||||
"leafSurgicalHistory": "Surgical history",
|
||||
"leafImmunizations": "Immunizations",
|
||||
"leafEmergency": "Emergency information (blood type, contacts, directives)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Emergency",
|
||||
"hospital": "Hospital",
|
||||
"therapy": "Therapy session",
|
||||
"other": "Appointment"
|
||||
"other": "Appointment",
|
||||
"procedure": "Procedure or surgery"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Left",
|
||||
"right": "Right",
|
||||
"both": "Both sides",
|
||||
"none": "Not stated"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Visits",
|
||||
"sectionDescription": "Doctor's visits and booked appointments in one place.",
|
||||
"add": "Visit",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Reason",
|
||||
"outcome": "Outcome",
|
||||
"outcomeReveal": "Add an outcome",
|
||||
"bodySite": "Body site",
|
||||
"bodySiteHint": "For example knee or gallbladder.",
|
||||
"laterality": "Side",
|
||||
"linkDocuments": "Documents",
|
||||
"linkLabResults": "Lab results",
|
||||
"linkConditions": "Conditions",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Next appointment: {what} on {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Procedures and surgeries",
|
||||
"description": "Every procedure and surgery you have had, newest first.",
|
||||
"add": "Procedure",
|
||||
"searchLabel": "Filter by body site",
|
||||
"searchPlaceholder": "For example knee",
|
||||
"sitesLabel": "Body sites",
|
||||
"allSites": "All",
|
||||
"count": "{shown} of {total}",
|
||||
"emptyTitle": "No procedure recorded yet",
|
||||
"emptyDescription": "Record a visit of the kind Procedure or surgery, or switch an existing visit to it.",
|
||||
"noMatchTitle": "Nothing matches this filter",
|
||||
"clearFilter": "Clear filter",
|
||||
"loadError": "Procedures could not be loaded."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Show checkups or visits",
|
||||
"viewSwitchLabel": "Show checkups, visits or procedures",
|
||||
"tabVorsorge": "Checkups",
|
||||
"tabVisits": "Visits"
|
||||
"tabVisits": "Visits",
|
||||
"tabProcedures": "Procedures"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Practices and doctors",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Select all",
|
||||
"searchPlaceholder": "Search",
|
||||
"searchNoMatch": "No match",
|
||||
"remove": "Remove",
|
||||
"suggested": "Around this date",
|
||||
"loadError": "The list could not be loaded."
|
||||
"loadError": "The list could not be loaded.",
|
||||
"removeNamed": "Remove {name}",
|
||||
"removed": "Unlinked {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -293,6 +293,11 @@
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Resultado",
|
||||
"visitsColConditions": "Enfermedades",
|
||||
"surgicalHistoryTitle": "Antecedentes quirúrgicos",
|
||||
"surgicalHistoryColDate": "Fecha",
|
||||
"surgicalHistoryColProcedure": "Procedimiento",
|
||||
"surgicalHistoryColBodySite": "Zona del cuerpo",
|
||||
"surgicalHistoryColOutcome": "Resultado",
|
||||
"immunizationsTitle": "Vacunas",
|
||||
"immunizationsColDate": "Fecha",
|
||||
"immunizationsColVaccine": "Vacuna",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Ayer",
|
||||
"tomorrow": "Mañana",
|
||||
"asNeededMarker": "Según necesidad",
|
||||
"recordOnlyBadge": "Solo registro",
|
||||
"trackIntake": {
|
||||
"title": "Registro de tomas",
|
||||
"label": "Registrar tomas",
|
||||
"helperOn": "Las dosis tocan según este horario, con recordatorios y adherencia.",
|
||||
"helperOff": "Se guarda como registro: nada queda pendiente, sin recordatorios ni adherencia.",
|
||||
"enabledToast": "Registro de tomas activado",
|
||||
"disabledToast": "Registro de tomas desactivado",
|
||||
"toggleFailed": "No se pudo cambiar el registro de tomas."
|
||||
},
|
||||
"withoutNotification": "Sin notificación",
|
||||
"pausedSince": "En pausa desde",
|
||||
"takeNow": "Tomar ahora",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Asignación quitada",
|
||||
"dueContext": "prevista: {time} ({delta})",
|
||||
"confirmTitle": "¿Asignar al horario de las {time}?",
|
||||
"confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
|
||||
"confirmBody": "La toma contará para este horario igual que si se hubiera registrado en él: a tiempo o con retraso según cuándo se tomó, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
|
||||
"confirmCancel": "Cancelar",
|
||||
"confirmAction": "Asignar"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Próxima dosis",
|
||||
"nextDoseNone": "No hay dosis pendiente.",
|
||||
"nextDoseAsNeeded": "Según necesidad — sin dosis programada.",
|
||||
"nextDoseNotTracked": "Sin registro de tomas, se guarda como registro.",
|
||||
"pausedHint": "En pausa — sin recordatorios.",
|
||||
"reminderLabel": "Recordatorio",
|
||||
"reminderOn": "Activado",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Editar",
|
||||
"packagingDescription": "Define cuántas unidades consume una dosis y el tamaño habitual de un envase nuevo, p. ej. tras un cambio de fabricante con otro tamaño de blíster.",
|
||||
"packagingUnitsPerDoseLabel": "Unidades por dosis",
|
||||
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg (1–100). Las tomas ya registradas no cambian.",
|
||||
"packagingUnitsPerDoseHelper": "P. ej. dos comprimidos de 2 mg para una dosis de 4 mg, o 1,5 para uno y medio (más de 0, hasta 100). Las tomas ya registradas no cambian.",
|
||||
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
|
||||
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
|
||||
"packagingSuccess": "Datos del envase guardados",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "el archivo no registra nada sobre esa dosis: nadie respondió y nada se anotó",
|
||||
"reasonStatusReminderEvent": "la fila trata de un recordatorio postergado, que no dice nada sobre la dosis",
|
||||
"reasonStatusNotificationNotSent": "la fila registra que no se envió ninguna notificación, lo cual habla de la aplicación y no de la dosis"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Otro…",
|
||||
"inputLabel": "Unidades por dosis, otro valor",
|
||||
"placeholder": "p. ej., 1,5 o 1 1/2",
|
||||
"invalid": "Introduce un número mayor que 0 y hasta 100, por ejemplo 1,5 o 1 1/2.",
|
||||
"preview": "Cuenta como {value} por dosis"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} está añadido.",
|
||||
"doneUnnamed": "Tu primer medicamento está añadido.",
|
||||
"nextDose": "Próxima dosis: {when}.",
|
||||
"reminders": "Los recordatorios siguen el horario que fijaste."
|
||||
"reminders": "Los recordatorios siguen el horario que fijaste.",
|
||||
"recordOnly": "Solo registro: sin recordatorios, nada queda pendiente.",
|
||||
"asNeeded": "Según necesidad: sin recordatorios, registra cada toma cuando la hagas."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Registra una medición: {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Subir copia de seguridad",
|
||||
"uploadDescription": "Valida un archivo de copia de seguridad y guárdalo como instantánea manual. La restauración es un paso aparte.",
|
||||
"uploadButton": "Subir archivo",
|
||||
"uploadHelp": "Archivo JSON que coincida con el esquema de copia de seguridad actual. Máx. 10 MB.",
|
||||
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
|
||||
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
|
||||
"uploadFailed": "La subida falló",
|
||||
"restore": "Restaurar",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.",
|
||||
"age": "hace {hours} h",
|
||||
"scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Restauración iniciada. Su progreso aparece encima de la lista.",
|
||||
"restoreActive": "Ya hay una restauración en curso para esta cuenta. Espera a que termine.",
|
||||
"restoreJobQueuedTitle": "La restauración de {username} está esperando para empezar",
|
||||
"restoreJobQueuedNote": "Empieza en cuanto el proceso en segundo plano la recoja.",
|
||||
"restoreJobRunningTitle": "Restaurando {username}",
|
||||
"restoreJobRunningNote": "La cuenta sigue mostrando sus datos actuales hasta que termine la restauración. Puedes salir de esta página; la restauración continúa en el servidor.",
|
||||
"restoreJobResumed": "Iniciada de nuevo tras reiniciarse el servidor.",
|
||||
"restoreJobPhaseValidating": "Comprobando el archivo de copia: {checked} lecturas comprobadas",
|
||||
"restoreJobPhaseClearing": "Borrando los datos actuales de la cuenta",
|
||||
"restoreJobPhaseMeasurements": "Escribiendo lecturas: {done} de {total}",
|
||||
"restoreJobPhaseSections": "Escribiendo el resto de registros: {done} de {total} secciones",
|
||||
"restoreJobPhaseRebuilding": "Recalculando gráficos y resúmenes",
|
||||
"restoreJobSucceededTitle": "Restauración de {username} terminada",
|
||||
"restoreJobSucceededDescription": "{count} registros restaurados. Terminó {when}.",
|
||||
"restoreJobFailedTitle": "La restauración de {username} no se completó",
|
||||
"restoreJobFailedKept": "No se cambió nada. La copia se conserva, así que puedes volver a iniciar la restauración desde su fila sin subirla otra vez.",
|
||||
"restoreFailureNotFound": "La copia se eliminó antes de que la restauración pudiera leerla.",
|
||||
"restoreFailureChanged": "La copia se sustituyó por una más reciente mientras la restauración esperaba. Revisa la copia nueva y vuelve a iniciar la restauración.",
|
||||
"restoreFailureUndecryptable": "No se pudo descifrar la copia. La clave con la que se escribió ya no está configurada, o la copia guardada se modificó.",
|
||||
"restoreFailureSchema": "El archivo no es una copia de seguridad válida de HealthLog.",
|
||||
"restoreFailureVersion": "La copia la escribió una versión más reciente de HealthLog. Actualiza primero este servidor.",
|
||||
"restoreFailureOwnerMismatch": "El archivo de la copia pertenece a otra cuenta que la fila de la copia.",
|
||||
"restoreFailureOwnerMissing": "La cuenta a la que pertenece esta copia ya no existe.",
|
||||
"restoreFailureSectionMissing": "El archivo está incompleto: su manifiesto nombra una sección que no contiene.",
|
||||
"restoreFailureDocument": "El archivo contiene documentos sin su contenido, como una exportación portátil. Usa una copia de recuperación ante desastres.",
|
||||
"restoreFailureTimeBudget": "Esta copia es demasiado grande para restaurarse dentro del límite de tiempo del proceso en segundo plano.",
|
||||
"restoreFailureTransaction": "La base de datos rechazó la restauración a mitad, así que se deshizo todo. El registro del servidor indica el error.",
|
||||
"restoreFailureInterrupted": "La restauración se detuvo al reiniciarse el servidor y se deshizo. Si vuelve a detenerse, probablemente al servidor le falta memoria.",
|
||||
"restoreFailureNotStarted": "El proceso en segundo plano no recogió la restauración. Comprueba que está en marcha.",
|
||||
"restoreFailureEnqueue": "No se pudo entregar la restauración al proceso en segundo plano. Comprueba que está en marcha.",
|
||||
"restoreFailureAfterCommit": "Los datos se restauraron, pero falló un paso posterior. Puede que los gráficos no se pongan al día hasta la próxima ejecución nocturna.",
|
||||
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "manchado",
|
||||
"flowLight": "ligero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abundante"
|
||||
"flowHeavy": "abundante",
|
||||
"legendIntercourse": "Relaciones",
|
||||
"legendOtherEntries": "Otras entradas"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Qué viene después",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} entradas de {groups} áreas",
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visitas médicas",
|
||||
"leafSurgicalHistory": "Antecedentes quirúrgicos",
|
||||
"leafImmunizations": "Vacunas",
|
||||
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Urgencia",
|
||||
"hospital": "Hospital",
|
||||
"therapy": "Sesión de terapia",
|
||||
"other": "Cita médica"
|
||||
"other": "Cita médica",
|
||||
"procedure": "Procedimiento o cirugía"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Izquierda",
|
||||
"right": "Derecha",
|
||||
"both": "Ambos lados",
|
||||
"none": "Sin indicar"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Citas",
|
||||
"sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
|
||||
"add": "Cita",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Motivo",
|
||||
"outcome": "Resultado",
|
||||
"outcomeReveal": "Añadir un resultado",
|
||||
"bodySite": "Zona del cuerpo",
|
||||
"bodySiteHint": "Por ejemplo, rodilla o vesícula.",
|
||||
"laterality": "Lado",
|
||||
"linkDocuments": "Documentos",
|
||||
"linkLabResults": "Resultados de laboratorio",
|
||||
"linkConditions": "Enfermedades",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Próxima cita: {what} el {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Procedimientos y cirugías",
|
||||
"description": "Todos los procedimientos y cirugías que has tenido, del más reciente al más antiguo.",
|
||||
"add": "Procedimiento",
|
||||
"searchLabel": "Filtrar por zona del cuerpo",
|
||||
"searchPlaceholder": "Por ejemplo, rodilla",
|
||||
"sitesLabel": "Zonas del cuerpo",
|
||||
"allSites": "Todas",
|
||||
"count": "{shown} de {total}",
|
||||
"emptyTitle": "Aún no hay ningún procedimiento registrado",
|
||||
"emptyDescription": "Registra una cita del tipo Procedimiento o cirugía, o cambia una cita existente a ese tipo.",
|
||||
"noMatchTitle": "Nada coincide con este filtro",
|
||||
"clearFilter": "Quitar filtro",
|
||||
"loadError": "No se pudieron cargar los procedimientos."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostrar revisiones o citas",
|
||||
"viewSwitchLabel": "Mostrar revisiones, citas o procedimientos",
|
||||
"tabVorsorge": "Revisiones",
|
||||
"tabVisits": "Citas"
|
||||
"tabVisits": "Citas",
|
||||
"tabProcedures": "Procedimientos"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Consultas y médicos",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Seleccionar todo",
|
||||
"searchPlaceholder": "Buscar",
|
||||
"searchNoMatch": "Sin coincidencias",
|
||||
"remove": "Quitar",
|
||||
"suggested": "En torno a esta fecha",
|
||||
"loadError": "No se pudo cargar la lista."
|
||||
"loadError": "No se pudo cargar la lista.",
|
||||
"removeNamed": "Quitar {name}",
|
||||
"removed": "Se desvinculó {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -293,6 +293,11 @@
|
||||
"visitsColReason": "Motif",
|
||||
"visitsColOutcome": "Résultat",
|
||||
"visitsColConditions": "Maladies",
|
||||
"surgicalHistoryTitle": "Antécédents chirurgicaux",
|
||||
"surgicalHistoryColDate": "Date",
|
||||
"surgicalHistoryColProcedure": "Intervention",
|
||||
"surgicalHistoryColBodySite": "Partie du corps",
|
||||
"surgicalHistoryColOutcome": "Résultat",
|
||||
"immunizationsTitle": "Vaccinations",
|
||||
"immunizationsColDate": "Date",
|
||||
"immunizationsColVaccine": "Vaccin",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Hier",
|
||||
"tomorrow": "Demain",
|
||||
"asNeededMarker": "Au besoin",
|
||||
"recordOnlyBadge": "Pour mémoire",
|
||||
"trackIntake": {
|
||||
"title": "Suivi des prises",
|
||||
"label": "Suivre les prises",
|
||||
"helperOn": "Les doses sont dues selon cet horaire, avec rappels et observance.",
|
||||
"helperOff": "Conservé pour mémoire : rien n’est dû, pas de rappel, pas d’observance.",
|
||||
"enabledToast": "Suivi des prises activé",
|
||||
"disabledToast": "Suivi des prises désactivé",
|
||||
"toggleFailed": "Impossible de modifier le suivi des prises."
|
||||
},
|
||||
"withoutNotification": "Sans notification",
|
||||
"pausedSince": "En pause depuis",
|
||||
"takeNow": "Prendre maintenant",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Prise détachée du créneau",
|
||||
"dueContext": "prévue à {time} ({delta})",
|
||||
"confirmTitle": "Rattacher au créneau de {time} ?",
|
||||
"confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.",
|
||||
"confirmBody": "La prise comptera pour ce créneau exactement comme si elle y avait été enregistrée : à l'heure ou en retard selon le moment où elle a été prise. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.",
|
||||
"confirmCancel": "Annuler",
|
||||
"confirmAction": "Rattacher"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Prochaine dose",
|
||||
"nextDoseNone": "Aucune dose à venir.",
|
||||
"nextDoseAsNeeded": "Au besoin — aucune dose planifiée.",
|
||||
"nextDoseNotTracked": "Prises non suivies, conservé pour mémoire.",
|
||||
"pausedHint": "En pause — aucun rappel.",
|
||||
"reminderLabel": "Rappel",
|
||||
"reminderOn": "Activé",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Modifier",
|
||||
"packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.",
|
||||
"packagingUnitsPerDoseLabel": "Unités par dose",
|
||||
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg (1–100). Les prises déjà enregistrées restent inchangées.",
|
||||
"packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg, ou 1,5 pour un et demi (plus de 0, jusqu’à 100). Les prises déjà enregistrées restent inchangées.",
|
||||
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
|
||||
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
|
||||
"packagingSuccess": "Conditionnement enregistré",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "le fichier ne consigne rien sur cette dose : personne n'a répondu et rien n'a été noté",
|
||||
"reasonStatusReminderEvent": "la ligne concerne un rappel reporté, ce qui ne dit rien de la dose",
|
||||
"reasonStatusNotificationNotSent": "la ligne consigne qu'aucune notification n'a été envoyée, ce qui parle de l'application et non de la dose"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Autre…",
|
||||
"inputLabel": "Unités par dose, autre valeur",
|
||||
"placeholder": "p. ex. 1,5 ou 1 1/2",
|
||||
"invalid": "Saisissez un nombre supérieur à 0 et jusqu’à 100, par exemple 1,5 ou 1 1/2.",
|
||||
"preview": "Compte comme {value} par dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} est ajouté.",
|
||||
"doneUnnamed": "Votre premier médicament est ajouté.",
|
||||
"nextDose": "Prochaine prise : {when}.",
|
||||
"reminders": "Les rappels suivent l'horaire que vous avez défini."
|
||||
"reminders": "Les rappels suivent l'horaire que vous avez défini.",
|
||||
"recordOnly": "Conservé pour mémoire : pas de rappel, rien n’est dû.",
|
||||
"asNeeded": "Au besoin : pas de rappel, notez une prise quand vous la prenez."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Saisir une mesure : {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Téléverser une sauvegarde",
|
||||
"uploadDescription": "Validez un fichier de sauvegarde et stockez-le comme instantané manuel. La restauration est une étape distincte.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadHelp": "Fichier JSON correspondant au schéma de sauvegarde actuel. Max 10 Mo.",
|
||||
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
|
||||
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
|
||||
"uploadFailed": "Échec du téléversement",
|
||||
"restore": "Restaurer",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.",
|
||||
"age": "il y a {hours} h",
|
||||
"scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Restauration lancée. Sa progression s'affiche au-dessus de la liste.",
|
||||
"restoreActive": "Une restauration de ce compte est déjà en cours. Attends qu'elle se termine.",
|
||||
"restoreJobQueuedTitle": "La restauration de {username} attend de démarrer",
|
||||
"restoreJobQueuedNote": "Elle démarre dès que le processus en arrière-plan la prend en charge.",
|
||||
"restoreJobRunningTitle": "Restauration de {username} en cours",
|
||||
"restoreJobRunningNote": "Le compte affiche ses données actuelles jusqu'à la fin de la restauration. Tu peux quitter cette page ; la restauration continue sur le serveur.",
|
||||
"restoreJobResumed": "Relancée après le redémarrage du serveur.",
|
||||
"restoreJobPhaseValidating": "Vérification du fichier de sauvegarde : {checked} mesures vérifiées",
|
||||
"restoreJobPhaseClearing": "Suppression des données actuelles du compte",
|
||||
"restoreJobPhaseMeasurements": "Écriture des mesures : {done} sur {total}",
|
||||
"restoreJobPhaseSections": "Écriture des autres entrées : {done} sections sur {total}",
|
||||
"restoreJobPhaseRebuilding": "Recalcul des graphiques et des résumés",
|
||||
"restoreJobSucceededTitle": "Restauration de {username} terminée",
|
||||
"restoreJobSucceededDescription": "{count} entrées restaurées. Terminée {when}.",
|
||||
"restoreJobFailedTitle": "La restauration de {username} n'a pas abouti",
|
||||
"restoreJobFailedKept": "Rien n'a été modifié. La sauvegarde est conservée : tu peux relancer la restauration depuis sa ligne sans la téléverser à nouveau.",
|
||||
"restoreFailureNotFound": "La sauvegarde a été supprimée avant que la restauration puisse la lire.",
|
||||
"restoreFailureChanged": "La sauvegarde a été remplacée par une copie plus récente pendant l'attente. Vérifie la nouvelle copie, puis relance la restauration.",
|
||||
"restoreFailureUndecryptable": "La sauvegarde n'a pas pu être déchiffrée. La clé qui l'a écrite n'est plus configurée, ou la copie stockée a été modifiée.",
|
||||
"restoreFailureSchema": "Le fichier n'est pas une sauvegarde HealthLog valide.",
|
||||
"restoreFailureVersion": "La sauvegarde a été écrite par une version plus récente de HealthLog. Mets d'abord ce serveur à jour.",
|
||||
"restoreFailureOwnerMismatch": "Le fichier de la sauvegarde appartient à un autre compte que la ligne de sauvegarde.",
|
||||
"restoreFailureOwnerMissing": "Le compte auquel appartient cette sauvegarde n'existe plus.",
|
||||
"restoreFailureSectionMissing": "Le fichier est incomplet : son manifeste nomme une section qu'il ne contient pas.",
|
||||
"restoreFailureDocument": "Le fichier contient des documents sans leur contenu, comme un export portable. Utilise une sauvegarde de reprise après sinistre.",
|
||||
"restoreFailureTimeBudget": "Cette sauvegarde est trop volumineuse pour être restaurée dans le temps imparti au processus en arrière-plan.",
|
||||
"restoreFailureTransaction": "La base de données a refusé la restauration en cours de route, tout a donc été annulé. Le journal du serveur indique l'erreur.",
|
||||
"restoreFailureInterrupted": "La restauration s'est arrêtée au redémarrage du serveur et a été annulée. Si elle s'arrête encore, le serveur manque probablement de mémoire.",
|
||||
"restoreFailureNotStarted": "Le processus en arrière-plan n'a pas pris la restauration en charge. Vérifie qu'il tourne.",
|
||||
"restoreFailureEnqueue": "La restauration n'a pas pu être transmise au processus en arrière-plan. Vérifie qu'il tourne.",
|
||||
"restoreFailureAfterCommit": "Les données ont été restaurées, mais une étape suivante a échoué. Les graphiques ne seront peut-être à jour qu'après la prochaine exécution nocturne.",
|
||||
"restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "léger",
|
||||
"flowMedium": "moyen",
|
||||
"flowHeavy": "abondant"
|
||||
"flowHeavy": "abondant",
|
||||
"legendIntercourse": "Rapports",
|
||||
"legendOtherEntries": "Autres saisies"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Ce qui arrive ensuite",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} éléments issus de {groups} domaines",
|
||||
"scopeSensitive": "dont {names}",
|
||||
"leafVisits": "Consultations",
|
||||
"leafSurgicalHistory": "Antécédents chirurgicaux",
|
||||
"leafImmunizations": "Vaccinations",
|
||||
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Urgence",
|
||||
"hospital": "Hôpital",
|
||||
"therapy": "Séance de thérapie",
|
||||
"other": "Rendez-vous médical"
|
||||
"other": "Rendez-vous médical",
|
||||
"procedure": "Intervention ou chirurgie"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Gauche",
|
||||
"right": "Droite",
|
||||
"both": "Des deux côtés",
|
||||
"none": "Non précisé"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Rendez-vous",
|
||||
"sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
|
||||
"add": "Rendez-vous",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Motif",
|
||||
"outcome": "Résultat",
|
||||
"outcomeReveal": "Ajouter un résultat",
|
||||
"bodySite": "Partie du corps",
|
||||
"bodySiteHint": "Par exemple genou ou vésicule biliaire.",
|
||||
"laterality": "Côté",
|
||||
"linkDocuments": "Documents",
|
||||
"linkLabResults": "Résultats de laboratoire",
|
||||
"linkConditions": "Maladies",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Prochain rendez-vous : {what} le {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Interventions et chirurgies",
|
||||
"description": "Toutes les interventions et chirurgies que vous avez eues, les plus récentes d’abord.",
|
||||
"add": "Intervention",
|
||||
"searchLabel": "Filtrer par partie du corps",
|
||||
"searchPlaceholder": "Par exemple genou",
|
||||
"sitesLabel": "Parties du corps",
|
||||
"allSites": "Toutes",
|
||||
"count": "{shown} sur {total}",
|
||||
"emptyTitle": "Aucune intervention enregistrée",
|
||||
"emptyDescription": "Enregistrez un rendez-vous du type Intervention ou chirurgie, ou passez un rendez-vous existant à ce type.",
|
||||
"noMatchTitle": "Rien ne correspond à ce filtre",
|
||||
"clearFilter": "Effacer le filtre",
|
||||
"loadError": "Les interventions n’ont pas pu être chargées."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Afficher les dépistages ou les rendez-vous",
|
||||
"viewSwitchLabel": "Afficher les dépistages, les rendez-vous ou les interventions",
|
||||
"tabVorsorge": "Dépistages",
|
||||
"tabVisits": "Rendez-vous"
|
||||
"tabVisits": "Rendez-vous",
|
||||
"tabProcedures": "Interventions"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Cabinets et médecins",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Tout sélectionner",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
"searchNoMatch": "Aucun résultat",
|
||||
"remove": "Retirer",
|
||||
"suggested": "Autour de cette date",
|
||||
"loadError": "Impossible de charger la liste."
|
||||
"loadError": "Impossible de charger la liste.",
|
||||
"removeNamed": "Retirer {name}",
|
||||
"removed": "{name} dissocié"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -293,6 +293,11 @@
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Esito",
|
||||
"visitsColConditions": "Malattie",
|
||||
"surgicalHistoryTitle": "Anamnesi chirurgica",
|
||||
"surgicalHistoryColDate": "Data",
|
||||
"surgicalHistoryColProcedure": "Intervento",
|
||||
"surgicalHistoryColBodySite": "Parte del corpo",
|
||||
"surgicalHistoryColOutcome": "Esito",
|
||||
"immunizationsTitle": "Vaccinazioni",
|
||||
"immunizationsColDate": "Data",
|
||||
"immunizationsColVaccine": "Vaccino",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Ieri",
|
||||
"tomorrow": "Domani",
|
||||
"asNeededMarker": "Al bisogno",
|
||||
"recordOnlyBadge": "Solo annotato",
|
||||
"trackIntake": {
|
||||
"title": "Registrazione assunzioni",
|
||||
"label": "Registra le assunzioni",
|
||||
"helperOn": "Le dosi sono dovute secondo questo programma, con promemoria e aderenza.",
|
||||
"helperOff": "Solo annotato: nulla è dovuto, niente promemoria, niente aderenza.",
|
||||
"enabledToast": "Registrazione assunzioni attiva",
|
||||
"disabledToast": "Registrazione assunzioni disattivata",
|
||||
"toggleFailed": "Impossibile modificare la registrazione delle assunzioni."
|
||||
},
|
||||
"withoutNotification": "Senza notifica",
|
||||
"pausedSince": "In pausa dal",
|
||||
"takeNow": "Assumi ora",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Attribuzione rimossa",
|
||||
"dueContext": "prevista alle {time} ({delta})",
|
||||
"confirmTitle": "Attribuire allo slot delle {time}?",
|
||||
"confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
|
||||
"confirmBody": "L'assunzione conterà per questo slot esattamente come se fosse stata registrata lì: puntuale o in ritardo a seconda di quando è stata presa, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
|
||||
"confirmCancel": "Annulla",
|
||||
"confirmAction": "Attribuisci"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Prossima dose",
|
||||
"nextDoseNone": "Nessuna dose in programma.",
|
||||
"nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.",
|
||||
"nextDoseNotTracked": "Assunzioni non registrate, solo annotato.",
|
||||
"pausedHint": "In pausa — nessun promemoria.",
|
||||
"reminderLabel": "Promemoria",
|
||||
"reminderOn": "Attivo",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Modifica",
|
||||
"packagingDescription": "Imposta quante unità consuma una dose e la dimensione abituale di una nuova confezione, ad es. dopo un cambio di produttore con blister diverso.",
|
||||
"packagingUnitsPerDoseLabel": "Unità per dose",
|
||||
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg (1–100). Le assunzioni già registrate restano invariate.",
|
||||
"packagingUnitsPerDoseHelper": "Ad es. due compresse da 2 mg per una dose da 4 mg, oppure 1,5 per una e mezza (oltre 0, fino a 100). Le assunzioni già registrate restano invariate.",
|
||||
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
|
||||
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
|
||||
"packagingSuccess": "Dati confezione salvati",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "il file non registra nulla su quella dose: nessuno ha risposto e niente è stato annotato",
|
||||
"reasonStatusReminderEvent": "la riga riguarda un promemoria postposto, che non dice nulla sulla dose",
|
||||
"reasonStatusNotificationNotSent": "la riga registra che non è stata inviata alcuna notifica, il che riguarda l'app e non la dose"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Altro…",
|
||||
"inputLabel": "Unità per dose, altro valore",
|
||||
"placeholder": "ad es. 1,5 o 1 1/2",
|
||||
"invalid": "Inserisci un numero maggiore di 0 e fino a 100, ad esempio 1,5 o 1 1/2.",
|
||||
"preview": "Conta come {value} per dose"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} è stato aggiunto.",
|
||||
"doneUnnamed": "Il tuo primo farmaco è stato aggiunto.",
|
||||
"nextDose": "Prossima dose: {when}.",
|
||||
"reminders": "I promemoria seguono l'orario che hai impostato."
|
||||
"reminders": "I promemoria seguono l'orario che hai impostato.",
|
||||
"recordOnly": "Solo annotato: niente promemoria, nulla è dovuto.",
|
||||
"asNeeded": "Al bisogno: niente promemoria, registra una dose quando la prendi."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Inserisci una misurazione: {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Carica backup",
|
||||
"uploadDescription": "Convalida un file di backup e archivialo come istantanea manuale. Il ripristino è un passaggio separato.",
|
||||
"uploadButton": "Carica file",
|
||||
"uploadHelp": "File JSON conforme allo schema di backup attuale. Max 10 MB.",
|
||||
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
|
||||
"uploadSuccess": "Backup caricato ({count} record)",
|
||||
"uploadFailed": "Caricamento non riuscito",
|
||||
"restore": "Ripristina",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "Nessun giro notturno ha ancora registrato questo account.",
|
||||
"age": "{hours} h fa",
|
||||
"scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Ripristino avviato. L'avanzamento compare sopra l'elenco.",
|
||||
"restoreActive": "Per questo account è già in corso un ripristino. Aspetta che finisca.",
|
||||
"restoreJobQueuedTitle": "Il ripristino di {username} è in attesa di partire",
|
||||
"restoreJobQueuedNote": "Parte appena il processo in background lo prende in carico.",
|
||||
"restoreJobRunningTitle": "Ripristino di {username} in corso",
|
||||
"restoreJobRunningNote": "L'account continua a mostrare i dati attuali finché il ripristino non termina. Puoi lasciare questa pagina: il ripristino prosegue sul server.",
|
||||
"restoreJobResumed": "Riavviato dopo il riavvio del server.",
|
||||
"restoreJobPhaseValidating": "Controllo del file di backup: {checked} misurazioni controllate",
|
||||
"restoreJobPhaseClearing": "Rimozione dei dati attuali dell'account",
|
||||
"restoreJobPhaseMeasurements": "Scrittura delle misurazioni: {done} di {total}",
|
||||
"restoreJobPhaseSections": "Scrittura degli altri dati: {done} di {total} sezioni",
|
||||
"restoreJobPhaseRebuilding": "Ricalcolo di grafici e riepiloghi",
|
||||
"restoreJobSucceededTitle": "Ripristino di {username} completato",
|
||||
"restoreJobSucceededDescription": "{count} record ripristinati. Terminato {when}.",
|
||||
"restoreJobFailedTitle": "Il ripristino di {username} non è stato completato",
|
||||
"restoreJobFailedKept": "Non è stato modificato nulla. Il backup resta disponibile, quindi puoi riavviare il ripristino dalla sua riga senza caricarlo di nuovo.",
|
||||
"restoreFailureNotFound": "Il backup è stato eliminato prima che il ripristino potesse leggerlo.",
|
||||
"restoreFailureChanged": "Il backup è stato sostituito da una copia più recente mentre il ripristino aspettava. Controlla la nuova copia, poi riavvia il ripristino.",
|
||||
"restoreFailureUndecryptable": "Non è stato possibile decifrare il backup. La chiave con cui è stato scritto non è più configurata, oppure la copia salvata è stata modificata.",
|
||||
"restoreFailureSchema": "Il file non è un backup HealthLog valido.",
|
||||
"restoreFailureVersion": "Il backup è stato scritto da una versione più recente di HealthLog. Aggiorna prima questo server.",
|
||||
"restoreFailureOwnerMismatch": "Il file nel backup appartiene a un account diverso da quello della riga di backup.",
|
||||
"restoreFailureOwnerMissing": "L'account a cui appartiene questo backup non esiste più.",
|
||||
"restoreFailureSectionMissing": "Il file è incompleto: il suo manifesto nomina una sezione che non contiene.",
|
||||
"restoreFailureDocument": "Il file contiene documenti senza il loro contenuto, come un'esportazione portabile. Usa un backup di disaster recovery.",
|
||||
"restoreFailureTimeBudget": "Questo backup è troppo grande per essere ripristinato entro il tempo concesso al processo in background.",
|
||||
"restoreFailureTransaction": "Il database ha rifiutato il ripristino a metà, quindi tutto è stato annullato. Il log del server indica l'errore.",
|
||||
"restoreFailureInterrupted": "Il ripristino si è interrotto al riavvio del server ed è stato annullato. Se si interrompe di nuovo, probabilmente al server manca memoria.",
|
||||
"restoreFailureNotStarted": "Il processo in background non ha preso in carico il ripristino. Controlla che sia in esecuzione.",
|
||||
"restoreFailureEnqueue": "Non è stato possibile passare il ripristino al processo in background. Controlla che sia in esecuzione.",
|
||||
"restoreFailureAfterCommit": "I dati sono stati ripristinati, ma un passaggio successivo non è riuscito. I grafici potrebbero aggiornarsi solo con la prossima esecuzione notturna.",
|
||||
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "leggero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abbondante"
|
||||
"flowHeavy": "abbondante",
|
||||
"legendIntercourse": "Rapporti",
|
||||
"legendOtherEntries": "Altre voci"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Cosa arriva dopo",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} voci da {groups} aree",
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visite mediche",
|
||||
"leafSurgicalHistory": "Anamnesi chirurgica",
|
||||
"leafImmunizations": "Vaccinazioni",
|
||||
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Emergenza",
|
||||
"hospital": "Ospedale",
|
||||
"therapy": "Seduta di terapia",
|
||||
"other": "Appuntamento medico"
|
||||
"other": "Appuntamento medico",
|
||||
"procedure": "Procedura o intervento chirurgico"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Sinistra",
|
||||
"right": "Destra",
|
||||
"both": "Entrambi i lati",
|
||||
"none": "Non indicato"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Appuntamenti",
|
||||
"sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
|
||||
"add": "Appuntamento",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Motivo",
|
||||
"outcome": "Esito",
|
||||
"outcomeReveal": "Aggiungi un esito",
|
||||
"bodySite": "Parte del corpo",
|
||||
"bodySiteHint": "Per esempio ginocchio o cistifellea.",
|
||||
"laterality": "Lato",
|
||||
"linkDocuments": "Documenti",
|
||||
"linkLabResults": "Esami di laboratorio",
|
||||
"linkConditions": "Malattie",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Prossimo appuntamento: {what} il {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Procedure e interventi chirurgici",
|
||||
"description": "Tutte le procedure e gli interventi che hai avuto, dal più recente.",
|
||||
"add": "Intervento",
|
||||
"searchLabel": "Filtra per parte del corpo",
|
||||
"searchPlaceholder": "Per esempio ginocchio",
|
||||
"sitesLabel": "Parti del corpo",
|
||||
"allSites": "Tutte",
|
||||
"count": "{shown} di {total}",
|
||||
"emptyTitle": "Nessun intervento registrato",
|
||||
"emptyDescription": "Registra un appuntamento del tipo Procedura o intervento chirurgico, oppure cambia un appuntamento esistente in questo tipo.",
|
||||
"noMatchTitle": "Nessun risultato per questo filtro",
|
||||
"clearFilter": "Rimuovi filtro",
|
||||
"loadError": "Non è stato possibile caricare gli interventi."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostra controlli o appuntamenti",
|
||||
"viewSwitchLabel": "Mostra controlli, appuntamenti o interventi",
|
||||
"tabVorsorge": "Controlli",
|
||||
"tabVisits": "Appuntamenti"
|
||||
"tabVisits": "Appuntamenti",
|
||||
"tabProcedures": "Interventi"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Studi e medici",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Seleziona tutto",
|
||||
"searchPlaceholder": "Cerca",
|
||||
"searchNoMatch": "Nessun risultato",
|
||||
"remove": "Rimuovi",
|
||||
"suggested": "Intorno a questa data",
|
||||
"loadError": "Impossibile caricare l’elenco."
|
||||
"loadError": "Impossibile caricare l’elenco.",
|
||||
"removeNamed": "Rimuovi {name}",
|
||||
"removed": "Collegamento a {name} rimosso"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -329,6 +329,11 @@
|
||||
"visitsColReason": "사유",
|
||||
"visitsColOutcome": "결과",
|
||||
"visitsColConditions": "질환",
|
||||
"surgicalHistoryTitle": "수술 이력",
|
||||
"surgicalHistoryColDate": "날짜",
|
||||
"surgicalHistoryColProcedure": "시술",
|
||||
"surgicalHistoryColBodySite": "신체 부위",
|
||||
"surgicalHistoryColOutcome": "결과",
|
||||
"immunizationsTitle": "예방접종",
|
||||
"immunizationsColDate": "날짜",
|
||||
"immunizationsColVaccine": "백신",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "어제",
|
||||
"tomorrow": "내일",
|
||||
"asNeededMarker": "필요시",
|
||||
"recordOnlyBadge": "기록만",
|
||||
"trackIntake": {
|
||||
"title": "복용 기록",
|
||||
"label": "복용 기록하기",
|
||||
"helperOn": "이 일정에 따라 복용 시각이 되고, 알림과 복약 순응도가 적용돼요.",
|
||||
"helperOff": "기록으로만 남겨요: 예정된 복용, 알림, 복약 순응도가 없어요.",
|
||||
"enabledToast": "복용 기록 켜짐",
|
||||
"disabledToast": "복용 기록 꺼짐",
|
||||
"toggleFailed": "복용 기록 설정을 바꾸지 못했어요."
|
||||
},
|
||||
"withoutNotification": "알림 없음",
|
||||
"pausedSince": "일시중지 시작",
|
||||
"takeNow": "지금 복용",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "배정을 해제했어요",
|
||||
"dueContext": "예정 시각: {time} ({delta})",
|
||||
"confirmTitle": "{time} 시간대에 배정할까요?",
|
||||
"confirmBody": "이 복용은 해당 시간대에 늦게 복용한 것으로 계산돼요 – 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
|
||||
"confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
|
||||
"confirmCancel": "취소",
|
||||
"confirmAction": "배정"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "다음 복용",
|
||||
"nextDoseNone": "예정된 복용이 없어요.",
|
||||
"nextDoseAsNeeded": "필요시 복용 — 예정된 복용이 없어요.",
|
||||
"nextDoseNotTracked": "복용을 기록하지 않아요. 기록으로만 남겨요.",
|
||||
"pausedHint": "일시중지 — 알림이 오지 않아요.",
|
||||
"reminderLabel": "알림",
|
||||
"reminderOn": "켜짐",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "편집",
|
||||
"packagingDescription": "1회분에 몇 개를 쓰는지, 새 포장이 보통 몇 개인지 설정해요 — 예를 들어 제조사가 바뀌어 포장 크기가 달라졌을 때요.",
|
||||
"packagingUnitsPerDoseLabel": "1회분당 개수",
|
||||
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개 (1–100). 이미 기록된 복용은 그대로예요.",
|
||||
"packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개, 한 알 반이면 1.5 (0 초과, 100 이하). 이미 기록된 복용은 그대로예요.",
|
||||
"packagingDefaultPackLabel": "기본 포장 크기 (개)",
|
||||
"packagingDefaultPackHelper": "새 포장을 등록할 때 수량을 미리 채워요 (1–1000). 해당 없으면 비워 두세요.",
|
||||
"packagingSuccess": "포장 정보를 저장했어요",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "파일에 그 복용에 대한 기록이 없어요 — 응답도 없었고 기록된 것도 없어요",
|
||||
"reasonStatusReminderEvent": "행이 미뤄 둔 알림에 관한 내용이라 복용에 대해 알려주는 게 없어요",
|
||||
"reasonStatusNotificationNotSent": "행은 알림이 발송되지 않았다는 기록이라 복용이 아니라 앱에 관한 내용이에요"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "기타…",
|
||||
"inputLabel": "1회분 단위 수, 다른 값",
|
||||
"placeholder": "예: 1.5 또는 1 1/2",
|
||||
"invalid": "0보다 크고 100 이하인 숫자를 입력하세요. 예: 1.5 또는 1 1/2",
|
||||
"preview": "1회분당 {value}(으)로 계산돼요"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name}이(가) 추가되었습니다.",
|
||||
"doneUnnamed": "첫 약이 추가되었습니다.",
|
||||
"nextDose": "다음 복용: {when}.",
|
||||
"reminders": "알림은 설정한 일정을 따릅니다."
|
||||
"reminders": "알림은 설정한 일정을 따릅니다.",
|
||||
"recordOnly": "기록으로만 남겨요: 알림도, 예정된 복용도 없어요.",
|
||||
"asNeeded": "필요시: 알림 없이, 복용할 때 기록하세요."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "측정값 하나 입력: {target}",
|
||||
@@ -7148,7 +7173,7 @@
|
||||
"uploadTitle": "백업 업로드",
|
||||
"uploadDescription": "백업 파일을 검사한 뒤 수동 스냅샷으로 저장해요. 복원은 별도 단계예요.",
|
||||
"uploadButton": "파일 업로드",
|
||||
"uploadHelp": "현재 백업 스키마에 맞는 JSON 파일이에요. 최대 10 MB.",
|
||||
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
|
||||
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
|
||||
"uploadFailed": "업로드하지 못했어요",
|
||||
"restore": "복원",
|
||||
@@ -7223,7 +7248,39 @@
|
||||
"unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.",
|
||||
"age": "{hours}시간 전",
|
||||
"scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "복원을 시작했어요. 진행 상황은 목록 위에 표시돼요.",
|
||||
"restoreActive": "이 계정의 복원이 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
|
||||
"restoreJobQueuedTitle": "{username} 복원이 시작을 기다리고 있어요",
|
||||
"restoreJobQueuedNote": "백그라운드 작업자가 가져가는 즉시 시작돼요.",
|
||||
"restoreJobRunningTitle": "{username} 복원 중",
|
||||
"restoreJobRunningNote": "복원이 끝날 때까지 계정에는 기존 데이터가 그대로 보여요. 이 페이지를 떠나도 복원은 서버에서 계속돼요.",
|
||||
"restoreJobResumed": "서버가 다시 시작된 뒤 복원을 다시 시작했어요.",
|
||||
"restoreJobPhaseValidating": "백업 파일 확인 중: 측정값 {checked}개 확인",
|
||||
"restoreJobPhaseClearing": "계정의 기존 데이터를 지우는 중",
|
||||
"restoreJobPhaseMeasurements": "측정값 기록 중: {total}개 중 {done}개",
|
||||
"restoreJobPhaseSections": "나머지 기록 작성 중: {total}개 항목 중 {done}개",
|
||||
"restoreJobPhaseRebuilding": "차트와 요약을 다시 계산하는 중",
|
||||
"restoreJobSucceededTitle": "{username} 복원이 끝났어요",
|
||||
"restoreJobSucceededDescription": "기록 {count}개를 복원했어요. 완료: {when}.",
|
||||
"restoreJobFailedTitle": "{username} 복원이 완료되지 않았어요",
|
||||
"restoreJobFailedKept": "아무것도 바뀌지 않았어요. 백업은 그대로 남아 있으니 다시 업로드하지 않고 해당 행에서 복원을 다시 시작할 수 있어요.",
|
||||
"restoreFailureNotFound": "복원이 읽기 전에 백업이 삭제됐어요.",
|
||||
"restoreFailureChanged": "복원이 기다리는 동안 백업이 더 최신 사본으로 바뀌었어요. 새 사본을 확인한 뒤 복원을 다시 시작해 주세요.",
|
||||
"restoreFailureUndecryptable": "백업을 복호화할 수 없어요. 백업을 쓴 키가 더 이상 설정되어 있지 않거나, 저장된 사본이 바뀌었어요.",
|
||||
"restoreFailureSchema": "올바른 HealthLog 백업 파일이 아니에요.",
|
||||
"restoreFailureVersion": "더 새로운 HealthLog 버전이 쓴 백업이에요. 먼저 이 서버를 업데이트해 주세요.",
|
||||
"restoreFailureOwnerMismatch": "백업 안의 파일이 백업 행과 다른 계정의 것이에요.",
|
||||
"restoreFailureOwnerMissing": "이 백업이 속한 계정이 더 이상 없어요.",
|
||||
"restoreFailureSectionMissing": "파일이 불완전해요. 매니페스트에 적힌 항목이 파일에 없어요.",
|
||||
"restoreFailureDocument": "휴대용 내보내기처럼 내용 없이 문서만 담긴 파일이에요. 재해 복구용 백업을 사용해 주세요.",
|
||||
"restoreFailureTimeBudget": "이 백업은 너무 커서 백그라운드 작업의 시간 제한 안에 복원할 수 없어요.",
|
||||
"restoreFailureTransaction": "데이터베이스가 복원 도중에 거부해서 모든 변경을 되돌렸어요. 서버 로그에 오류가 나와 있어요.",
|
||||
"restoreFailureInterrupted": "서버가 다시 시작되면서 복원이 멈췄고 되돌려졌어요. 또 멈춘다면 서버 메모리가 부족할 가능성이 커요.",
|
||||
"restoreFailureNotStarted": "백그라운드 작업자가 복원을 가져가지 않았어요. 작업자가 실행 중인지 확인해 주세요.",
|
||||
"restoreFailureEnqueue": "복원을 백그라운드 작업자에게 넘기지 못했어요. 작업자가 실행 중인지 확인해 주세요.",
|
||||
"restoreFailureAfterCommit": "데이터는 복원됐지만 그다음 단계가 실패했어요. 차트는 다음 야간 실행 때 따라잡을 수 있어요.",
|
||||
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "점상 출혈",
|
||||
"flowLight": "적음",
|
||||
"flowMedium": "보통",
|
||||
"flowHeavy": "많음"
|
||||
"flowHeavy": "많음",
|
||||
"legendIntercourse": "성관계",
|
||||
"legendOtherEntries": "기타 기록"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "다음에 올 일",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{groups}개 영역에서 {count}개 항목",
|
||||
"scopeSensitive": "{names} 포함",
|
||||
"leafVisits": "진료",
|
||||
"leafSurgicalHistory": "수술 이력",
|
||||
"leafImmunizations": "예방접종",
|
||||
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "응급",
|
||||
"hospital": "병원",
|
||||
"therapy": "치료 세션",
|
||||
"other": "예약"
|
||||
"other": "예약",
|
||||
"procedure": "시술 또는 수술"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "왼쪽",
|
||||
"right": "오른쪽",
|
||||
"both": "양쪽",
|
||||
"none": "지정 안 함"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "진료",
|
||||
"sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.",
|
||||
"add": "진료",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "사유",
|
||||
"outcome": "결과",
|
||||
"outcomeReveal": "결과 추가",
|
||||
"bodySite": "신체 부위",
|
||||
"bodySiteHint": "예: 무릎, 담낭",
|
||||
"laterality": "방향",
|
||||
"linkDocuments": "문서",
|
||||
"linkLabResults": "검사 결과",
|
||||
"linkConditions": "질환",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "다음 예약: {what} ({when})"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "시술 및 수술",
|
||||
"description": "지금까지 받은 모든 시술과 수술을 최신순으로 보여 줍니다.",
|
||||
"add": "시술",
|
||||
"searchLabel": "신체 부위로 필터",
|
||||
"searchPlaceholder": "예: 무릎",
|
||||
"sitesLabel": "신체 부위",
|
||||
"allSites": "전체",
|
||||
"count": "{total}개 중 {shown}개",
|
||||
"emptyTitle": "아직 기록된 시술이 없습니다",
|
||||
"emptyDescription": "종류를 '시술 또는 수술'로 진료를 기록하거나, 기존 진료의 종류를 바꾸세요.",
|
||||
"noMatchTitle": "이 필터에 맞는 항목이 없습니다",
|
||||
"clearFilter": "필터 지우기",
|
||||
"loadError": "시술 목록을 불러올 수 없습니다."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "건강검진 또는 진료 보기",
|
||||
"viewSwitchLabel": "건강검진, 진료 또는 시술 보기",
|
||||
"tabVorsorge": "건강검진",
|
||||
"tabVisits": "진료"
|
||||
"tabVisits": "진료",
|
||||
"tabProcedures": "시술·수술"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "병원과 의사",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "전체 선택",
|
||||
"searchPlaceholder": "검색",
|
||||
"searchNoMatch": "결과 없음",
|
||||
"remove": "제거",
|
||||
"suggested": "이 날짜 전후",
|
||||
"loadError": "목록을 불러오지 못했습니다."
|
||||
"loadError": "목록을 불러오지 못했습니다.",
|
||||
"removeNamed": "{name} 제거",
|
||||
"removed": "{name} 연결 해제됨"
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-11
@@ -293,6 +293,11 @@
|
||||
"visitsColReason": "Powód",
|
||||
"visitsColOutcome": "Wynik",
|
||||
"visitsColConditions": "Choroby",
|
||||
"surgicalHistoryTitle": "Historia operacji",
|
||||
"surgicalHistoryColDate": "Data",
|
||||
"surgicalHistoryColProcedure": "Zabieg",
|
||||
"surgicalHistoryColBodySite": "Miejsce na ciele",
|
||||
"surgicalHistoryColOutcome": "Wynik",
|
||||
"immunizationsTitle": "Szczepienia",
|
||||
"immunizationsColDate": "Data",
|
||||
"immunizationsColVaccine": "Szczepionka",
|
||||
@@ -1201,6 +1206,16 @@
|
||||
"yesterday": "Wczoraj",
|
||||
"tomorrow": "Jutro",
|
||||
"asNeededMarker": "W razie potrzeby",
|
||||
"recordOnlyBadge": "Tylko zapis",
|
||||
"trackIntake": {
|
||||
"title": "Rejestrowanie przyjęć",
|
||||
"label": "Rejestruj przyjęcia",
|
||||
"helperOn": "Dawki przypadają według tego harmonogramu, z przypomnieniami i przestrzeganiem.",
|
||||
"helperOff": "Tylko zapis: nic nie przypada, bez przypomnień i bez przestrzegania.",
|
||||
"enabledToast": "Rejestrowanie przyjęć włączone",
|
||||
"disabledToast": "Rejestrowanie przyjęć wyłączone",
|
||||
"toggleFailed": "Nie udało się zmienić rejestrowania przyjęć."
|
||||
},
|
||||
"withoutNotification": "Bez powiadomienia",
|
||||
"pausedSince": "Wstrzymane od",
|
||||
"takeNow": "Przyjmij teraz",
|
||||
@@ -1796,7 +1811,7 @@
|
||||
"unpinnedToast": "Przypisanie usunięte",
|
||||
"dueContext": "termin: {time} ({delta})",
|
||||
"confirmTitle": "Przypisać do slotu {time}?",
|
||||
"confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
|
||||
"confirmBody": "Dawka zostanie policzona dla tego slotu tak, jakby została w nim zapisana: jako przyjęta na czas lub z opóźnieniem, zależnie od tego, kiedy ją przyjęto, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
|
||||
"confirmCancel": "Anuluj",
|
||||
"confirmAction": "Przypisz"
|
||||
}
|
||||
@@ -1927,6 +1942,7 @@
|
||||
"nextDoseLabel": "Następna dawka",
|
||||
"nextDoseNone": "Brak nadchodzącej dawki.",
|
||||
"nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.",
|
||||
"nextDoseNotTracked": "Przyjęcia nie są rejestrowane, tylko zapis.",
|
||||
"pausedHint": "Wstrzymano — brak przypomnień.",
|
||||
"reminderLabel": "Przypomnienie",
|
||||
"reminderOn": "Włączone",
|
||||
@@ -2000,7 +2016,7 @@
|
||||
"packagingEdit": "Edytuj",
|
||||
"packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.",
|
||||
"packagingUnitsPerDoseLabel": "Jednostki na dawkę",
|
||||
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg (1–100). Zarejestrowane dawki pozostają bez zmian.",
|
||||
"packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg lub 1,5 dla półtorej (powyżej 0, do 100). Zarejestrowane dawki pozostają bez zmian.",
|
||||
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
|
||||
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
|
||||
"packagingSuccess": "Zapisano dane opakowania",
|
||||
@@ -2198,6 +2214,13 @@
|
||||
"reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane",
|
||||
"reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce",
|
||||
"reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki"
|
||||
},
|
||||
"unitsPerDoseField": {
|
||||
"other": "Inna…",
|
||||
"inputLabel": "Jednostki na dawkę, inna wartość",
|
||||
"placeholder": "np. 1,5 lub 1 1/2",
|
||||
"invalid": "Wpisz liczbę większą od 0 i nie większą niż 100, na przykład 1,5 lub 1 1/2.",
|
||||
"preview": "Liczy się jako {value} na dawkę"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -4760,7 +4783,9 @@
|
||||
"done": "{name} został dodany.",
|
||||
"doneUnnamed": "Twój pierwszy lek został dodany.",
|
||||
"nextDose": "Następna dawka: {when}.",
|
||||
"reminders": "Przypomnienia podążają za ustawionym harmonogramem."
|
||||
"reminders": "Przypomnienia podążają za ustawionym harmonogramem.",
|
||||
"recordOnly": "Tylko zapis: bez przypomnień, nic nie przypada.",
|
||||
"asNeeded": "W razie potrzeby: bez przypomnień, zapisz dawkę, gdy ją przyjmiesz."
|
||||
},
|
||||
"log-reading": {
|
||||
"title": "Wpisz jeden pomiar: {target}",
|
||||
@@ -7169,7 +7194,7 @@
|
||||
"uploadTitle": "Prześlij kopię zapasową",
|
||||
"uploadDescription": "Sprawdź plik kopii zapasowej i zapisz go jako ręczną migawkę. Przywracanie to osobny krok.",
|
||||
"uploadButton": "Prześlij plik",
|
||||
"uploadHelp": "Plik JSON zgodny z bieżącym schematem kopii zapasowej. Maks. 10 MB.",
|
||||
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
|
||||
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
|
||||
"uploadFailed": "Przesyłanie nie powiodło się",
|
||||
"restore": "Przywróć",
|
||||
@@ -7244,7 +7269,39 @@
|
||||
"unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.",
|
||||
"age": "{hours} h temu",
|
||||
"scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa."
|
||||
}
|
||||
},
|
||||
"restoreStarted": "Przywracanie rozpoczęte. Postęp widać nad listą.",
|
||||
"restoreActive": "Dla tego konta trwa już przywracanie. Poczekaj, aż się skończy.",
|
||||
"restoreJobQueuedTitle": "Przywracanie {username} czeka na start",
|
||||
"restoreJobQueuedNote": "Zacznie się, gdy tylko proces w tle je przejmie.",
|
||||
"restoreJobRunningTitle": "Przywracanie {username}",
|
||||
"restoreJobRunningNote": "Konto pokazuje dotychczasowe dane, dopóki przywracanie się nie zakończy. Możesz opuścić tę stronę; przywracanie trwa dalej na serwerze.",
|
||||
"restoreJobResumed": "Uruchomione ponownie po restarcie serwera.",
|
||||
"restoreJobPhaseValidating": "Sprawdzanie pliku kopii: sprawdzono {checked} pomiarów",
|
||||
"restoreJobPhaseClearing": "Usuwanie bieżących danych konta",
|
||||
"restoreJobPhaseMeasurements": "Zapisywanie pomiarów: {done} z {total}",
|
||||
"restoreJobPhaseSections": "Zapisywanie pozostałych wpisów: {done} z {total} sekcji",
|
||||
"restoreJobPhaseRebuilding": "Przeliczanie wykresów i podsumowań",
|
||||
"restoreJobSucceededTitle": "Przywracanie {username} zakończone",
|
||||
"restoreJobSucceededDescription": "Przywrócono {count} wpisów. Zakończono {when}.",
|
||||
"restoreJobFailedTitle": "Przywracanie {username} nie zostało ukończone",
|
||||
"restoreJobFailedKept": "Nic nie zostało zmienione. Kopia zostaje, więc możesz ponownie uruchomić przywracanie z jej wiersza bez ponownego wgrywania.",
|
||||
"restoreFailureNotFound": "Kopia została usunięta, zanim przywracanie zdążyło ją odczytać.",
|
||||
"restoreFailureChanged": "Kopia została zastąpiona nowszą, gdy przywracanie czekało. Sprawdź nową kopię, a potem uruchom przywracanie ponownie.",
|
||||
"restoreFailureUndecryptable": "Nie udało się odszyfrować kopii. Klucz, którym ją zapisano, nie jest już skonfigurowany albo zapisana kopia została zmieniona.",
|
||||
"restoreFailureSchema": "Plik nie jest prawidłową kopią HealthLog.",
|
||||
"restoreFailureVersion": "Kopię zapisała nowsza wersja HealthLog. Najpierw zaktualizuj ten serwer.",
|
||||
"restoreFailureOwnerMismatch": "Plik w kopii należy do innego konta niż wiersz kopii.",
|
||||
"restoreFailureOwnerMissing": "Konto, do którego należy ta kopia, już nie istnieje.",
|
||||
"restoreFailureSectionMissing": "Plik jest niekompletny: jego manifest wymienia sekcję, której nie zawiera.",
|
||||
"restoreFailureDocument": "Plik zawiera dokumenty bez ich treści, jak eksport przenośny. Użyj kopii do odtwarzania po awarii.",
|
||||
"restoreFailureTimeBudget": "Ta kopia jest za duża, by przywrócić ją w limicie czasu procesu w tle.",
|
||||
"restoreFailureTransaction": "Baza danych odrzuciła przywracanie w trakcie, więc wszystko wycofano. Dziennik serwera podaje błąd.",
|
||||
"restoreFailureInterrupted": "Przywracanie zatrzymało się przy restarcie serwera i zostało wycofane. Jeśli zatrzyma się znowu, serwerowi prawdopodobnie brakuje pamięci.",
|
||||
"restoreFailureNotStarted": "Proces w tle nie przejął przywracania. Sprawdź, czy działa.",
|
||||
"restoreFailureEnqueue": "Nie udało się przekazać przywracania do procesu w tle. Sprawdź, czy działa.",
|
||||
"restoreFailureAfterCommit": "Dane zostały przywrócone, ale krok po nich się nie powiódł. Wykresy mogą nadrobić to dopiero przy następnym nocnym przebiegu.",
|
||||
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd."
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
@@ -8233,7 +8290,9 @@
|
||||
"flowSpotting": "plamienie",
|
||||
"flowLight": "lekkie",
|
||||
"flowMedium": "średnie",
|
||||
"flowHeavy": "obfite"
|
||||
"flowHeavy": "obfite",
|
||||
"legendIntercourse": "Współżycie",
|
||||
"legendOtherEntries": "Inne wpisy"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Co dalej",
|
||||
@@ -10393,6 +10452,7 @@
|
||||
"scopeCount": "{count} pozycji z {groups} obszarów",
|
||||
"scopeSensitive": "w tym {names}",
|
||||
"leafVisits": "Wizyty lekarskie",
|
||||
"leafSurgicalHistory": "Historia operacji",
|
||||
"leafImmunizations": "Szczepienia",
|
||||
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)"
|
||||
},
|
||||
@@ -10777,8 +10837,16 @@
|
||||
"emergency": "Nagły przypadek",
|
||||
"hospital": "Szpital",
|
||||
"therapy": "Sesja terapeutyczna",
|
||||
"other": "Wizyta lekarska"
|
||||
"other": "Wizyta lekarska",
|
||||
"procedure": "Zabieg lub operacja"
|
||||
},
|
||||
"laterality": {
|
||||
"left": "Lewa strona",
|
||||
"right": "Prawa strona",
|
||||
"both": "Obie strony",
|
||||
"none": "Nie podano"
|
||||
},
|
||||
"bodySiteWithSide": "{site} · {side}",
|
||||
"sectionTitle": "Wizyty",
|
||||
"sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
|
||||
"add": "Wizyta",
|
||||
@@ -10820,6 +10888,9 @@
|
||||
"reason": "Powód",
|
||||
"outcome": "Wynik",
|
||||
"outcomeReveal": "Dodaj wynik",
|
||||
"bodySite": "Miejsce na ciele",
|
||||
"bodySiteHint": "Na przykład kolano lub pęcherzyk żółciowy.",
|
||||
"laterality": "Strona",
|
||||
"linkDocuments": "Dokumenty",
|
||||
"linkLabResults": "Wyniki badań",
|
||||
"linkConditions": "Choroby",
|
||||
@@ -10839,12 +10910,28 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Najbliższa wizyta: {what}, {when}"
|
||||
},
|
||||
"procedures": {
|
||||
"title": "Zabiegi i operacje",
|
||||
"description": "Wszystkie zabiegi i operacje, które przeszedłeś, od najnowszych.",
|
||||
"add": "Zabieg",
|
||||
"searchLabel": "Filtruj według miejsca na ciele",
|
||||
"searchPlaceholder": "Na przykład kolano",
|
||||
"sitesLabel": "Miejsca na ciele",
|
||||
"allSites": "Wszystkie",
|
||||
"count": "{shown} z {total}",
|
||||
"emptyTitle": "Nie zapisano jeszcze żadnego zabiegu",
|
||||
"emptyDescription": "Zapisz wizytę rodzaju Zabieg lub operacja albo zmień rodzaj istniejącej wizyty.",
|
||||
"noMatchTitle": "Nic nie pasuje do tego filtra",
|
||||
"clearFilter": "Wyczyść filtr",
|
||||
"loadError": "Nie udało się wczytać zabiegów."
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty",
|
||||
"viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty lub zabiegi",
|
||||
"tabVorsorge": "Profilaktyka",
|
||||
"tabVisits": "Wizyty"
|
||||
"tabVisits": "Wizyty",
|
||||
"tabProcedures": "Zabiegi"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Gabinety i lekarze",
|
||||
@@ -10880,9 +10967,10 @@
|
||||
"selectAll": "Zaznacz wszystko",
|
||||
"searchPlaceholder": "Szukaj",
|
||||
"searchNoMatch": "Brak wyników",
|
||||
"remove": "Usuń",
|
||||
"suggested": "Wokół tej daty",
|
||||
"loadError": "Nie udało się wczytać listy."
|
||||
"loadError": "Nie udało się wczytać listy.",
|
||||
"removeNamed": "Usuń {name}",
|
||||
"removed": "Odłączono: {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.39.0",
|
||||
"version": "1.39.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Backup restores run as a background job.
|
||||
--
|
||||
-- A restore used to run inside the request that asked for it. On a large
|
||||
-- account that request outlived a reverse proxy's 60-second limit, so the
|
||||
-- operator saw an error while the restore went on to finish. The request now
|
||||
-- writes one of these rows and answers 202; the worker records progress and
|
||||
-- the outcome here. Additive: nothing existing changes.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "backup_restore_jobs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"actor_user_id" TEXT NOT NULL,
|
||||
"backup_id" TEXT NOT NULL,
|
||||
"backup_digest" TEXT NOT NULL,
|
||||
"restore_instance_settings" BOOLEAN NOT NULL DEFAULT false,
|
||||
"pg_boss_job_id" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||
"phase" TEXT,
|
||||
"progress" JSONB NOT NULL DEFAULT '{}',
|
||||
"result" JSONB,
|
||||
"failure" JSONB,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"committed_at" TIMESTAMP(3),
|
||||
"heartbeat_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "backup_restore_jobs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "backup_restore_jobs_user_id_created_at_idx" ON "backup_restore_jobs"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "backup_restore_jobs_status_heartbeat_at_idx" ON "backup_restore_jobs"("status", "heartbeat_at");
|
||||
|
||||
-- One queued or running restore per account. A second request for the same
|
||||
-- account is refused with 409 by the insert this index rejects, so two
|
||||
-- concurrent requests cannot both pass a read-then-write check.
|
||||
CREATE UNIQUE INDEX "backup_restore_jobs_one_active_per_user"
|
||||
ON "backup_restore_jobs" ("user_id")
|
||||
WHERE "status" IN ('queued', 'running');
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "backup_restore_jobs" ADD CONSTRAINT "backup_restore_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Procedure and surgery history on visits.
|
||||
--
|
||||
-- A visit can now be marked as a procedure or surgery (`PROCEDURE` kind) and
|
||||
-- carry the body site it was done on: free text, encrypted at rest like the
|
||||
-- visit's reason and outcome, plus an optional side. The reasoning for a kind
|
||||
-- rather than a flag, and for searching the site by decrypting server-side
|
||||
-- rather than storing a plaintext copy, is written at the `Encounter` model.
|
||||
--
|
||||
-- Purely additive. `ADD VALUE IF NOT EXISTS` makes a rerun safe, and the new
|
||||
-- value is not used elsewhere in this migration, which is the one thing
|
||||
-- Postgres forbids inside the transaction that adds it. No backfill: an
|
||||
-- existing visit keeps its kind until the person switches it, which is an
|
||||
-- ordinary edit. Both columns are nullable, so no row is rewritten.
|
||||
--
|
||||
-- Reversibility: Postgres cannot drop an enum value, so `PROCEDURE` would stay
|
||||
-- (inert with no rows); the two columns and the `laterality` type can be
|
||||
-- dropped.
|
||||
ALTER TYPE "encounter_kind" ADD VALUE IF NOT EXISTS 'PROCEDURE';
|
||||
|
||||
CREATE TYPE "laterality" AS ENUM ('LEFT', 'RIGHT', 'BOTH');
|
||||
|
||||
ALTER TABLE "encounters"
|
||||
ADD COLUMN "body_site_encrypted" BYTEA,
|
||||
ADD COLUMN "laterality" "laterality";
|
||||
@@ -0,0 +1,27 @@
|
||||
-- The today projector mints a pending placeholder for every slot of the
|
||||
-- current day, and the hourly auto-miss pass used to stamp every stale one a
|
||||
-- miss. For a medication added in the afternoon that turned the placeholders
|
||||
-- of that day's earlier slots, which were never expected doses, into
|
||||
-- forgotten doses: the miss-free streak broke and the Coach read a miss on a
|
||||
-- day before the medication existed. The pass no longer stamps them; this
|
||||
-- returns the ones it already stamped to pending.
|
||||
--
|
||||
-- Only rows that can be nothing but such a placeholder are touched: minted by
|
||||
-- the server (source REMINDER), never acted on (no take, no skip, live), and
|
||||
-- anchored before the medication's creation but on its creation day. The
|
||||
-- projector mints for the current local day only, which begins less than a
|
||||
-- day before the creation, so the 24-hour bound is exact for placeholders and
|
||||
-- leaves any older auto-miss (history carried in by a restore) as it was.
|
||||
-- `sync_version` bumps so sync clients pick the change up.
|
||||
UPDATE "medication_intake_events" AS e
|
||||
SET "auto_missed" = false,
|
||||
"sync_version" = e."sync_version" + 1
|
||||
FROM "medications" AS m
|
||||
WHERE e."medication_id" = m."id"
|
||||
AND e."auto_missed" = true
|
||||
AND e."taken_at" IS NULL
|
||||
AND e."skipped" = false
|
||||
AND e."deleted_at" IS NULL
|
||||
AND e."source" = 'REMINDER'
|
||||
AND e."scheduled_for" < m."created_at"
|
||||
AND e."scheduled_for" >= m."created_at" - INTERVAL '24 hours';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Per-medication intake tracking (#1033). A medication with tracking off
|
||||
-- keeps its dose, dates and schedule as a record, but nothing is due, no
|
||||
-- reminder is sent and it is left out of adherence. Every existing row
|
||||
-- defaults to tracking on, so nothing changes for anyone who does not flip it.
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN "track_intake" BOOLEAN NOT NULL DEFAULT true;
|
||||
+200
-42
@@ -750,6 +750,7 @@ model User {
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
backupRestoreJobs BackupRestoreJob[]
|
||||
offhostBackupState OffhostBackupState?
|
||||
achievements UserAchievement[]
|
||||
recommendationFeedback RecommendationFeedback[]
|
||||
@@ -983,7 +984,7 @@ model User {
|
||||
// record. 1:1, and keyed by record rather than by the person at the
|
||||
// keyboard so a guardian running a managed profile's setup writes the
|
||||
// child's row, not their own. See `OnboardingRecord`.
|
||||
onboardingRecord OnboardingRecord?
|
||||
onboardingRecord OnboardingRecord?
|
||||
|
||||
// v1.15.20 — user-authored "about me" context for the AI surfaces
|
||||
// (1:1, encrypted free text). See `UserHealthProfile`.
|
||||
@@ -2609,6 +2610,25 @@ model Medication {
|
||||
/// never reminded" surface follows structurally from the empty
|
||||
/// schedule list. Mutually exclusive with `oneShot`.
|
||||
asNeeded Boolean @default(false) @map("as_needed")
|
||||
/// v1.39.1 (#1033) — per-medication intake tracking. `true` (the default,
|
||||
/// and the backfill for every existing row) is the behaviour every
|
||||
/// medication has always had. `false` keeps the medication as a record:
|
||||
/// dose, dates and the schedule rows stay stored and shown as information,
|
||||
/// but nothing derives a due dose from them — no reminder on any channel,
|
||||
/// no projected or auto-missed slot, no place on the doses card or the
|
||||
/// take-all flow, and no adherence figure anywhere (compliance, heatmap,
|
||||
/// doctor report, Coach, BP-status gate, achievements, MCP). Unlike
|
||||
/// `asNeeded` the schedule rows survive, so every schedule reader that
|
||||
/// derives a due dose must honour this flag explicitly
|
||||
/// (`src/lib/medications/intake-tracking.ts`); the structural guard
|
||||
/// `src/__tests__/medication-intake-tracking-guard.test.ts` holds the list.
|
||||
/// The medication wire serves such a medication with `schedules: []` and
|
||||
/// the stored rows under `recordedSchedules`, so a client that arms local
|
||||
/// reminders from `schedules` stays silent without an update. Switching
|
||||
/// tracking off or back on archives a schedule revision at that instant
|
||||
/// (the stretch in between archives with an empty payload), so the rate
|
||||
/// paths never count the untracked stretch as missed.
|
||||
trackIntake Boolean @default(true) @map("track_intake")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
@@ -4711,14 +4731,14 @@ model AppSettings {
|
||||
// id or an unreachable host all parse perfectly and deliver nothing.
|
||||
/// Instant the last report left successfully. Consumer: the GlitchTip card's
|
||||
/// badge, which is only green while this is inside the recent window.
|
||||
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
|
||||
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
|
||||
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
|
||||
/// line, which names the failure rather than showing a reassuring badge.
|
||||
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
|
||||
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
|
||||
/// Short classification of that failure (`invalid_dsn`, `http_403`,
|
||||
/// `network`) — never a response body, which is a remote host's text.
|
||||
/// Consumer: the same status line.
|
||||
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
|
||||
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
|
||||
|
||||
// Medication reminder thresholds (minutes after windowEnd)
|
||||
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
||||
@@ -4957,6 +4977,101 @@ model DataBackup {
|
||||
@@map("data_backups")
|
||||
}
|
||||
|
||||
// ─── Backup restore jobs (v1.39.1) ──────────────────────────
|
||||
//
|
||||
// A restore of a stored backup runs on the `backup-restore` pg-boss queue, not
|
||||
// inside the request that asked for it: rebuilding an account of 1.25 million
|
||||
// readings takes about a minute and a half, and a reverse proxy that cuts a
|
||||
// request at 60 s showed the operator an error for a restore that went on to
|
||||
// finish. The request checks the copy opens, writes one of these rows and
|
||||
// answers 202; the worker runs the restore and writes its progress and its
|
||||
// outcome here, and `GET /api/admin/backups/restores/{id}` reads them back.
|
||||
//
|
||||
// The staged file is the `DataBackup` row the restore reads. It is already
|
||||
// encrypted at rest and stays where it is whatever the outcome, so a failed
|
||||
// restore is retried from the same row without uploading anything again.
|
||||
//
|
||||
// At most one queued or running job per account: the partial unique index
|
||||
// `backup_restore_jobs_one_active_per_user` (migration 0347, raw SQL because
|
||||
// Prisma cannot express a partial index) is what makes a second request a 409
|
||||
// rather than a race.
|
||||
//
|
||||
// Status state machine:
|
||||
// queued → running → succeeded
|
||||
// ↘ failed
|
||||
// running (worker gone) → queued again at the next worker boot, at most
|
||||
// twice, then failed.
|
||||
model BackupRestoreJob {
|
||||
id String @id @default(cuid())
|
||||
/// The account the restore replaces: the backup's owner, never the admin.
|
||||
/// Consumer: the admission check, the worker, and the status routes.
|
||||
userId String @map("user_id")
|
||||
/// The admin who asked for the restore. A plain column rather than a
|
||||
/// relation, so deleting that admin later does not erase the record of who
|
||||
/// replaced this account's data. Consumer: the worker's audit rows.
|
||||
actorUserId String @map("actor_user_id")
|
||||
/// The `DataBackup` row being restored. A plain column: the backup may be
|
||||
/// deleted while the job waits, which the worker reports as a failure rather
|
||||
/// than a cascade deleting the job. Consumer: the worker and the status
|
||||
/// routes (the console marks the row being restored).
|
||||
backupId String @map("backup_id")
|
||||
/// SHA-256 of `DataBackup.data` when the restore was requested. The weekly
|
||||
/// job overwrites its row in place, so the worker checks the copy it reads is
|
||||
/// the one the operator previewed and confirmed. Consumer: the worker.
|
||||
backupDigest String @map("backup_digest")
|
||||
/// The operator's answer to "also restore the instance settings". Consumer:
|
||||
/// the worker, and the status routes.
|
||||
restoreInstanceSettings Boolean @default(false) @map("restore_instance_settings")
|
||||
/// `pg-boss` job id of the latest delivery. Consumer: the boot sweep, which
|
||||
/// completes a delivery whose worker is gone so it does not later surface as
|
||||
/// a timed-out job.
|
||||
pgBossJobId String? @map("pg_boss_job_id")
|
||||
/// `queued | running | succeeded | failed`. Consumer: every reader.
|
||||
status String @default("queued")
|
||||
/// Where a running restore is: `validating | clearing | measurements |
|
||||
/// sections | rebuilding`. NULL while queued and once finished. Consumer:
|
||||
/// the status routes.
|
||||
phase String?
|
||||
/// Counts the worker writes as it goes (`RestoreProgress` in
|
||||
/// `src/lib/export/restore-backup.ts`). Consumer: the status routes.
|
||||
progress Json @default("{}")
|
||||
/// On success: what the restore wrote, cleared and skipped. Counts and
|
||||
/// catalogue keys only, the same facts the audit row carries. Consumer: the
|
||||
/// status routes.
|
||||
result Json?
|
||||
/// On failure: a stable `code`, a sentence an operator can act on, and the
|
||||
/// missing sections when that was the reason. Consumer: the status routes.
|
||||
failure Json?
|
||||
/// When the restore transaction committed: from here on the account holds
|
||||
/// the restored data, whatever happens to the steps after it. Written and
|
||||
/// awaited before the rollup rebuild starts. Consumer: the boot sweep and
|
||||
/// the admission check, which never queue a committed job again (a second
|
||||
/// run would delete what the account gained since) and close it as
|
||||
/// `failed_after_commit` instead; and the worker's own failure message.
|
||||
committedAt DateTime? @map("committed_at")
|
||||
/// How many times a worker has started this job. Consumer: the boot sweep,
|
||||
/// which stops re-queueing after the second attempt.
|
||||
attempts Int @default(0)
|
||||
/// Bumped every few seconds while a worker runs the job. A running row whose
|
||||
/// heartbeat has gone stale belongs to a worker that is gone. Consumer: the
|
||||
/// admission check and the boot sweep.
|
||||
heartbeatAt DateTime? @map("heartbeat_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// `backup_restore_jobs_one_active_per_user` is a partial UNIQUE index over
|
||||
// `(user_id) WHERE status IN ('queued', 'running')` — see migration 0347.
|
||||
// Prisma can't express a partial index in the schema language, so it lives
|
||||
// in raw SQL and is intentionally omitted here.
|
||||
@@index([userId, createdAt])
|
||||
@@index([status, heartbeatAt])
|
||||
@@map("backup_restore_jobs")
|
||||
}
|
||||
|
||||
// ─── Off-host backup ledger ──────────────────────────────────
|
||||
//
|
||||
// One row per account, written by the nightly off-host job the moment an
|
||||
@@ -5338,7 +5453,7 @@ model MeasurementReminder {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
|
||||
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
|
||||
/// side, so deleting the reminder leaves the visit intact.
|
||||
@@ -5622,15 +5737,15 @@ model CustomMetric {
|
||||
/// snapshot of the metric's unit at write time (historical truth, mirroring the
|
||||
/// LabResult per-row unit). Charts read these LIVE (no rollup tier).
|
||||
model CustomMetricEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
customMetricId String @map("custom_metric_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
customMetricId String @map("custom_metric_id")
|
||||
value Float
|
||||
/// Unit snapshot from the metric at write time.
|
||||
unit String
|
||||
measuredAt DateTime @map("measured_at")
|
||||
measuredAt DateTime @map("measured_at")
|
||||
note String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
|
||||
/// MoodEntry (every other entry surface deletes recoverably with an undo
|
||||
/// toast; custom-metric entries were the one hard delete). NULL = live.
|
||||
@@ -6138,28 +6253,28 @@ model CoachReminder {
|
||||
// must never land in the table as plaintext. A cleared profile keeps the
|
||||
// row with a NULL payload so `updatedAt` documents the deletion instant.
|
||||
model UserHealthProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
// Free text, hard-capped at 4 000 chars by the route's Zod schema
|
||||
// BEFORE encryption. NULL = cleared / never written.
|
||||
aboutMeEncrypted Bytes? @map("about_me_encrypted")
|
||||
aboutMeEncrypted Bytes? @map("about_me_encrypted")
|
||||
// v1.16.0 — structured self-context companions to the free text.
|
||||
// Short answers (each capped at 500 chars BEFORE encryption).
|
||||
// Age/gender deliberately do NOT live here — they come from the User
|
||||
// profile and are merged into the prompt block at assembly time so
|
||||
// the data is never duplicated.
|
||||
conditionsEncrypted Bytes? @map("conditions_encrypted")
|
||||
allergiesEncrypted Bytes? @map("allergies_encrypted")
|
||||
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
|
||||
conditionsEncrypted Bytes? @map("conditions_encrypted")
|
||||
allergiesEncrypted Bytes? @map("allergies_encrypted")
|
||||
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
|
||||
// v1.16.0 — up to 3 clarifying questions the server derives after a
|
||||
// save (AI-generated when a provider + budget allow, deterministic
|
||||
// hints otherwise). Encrypted JSON string[]; the Coach composer
|
||||
// renders them as tappable suggestion chips. NULL = none pending.
|
||||
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
|
||||
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
|
||||
// Closed inclusion list for self-context sent to Coach and briefing prompts.
|
||||
// The static default preserves every pre-existing prompt section for legacy
|
||||
// rows without automatically opting future enum members in.
|
||||
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
|
||||
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
|
||||
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
|
||||
// report and edited in Settings -> Anamnese. Three closed-set enums are
|
||||
// plaintext (read directly by the report, like `User.gender`); three free
|
||||
@@ -6167,14 +6282,14 @@ model UserHealthProfile {
|
||||
// `*Encrypted` column. All six are nullable: an unset emergency profile is
|
||||
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
|
||||
// admitted AND at least one of these holds a value.
|
||||
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
|
||||
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
|
||||
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
|
||||
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
|
||||
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
|
||||
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
|
||||
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
|
||||
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
|
||||
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
|
||||
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
|
||||
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -6285,10 +6400,10 @@ model HealthProfileFactRevision {
|
||||
// secondary signal used by the frontend to render a "≈ N replies left"
|
||||
// hint without exposing raw token figures.
|
||||
model CoachUsage {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
dateKey String @map("date_key")
|
||||
totalTokens Int @default(0) @map("total_tokens")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
dateKey String @map("date_key")
|
||||
totalTokens Int @default(0) @map("total_tokens")
|
||||
/// v1.38.19 — the share of `totalTokens` served by a provider the
|
||||
/// OPERATOR funds (`admin-openai` / `admin-codex`). The operator-cost cap is
|
||||
/// enforced against this counter and the user-plan cap against
|
||||
@@ -8119,12 +8234,12 @@ model NutrientIntakeDay {
|
||||
/// relocated to the link pickers, which follow the modules that own the
|
||||
/// things they point at.
|
||||
model Encounter {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The visit instant (UTC; wall-clock anchoring is the reader's
|
||||
/// concern, per the schema's UTC-only convention). Future instants are
|
||||
/// legal — that is the whole appointment half.
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// Where in its lifecycle. PLANNED is a future appointment; DONE is a
|
||||
/// visit that happened; CANCELLED and NO_SHOW are terminal and keep
|
||||
/// the row so the history stays honest.
|
||||
@@ -8138,27 +8253,49 @@ model Encounter {
|
||||
/// `FHIR_REST_RESOURCE_TYPES` (`src/lib/fhir/rest.ts`) is deliberately
|
||||
/// untouched — a capability statement that names a type with no route
|
||||
/// module lies.
|
||||
status EncounterStatus @default(DONE)
|
||||
kind EncounterKind @default(OTHER)
|
||||
practitionerId String? @map("practitioner_id")
|
||||
status EncounterStatus @default(DONE)
|
||||
kind EncounterKind @default(OTHER)
|
||||
practitionerId String? @map("practitioner_id")
|
||||
/// The person's own framing of why they went or are going. Encrypted
|
||||
/// at rest (AES-256-GCM, the `*Encrypted` Bytes convention).
|
||||
reasonEncrypted Bytes? @map("reason_encrypted")
|
||||
reasonEncrypted Bytes? @map("reason_encrypted")
|
||||
/// What came out of it — plan, follow-up, what to watch. Same codec.
|
||||
/// One field rather than a treatment/follow-up pair whose boundary
|
||||
/// nobody can state: two columns would be two chances to put the text
|
||||
/// in the wrong one, and the actionable half of a follow-up is not
|
||||
/// prose at all, it is the next appointment.
|
||||
outcomeEncrypted Bytes? @map("outcome_encrypted")
|
||||
outcomeEncrypted Bytes? @map("outcome_encrypted")
|
||||
/// Where on the body a procedure was done, in the person's own words
|
||||
/// ("knee", "gallbladder", "L4/L5"). Free text, because no picker covers
|
||||
/// everything a surgeon operates on, and a coded vocabulary the person
|
||||
/// cannot read is worse than their own word. Health data, so encrypted
|
||||
/// like `reasonEncrypted`.
|
||||
///
|
||||
/// Searching it does not need plaintext at rest: the procedures read
|
||||
/// (`src/lib/encounters/procedures.ts`) decrypts the account's own
|
||||
/// procedures server-side and matches there. That set is a person's
|
||||
/// surgical history — tens of rows, not thousands — so a decrypt pass
|
||||
/// per search is cheap, and a plaintext or hashed search column would be
|
||||
/// a second copy of the most identifying field on the row.
|
||||
///
|
||||
/// Allowed on any kind, surfaced for PROCEDURE: switching a visit's kind
|
||||
/// away and back must not lose what was typed.
|
||||
bodySiteEncrypted Bytes? @map("body_site_encrypted")
|
||||
/// Which side, when the site has one. Separate from the text so "left
|
||||
/// knee" and "right knee" group and filter as the same site on two sides
|
||||
/// without parsing prose, and plaintext because a side alone says
|
||||
/// nothing. NULL means not stated or not applicable, deliberately one
|
||||
/// value rather than a NONE beside NULL that would mean the same.
|
||||
laterality Laterality?
|
||||
/// Optional link to the reminder this visit closes. Set when the user
|
||||
/// files the visit from a due checkup, or minted here when the visit
|
||||
/// is PLANNED. Satisfying the reminder is the side effect, not this
|
||||
/// column.
|
||||
reminderId String? @map("reminder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
reminderId String? @map("reminder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||||
@@ -8189,6 +8326,16 @@ enum EncounterStatus {
|
||||
/// What kind of contact it was. Defaulted to OTHER so it is never a
|
||||
/// required pick, and small enough to stay readable at a glance in a
|
||||
/// list. Maps to FHIR `Encounter.class` at the export boundary.
|
||||
///
|
||||
/// PROCEDURE (v1.39.1) marks a surgery or procedure. A kind rather than a
|
||||
/// boolean beside the kind, because the kind already answers "what was
|
||||
/// this contact" and a flag would make HOSPITAL-plus-flag and PROCEDURE two
|
||||
/// spellings of one visit with no rule for which to use. The cost is that a
|
||||
/// surgery done in an emergency is filed as PROCEDURE and not EMERGENCY; the
|
||||
/// operation is the fact a surgical history asks for, and the reason text
|
||||
/// keeps the rest. Additive for every client in the field: the shipped iOS
|
||||
/// app does not decode visits at all, and the web client ships with the
|
||||
/// server.
|
||||
enum EncounterKind {
|
||||
ROUTINE
|
||||
ACUTE
|
||||
@@ -8198,10 +8345,21 @@ enum EncounterKind {
|
||||
HOSPITAL
|
||||
THERAPY
|
||||
OTHER
|
||||
PROCEDURE
|
||||
|
||||
@@map("encounter_kind")
|
||||
}
|
||||
|
||||
/// The side of the body a procedure's site is on. See
|
||||
/// `Encounter.laterality`.
|
||||
enum Laterality {
|
||||
LEFT
|
||||
RIGHT
|
||||
BOTH
|
||||
|
||||
@@map("laterality")
|
||||
}
|
||||
|
||||
/// The account's own address book of doctors and practices.
|
||||
///
|
||||
/// User-scoped, and the scoping is the point: a global registry with an
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.39.0";
|
||||
/* @sw-version-fallback */ "v1.39.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
+42
-16
@@ -15,12 +15,23 @@
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*
|
||||
* An output name ending in `.gz` writes the JSON compressed, which is the
|
||||
* form to hand to the admin upload for a large account: the JSON of 1.25
|
||||
* million measurements is 662 MB, the compressed file about a tenth of it.
|
||||
* The file is written as a stream in either case and never held whole.
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { rename } from "node:fs/promises";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGzip } from "node:zlib";
|
||||
|
||||
import { scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
getS3Client,
|
||||
decryptBackup,
|
||||
openBackupObject,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
@@ -98,28 +109,43 @@ async function main() {
|
||||
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
||||
);
|
||||
const ciphertext = await s3.getObject(key);
|
||||
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
|
||||
const source = openBackupObject(ciphertext, cfg.encryptionKey);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const body = validateBackupShape(parsed);
|
||||
// Checked first, in one streamed pass that keeps only the small sections,
|
||||
// so nothing is written for an object that is not a backup.
|
||||
const { document, streamedCounts } = await scanBackupJson(source(), {
|
||||
streamKeys: new Set(["measurements", "intakeEvents", "moodEntries"]),
|
||||
});
|
||||
const body = validateBackupShape({
|
||||
...document,
|
||||
measurements: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [],
|
||||
});
|
||||
if (expectedUserId && body.userId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// The restored file is decrypted PHI in plaintext. Write it 0o600 so it is
|
||||
// not world-readable on the operator's disk while it sits there.
|
||||
writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 });
|
||||
// The restored file is decrypted PHI in plaintext. Written 0o600 so it is
|
||||
// not world-readable on the operator's disk, to a temporary name first so
|
||||
// an interrupted run never leaves a truncated file under the real one.
|
||||
const partial = `${out}.part`;
|
||||
let bytes = 0;
|
||||
const counter = new PassThrough();
|
||||
counter.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
});
|
||||
await pipeline(
|
||||
Readable.from(source()),
|
||||
counter,
|
||||
...(out.endsWith(".gz") ? [createGzip()] : []),
|
||||
createWriteStream(partial, { mode: 0o600 }),
|
||||
);
|
||||
await rename(partial, out);
|
||||
console.log(
|
||||
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
|
||||
`Restored userId=${body.userId} (${bytes} bytes of JSON, ${streamedCounts.measurements ?? 0} measurements) -> ${out}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,50 @@ function shouldRotate(value: string | null): boolean {
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
/** The part of a Prisma delegate the per-column walks below need. */
|
||||
interface PagedDelegate<V> {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
orderBy: { id: "asc" };
|
||||
take: number;
|
||||
where?: { id: { gt: string } };
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, V>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Rows per page when walking a column. */
|
||||
const PAGE_SIZE = 5_000;
|
||||
|
||||
/**
|
||||
* Every row of one column, `id` and value only, a page at a time in id order.
|
||||
* The walks used to read a column in one `findMany`, which for
|
||||
* `Measurement.notesEncrypted` is every measurement of the instance: 1.25
|
||||
* million rows on one large account (#1031).
|
||||
*/
|
||||
async function* pagedRows(
|
||||
delegate: PagedDelegate<unknown>,
|
||||
field: string,
|
||||
): AsyncGenerator<Record<string, unknown>> {
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [field]: true },
|
||||
orderBy: { id: "asc" },
|
||||
take: PAGE_SIZE,
|
||||
// `id > last`, not Prisma's `cursor` + `skip`: the cursor form looks
|
||||
// the last row up again, and a row deleted between pages ends the walk.
|
||||
...(cursor ? { where: { id: { gt: cursor } } } : {}),
|
||||
});
|
||||
if (rows.length === 0) return;
|
||||
yield* rows;
|
||||
if (rows.length < PAGE_SIZE) return;
|
||||
cursor = rows[rows.length - 1]!.id as string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate one `String` ciphertext column on a Prisma model. `delegate` is the
|
||||
* `prisma.<model>` accessor; `field` is the column. Reads only `id` + the
|
||||
@@ -116,31 +160,28 @@ function shouldRotate(value: string | null): boolean {
|
||||
async function rotateStringColumn(
|
||||
table: string,
|
||||
field: string,
|
||||
delegate: {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, string>;
|
||||
}) => Promise<unknown>;
|
||||
},
|
||||
delegate: PagedDelegate<string>,
|
||||
): Promise<RotationResult> {
|
||||
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
scanned: 0,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
for await (const row of pagedRows(
|
||||
delegate as PagedDelegate<unknown>,
|
||||
field,
|
||||
)) {
|
||||
const v = row[field] as string | null;
|
||||
// `scanned` counts rows that hold ciphertext, as the in-app rotation does.
|
||||
if (!v) continue;
|
||||
result.scanned++;
|
||||
if (!shouldRotate(v)) continue;
|
||||
const id = row.id as string;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
const re = encrypt(decrypt(v));
|
||||
await delegate.update({ where: { id }, data: { [field]: re } });
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
@@ -159,28 +200,23 @@ async function rotateStringColumn(
|
||||
async function rotateBytesColumn(
|
||||
table: string,
|
||||
field: string,
|
||||
delegate: {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, Uint8Array>;
|
||||
}) => Promise<unknown>;
|
||||
},
|
||||
delegate: PagedDelegate<Uint8Array>,
|
||||
): Promise<RotationResult> {
|
||||
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
scanned: 0,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
for await (const row of pagedRows(
|
||||
delegate as PagedDelegate<unknown>,
|
||||
field,
|
||||
)) {
|
||||
const buf = row[field] as Uint8Array | null;
|
||||
if (!buf || buf.byteLength === 0) continue;
|
||||
result.scanned++;
|
||||
const asString = Buffer.from(buf).toString("utf8");
|
||||
if (!shouldRotate(asString)) continue;
|
||||
const id = row.id as string;
|
||||
@@ -625,6 +661,10 @@ async function main() {
|
||||
results.push(
|
||||
await rotateBytesColumn("Encounter", "outcomeEncrypted", prisma.encounter),
|
||||
);
|
||||
// v1.39.1 — a procedure's body site.
|
||||
results.push(
|
||||
await rotateBytesColumn("Encounter", "bodySiteEncrypted", prisma.encounter),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Practitioner",
|
||||
@@ -747,7 +787,7 @@ async function main() {
|
||||
select: { id: true, textEncrypted: true, verbatimTextEncrypted: true },
|
||||
orderBy: { id: "asc" },
|
||||
take: 100,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
...(cursor ? { where: { id: { gt: cursor } } } : {}),
|
||||
});
|
||||
if (rows.length === 0) break;
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Seed one account shaped like a multi-year Apple Health `export.xml` import,
|
||||
* for reproducing the maintenance jobs at the size a real heavy account
|
||||
* reaches (issue #1031: about 1.26 million measurement rows).
|
||||
*
|
||||
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/seed-large-account.ts \
|
||||
* [--user bench-large] [--years 4] [--pulse-seconds 165]
|
||||
*
|
||||
* Never point this at a database that holds real data: it inserts rows under
|
||||
* the given user id and, with `--reset`, deletes that user first.
|
||||
*
|
||||
* The shape follows what the import and the iOS sync actually write:
|
||||
*
|
||||
* - dense spot rows (heart rate every `--pulse-seconds`, HRV, SpO2) keyed by
|
||||
* a hashed sample id, the tier the dense-intraday retention folds;
|
||||
* - high-frequency mean rows (respiratory rate, gait metrics, audio
|
||||
* exposure), the tier the daily-mean consolidation folds;
|
||||
* - one `stats:` daily row per cumulative type and day (what the export
|
||||
* import writes for steps, energy, distance and flights);
|
||||
* - per-sample cumulative rows with opaque sample ids for the most recent
|
||||
* 90 days (what the iOS sync writes, and what the cumulative drain folds);
|
||||
* - sleep-stage segments every night.
|
||||
*
|
||||
* Everything is generated inside Postgres with `generate_series`, so a million
|
||||
* rows take seconds rather than a million round trips. `ANALYZE` runs at the
|
||||
* end: a planner reading pre-seed statistics picks plans no production table
|
||||
* of this size would get.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function arg(name: string, fallback: string): string {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
|
||||
}
|
||||
|
||||
const USER_ID = arg("user", "bench-large");
|
||||
const YEARS = Number(arg("years", "4"));
|
||||
const PULSE_SECONDS = Number(arg("pulse-seconds", "165"));
|
||||
const RESET = process.argv.includes("--reset");
|
||||
|
||||
/** A spot series: `perDay` evenly spaced rows per day over the whole span. */
|
||||
interface SpotSeries {
|
||||
type: string;
|
||||
unit: string;
|
||||
perDay: number;
|
||||
base: number;
|
||||
spread: number;
|
||||
/** Days back from now the series covers. Defaults to the whole span. */
|
||||
days?: number;
|
||||
/** externalId prefix; `stats:` rows are generated separately. */
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
async function insertSpot(s: SpotSeries, days: number): Promise<number> {
|
||||
const step = Math.floor(86_400 / s.perDay);
|
||||
const count = Math.floor((days * 86_400) / step);
|
||||
// Jitter stays below the step so every measured_at is distinct per type
|
||||
// (the (user, type, measured_at, source, sleep_stage) unique index).
|
||||
const jitter = Math.max(1, Math.min(59, step - 1));
|
||||
return prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-' || g,
|
||||
$1,
|
||||
$2::measurement_type,
|
||||
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
|
||||
$5,
|
||||
'APPLE_HEALTH',
|
||||
date_trunc('minute', now()) - make_interval(secs => $6 * $7::bigint)
|
||||
+ make_interval(secs => g * $6 + (g * 31) % $8),
|
||||
$9 || md5($2 || g::text),
|
||||
now(), now()
|
||||
FROM generate_series(0, $7::bigint - 1) AS g
|
||||
`,
|
||||
USER_ID,
|
||||
s.type,
|
||||
s.base,
|
||||
s.spread,
|
||||
s.unit,
|
||||
step,
|
||||
count,
|
||||
jitter,
|
||||
s.idPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const days = Math.round(YEARS * 365);
|
||||
|
||||
if (RESET) {
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
|
||||
}
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO users (id, username, timezone, updated_at)
|
||||
VALUES ($1, $1, 'Europe/Berlin', now())
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
const pulsePerDay = Math.floor(86_400 / PULSE_SECONDS);
|
||||
const spot: SpotSeries[] = [
|
||||
{
|
||||
type: "PULSE",
|
||||
unit: "bpm",
|
||||
perDay: pulsePerDay,
|
||||
base: 62,
|
||||
spread: 25,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
unit: "ms",
|
||||
perDay: 12,
|
||||
base: 45,
|
||||
spread: 20,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
unit: "%",
|
||||
perDay: 24,
|
||||
base: 96,
|
||||
spread: 2,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
unit: "breaths/min",
|
||||
perDay: 48,
|
||||
base: 14,
|
||||
spread: 3,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_SPEED",
|
||||
unit: "km/h",
|
||||
perDay: 30,
|
||||
base: 4.8,
|
||||
spread: 0.8,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_STEP_LENGTH",
|
||||
unit: "cm",
|
||||
perDay: 30,
|
||||
base: 72,
|
||||
spread: 6,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_ASYMMETRY",
|
||||
unit: "%",
|
||||
perDay: 30,
|
||||
base: 3,
|
||||
spread: 2,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_DOUBLE_SUPPORT",
|
||||
unit: "%",
|
||||
perDay: 30,
|
||||
base: 28,
|
||||
spread: 3,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
{
|
||||
type: "AUDIO_EXPOSURE_ENV",
|
||||
unit: "dB",
|
||||
perDay: 60,
|
||||
base: 62,
|
||||
spread: 12,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
unit: "steps",
|
||||
perDay: 100,
|
||||
base: 90,
|
||||
spread: 60,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
unit: "kcal",
|
||||
perDay: 400,
|
||||
base: 1.2,
|
||||
spread: 0.9,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
unit: "m",
|
||||
perDay: 100,
|
||||
base: 70,
|
||||
spread: 50,
|
||||
days: 90,
|
||||
idPrefix: "uuid-",
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
unit: "kg",
|
||||
perDay: 1,
|
||||
base: 82,
|
||||
spread: 1.5,
|
||||
idPrefix: "hk-",
|
||||
},
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const s of spot) {
|
||||
const n = await insertSpot(s, s.days ?? days);
|
||||
total += n;
|
||||
console.log(`${s.type.padEnd(28)} ${String(n).padStart(9)}`);
|
||||
}
|
||||
|
||||
// One export-estimate `stats:` row per cumulative type and local day, at
|
||||
// local noon — what `flushCumulativeBuckets` in the export import writes.
|
||||
for (const [type, hk, unit, base] of [
|
||||
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
|
||||
[
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"HKQuantityTypeIdentifierActiveEnergyBurned",
|
||||
"kcal",
|
||||
550,
|
||||
],
|
||||
[
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
"m",
|
||||
6000,
|
||||
],
|
||||
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "count", 9],
|
||||
] as const) {
|
||||
const n = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
aggregation_provenance, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-' || $2 || '-stats-' || d,
|
||||
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
|
||||
((current_date - d)::timestamp + interval '12 hours')
|
||||
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
|
||||
'stats:' || $3 || ':' || to_char(current_date - d, 'YYYY-MM-DD'),
|
||||
'EXPORT_XML_SOURCE_MAX', now(), now()
|
||||
FROM generate_series(91, $6::int) AS d
|
||||
`,
|
||||
USER_ID,
|
||||
type,
|
||||
hk,
|
||||
unit,
|
||||
base,
|
||||
days,
|
||||
);
|
||||
total += n;
|
||||
console.log(`${`${type} (stats:)`.padEnd(28)} ${String(n).padStart(9)}`);
|
||||
}
|
||||
|
||||
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
|
||||
// through the stages. Distinct measured_at per segment.
|
||||
const sleep = await prisma.$executeRawUnsafe(
|
||||
`
|
||||
INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, external_id,
|
||||
sleep_stage, created_at, updated_at)
|
||||
SELECT
|
||||
'seed-' || $1 || '-sleep-' || d || '-' || s,
|
||||
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
|
||||
((current_date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
|
||||
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
|
||||
'hk-' || md5('sleep' || d || '-' || s),
|
||||
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
|
||||
now(), now()
|
||||
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
|
||||
`,
|
||||
USER_ID,
|
||||
days,
|
||||
);
|
||||
total += sleep;
|
||||
console.log(`${"SLEEP_DURATION".padEnd(28)} ${String(sleep).padStart(9)}`);
|
||||
|
||||
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
|
||||
console.log(`${"total".padEnd(28)} ${String(total).padStart(9)}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -321,7 +321,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
it("does not count a nested relation read as a restore write", () => {
|
||||
expect(
|
||||
touches(
|
||||
["src/app/api/admin/backups/[id]/restore/route.ts"],
|
||||
["src/lib/export/restore-backup.ts"],
|
||||
"User",
|
||||
WRITE_OPS,
|
||||
WRITE_RELATION_OPS,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Every reader of a stored or uploaded backup reads it as a stream (#1031).
|
||||
*
|
||||
* A backup's JSON grows with the record, and past 536 870 888 characters it
|
||||
* cannot exist as one JavaScript string at all: the disaster-recovery JSON of
|
||||
* an account with 1.25 million measurements is 662 MB. Each of the readers
|
||||
* below used to turn the file into one string first, and so none of them
|
||||
* could open that account's backup. This pins that none of them goes back to
|
||||
* it: no whole-document decrypt-to-string, no whole-body text read.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const READERS = [
|
||||
"src/lib/export/restore-backup.ts",
|
||||
"src/app/api/admin/backups/[id]/summary/route.ts",
|
||||
"src/app/api/admin/backups/[id]/download/route.ts",
|
||||
"src/app/api/admin/backups/upload/route.ts",
|
||||
"src/lib/jobs/restore-drill.ts",
|
||||
"scripts/restore-backup.ts",
|
||||
] as const;
|
||||
|
||||
/** Calls that hand back the whole document as one string or parse it whole. */
|
||||
const WHOLE_DOCUMENT = [
|
||||
/\bunpackBackupBlob\s*\(/,
|
||||
/\bdecryptBackup\s*\(/,
|
||||
/\bJSON\.parse\s*\(\s*plaintext\b/,
|
||||
/\bparseBackupPayload\s*\(\s*plaintext\b/,
|
||||
/\bfile\.text\s*\(/,
|
||||
/\bawait\s+request\.text\s*\(\s*\)[\s\S]{0,200}parseBackupPayload/,
|
||||
];
|
||||
|
||||
describe("backup readers stream the file", () => {
|
||||
it.each(READERS)("%s never holds the whole document", (path) => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
for (const pattern of WHOLE_DOCUMENT) {
|
||||
expect(source, `${path} matches ${pattern}`).not.toMatch(pattern);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(READERS)("%s reads through the streaming reader", (path) => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source).toMatch(/\b(readStreamedBackup|scanBackupJson)\b/);
|
||||
});
|
||||
});
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(94);
|
||||
expect(ALL_LEAF_IDS.length).toBe(95);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(17);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -114,7 +114,7 @@ const WRITERS: Record<string, Disposition> = {
|
||||
},
|
||||
|
||||
// ── Restore ───────────────────────────────────────────────────────────
|
||||
"app/api/admin/backups/[id]/restore/route.ts": {
|
||||
"lib/export/restore-backup.ts": {
|
||||
kind: "restore",
|
||||
why: "replays the record's own export; dropping rows would break the restore contract",
|
||||
},
|
||||
@@ -130,6 +130,22 @@ const WRITERS: Record<string, Disposition> = {
|
||||
const MEASUREMENT_WRITE =
|
||||
/[A-Za-z_$][\w$]*\s*\.\s*measurement\s*\.\s*(create|createMany|createManyAndReturn|upsert)\s*\(/;
|
||||
|
||||
/**
|
||||
* A call into the column-array bulk insert, which writes around the Prisma
|
||||
* delegate and so never matches {@link MEASUREMENT_WRITE}. The Apple export
|
||||
* importer writes its spot rows only this way, and was invisible to this
|
||||
* guard for as long as nothing looked for it. The definitions themselves are
|
||||
* excluded by the lookbehind; the module that holds them is pinned below as
|
||||
* the one place raw SQL may insert a measurement.
|
||||
*/
|
||||
const BULK_INSERT_CALL = /(?<!function\s)\binsert(?:New)?MeasurementRows\s*\(/;
|
||||
|
||||
/** Raw SQL that inserts into the measurements table. */
|
||||
const RAW_MEASUREMENT_INSERT = /INSERT\s+INTO\s+"?measurements"?[\s(]/i;
|
||||
|
||||
/** The only module allowed to hold {@link RAW_MEASUREMENT_INSERT}. */
|
||||
const BULK_INSERT_MODULE = "lib/export/measurement-bulk-insert.ts";
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
@@ -143,7 +159,10 @@ function read(rel: string): string {
|
||||
}
|
||||
|
||||
function discoveredWriters(): string[] {
|
||||
return sourceFiles().filter((rel) => MEASUREMENT_WRITE.test(read(rel)));
|
||||
return sourceFiles().filter((rel) => {
|
||||
const text = read(rel);
|
||||
return MEASUREMENT_WRITE.test(text) || BULK_INSERT_CALL.test(text);
|
||||
});
|
||||
}
|
||||
|
||||
describe("every measurement writer declares how it meets the plausibility domain", () => {
|
||||
@@ -158,6 +177,21 @@ describe("every measurement writer declares how it meets the plausibility domain
|
||||
expect(discoveredWriters()).toEqual(Object.keys(WRITERS).sort());
|
||||
});
|
||||
|
||||
it("sees a writer that only calls the bulk insert", () => {
|
||||
// The importer has no Prisma measurement write left; if the bulk-insert
|
||||
// matcher stopped matching, it would drop out of the sweep silently.
|
||||
const importer = "lib/measurements/import-apple-health-export.ts";
|
||||
expect(MEASUREMENT_WRITE.test(read(importer))).toBe(false);
|
||||
expect(discoveredWriters()).toContain(importer);
|
||||
});
|
||||
|
||||
it("raw SQL inserts measurements in the bulk-insert module and nowhere else", () => {
|
||||
const raw = sourceFiles().filter((rel) =>
|
||||
RAW_MEASUREMENT_INSERT.test(read(rel)),
|
||||
);
|
||||
expect(raw).toEqual([BULK_INSERT_MODULE]);
|
||||
});
|
||||
|
||||
it("every provider-fed writer reaches the gate module", () => {
|
||||
const gated = Object.entries(WRITERS)
|
||||
.filter(([, d]) => d.kind === "gate")
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Structural guard — every reader of medication schedules honours the
|
||||
* per-medication intake-tracking switch (#1033).
|
||||
*
|
||||
* An as-needed medication is "never due, never reminded, never counted"
|
||||
* structurally: it carries no schedule rows, so every reader that expands
|
||||
* schedules into slots finds nothing to expand. A medication with intake
|
||||
* tracking off (`Medication.trackIntake = false`) keeps its schedule rows as a
|
||||
* record. Nothing about the rows themselves says "do not derive a due dose
|
||||
* from me", so the promise holds only as long as every reader asks. A new
|
||||
* reminder path, compliance surface or projection that reads `schedules` and
|
||||
* forgets the flag would quietly start reminding a person who asked not to be
|
||||
* reminded.
|
||||
*
|
||||
* So the set of schedule readers is frozen here. A file that reads medication
|
||||
* schedules through Prisma either uses the shared vocabulary in
|
||||
* `src/lib/medications/intake-tracking.ts` (or `expectsDoses`, whose required
|
||||
* `trackIntake` field makes the type checker enforce the same thing), or it is
|
||||
* on the allowlist below with the reason it may see every stored row.
|
||||
*
|
||||
* Limits, stated plainly. The matcher is textual: it finds a Prisma
|
||||
* `schedules: true` / `schedules: { … }` include or select and the
|
||||
* `medicationSchedule.find*` delegates. A schedule read spelt some other way
|
||||
* (raw SQL on `medication_schedules`, a helper that returns rows it read
|
||||
* elsewhere) is invisible to it. It proves a file names the vocabulary, not
|
||||
* that the named helper is applied to the right query. The behavioural tests
|
||||
* beside each consumer carry that half.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** A Prisma read (or write payload) that touches medication schedule rows. */
|
||||
const SCHEDULE_READ =
|
||||
/\bschedules\s*:\s*(?:true\b|\{)|\bmedicationSchedule\s*\.\s*find(?:Many|First|Unique)\b/;
|
||||
|
||||
/** The vocabulary that honours the switch. */
|
||||
const HONOURS =
|
||||
/\b(?:TRACKED_INTAKE_WHERE|TRACKED_INTAKE_EVENT_WHERE|dueSchedules|scheduleWireFields|expectsDoses|isRecordOnly)\b/;
|
||||
|
||||
/**
|
||||
* A bulk read of intake rows. Rate readers that never touch a schedule (a
|
||||
* nudge that divides taken by resolved rows, a model series that buckets
|
||||
* rows per day) count a record-only medication's rows unless they filter.
|
||||
* Whitespace-tolerant: the delegate and the method may sit on two lines.
|
||||
*/
|
||||
const INTAKE_READ =
|
||||
/\bmedicationIntakeEvent\s*\.\s*(?:findMany|groupBy|count|aggregate)\s*\(/;
|
||||
|
||||
/**
|
||||
* Intake readers that may see every medication's rows, and why: nothing in
|
||||
* them is an adherence rate, an expected-dose count or a due dose.
|
||||
*/
|
||||
const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"app/api/admin/status/route.ts": "instance row counts",
|
||||
"app/api/dashboard/summary/route.ts":
|
||||
"engagement streak of days with any logged action, no rate",
|
||||
"app/api/export/medications/route.ts": "export of the recorded history",
|
||||
"app/api/export/route.ts": "export of the recorded history",
|
||||
"lib/export/full-backup-payload.ts": "backup of the recorded history",
|
||||
"app/api/sync/state/route.ts": "sync watermarks, no rate",
|
||||
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
|
||||
"lib/ai/coach/tools/availability.ts":
|
||||
"whether any intake history exists, and its date range",
|
||||
"app/api/medications/[id]/intake/route.ts":
|
||||
"one medication's recorded history, listed or written",
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts":
|
||||
"deletes rows the person chose",
|
||||
"app/api/medications/[id]/route.ts":
|
||||
"one medication's slots; tombstones on the tracking switch",
|
||||
"app/api/medications/intake/bulk/route.ts": "writes recorded doses",
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/scheduling/worker-helpers.ts":
|
||||
"slot lookups for one medication the caller already selected",
|
||||
"lib/medications/inventory/consumption.ts":
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"administration ledger lists recorded doses; rates gate via expectsDoses",
|
||||
};
|
||||
|
||||
/**
|
||||
* Readers that may see every stored schedule row, and why. Each reason says
|
||||
* why no due dose, reminder or adherence figure can come out of the file.
|
||||
*/
|
||||
const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// Record-keeping: a backup, a restore and an export carry the medication
|
||||
// as stored, schedule and switch included.
|
||||
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
|
||||
"lib/export/restore-backup.ts": "restore writes the stored rows back",
|
||||
"app/api/export/route.ts": "data export of the stored record",
|
||||
"app/api/export/medications/route.ts": "medication CSV export of the record",
|
||||
// Intake WRITE helpers: they run only when a dose is being recorded, bind
|
||||
// it to the slot it belongs to and consume stock. They derive nothing due.
|
||||
"lib/medications/scheduling/slot-upsert.ts":
|
||||
"binds a recorded dose to its slot",
|
||||
"lib/medications/inventory/consumption.ts":
|
||||
"units consumed by a recorded dose",
|
||||
"lib/medications/intake-slot-dedup.ts":
|
||||
"folds duplicate rows of doses already recorded",
|
||||
// Readers that hand the rows to a builder whose input TYPE requires
|
||||
// `trackIntake` and which gates on it: the per-medication compliance
|
||||
// payload (`buildCompliancePayload`, INTAKE_NOT_TRACKED) and the doctor
|
||||
// report's ledger compliance (`expectsDoses`). The report's medication
|
||||
// list shows the stored schedule as information.
|
||||
"app/api/medications/[id]/compliance/route.ts":
|
||||
"buildCompliancePayload gates on the type-required trackIntake",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"buildLedgerCompliance gates via expectsDoses; list is information",
|
||||
// The MCP medication resources list the stored schedule as information,
|
||||
// with `intakeTracked` beside it; they carry no due time or rate.
|
||||
"lib/mcp/resources.ts":
|
||||
"schedule as information, intakeTracked flag beside it",
|
||||
// The Coach's GLP-1 block reads the stored cadence as information and
|
||||
// gates its next-injection prediction on the flag inline.
|
||||
"lib/ai/coach/glp1-snapshot.ts":
|
||||
"cadence as information; prediction gated on trackIntake",
|
||||
};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Drop line and block comments so a mention in prose never counts. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function code(rel: string): string {
|
||||
return stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The code without its import statements. An import of the vocabulary is
|
||||
* not a use of it: a reader that still imports `TRACKED_INTAKE_WHERE` after
|
||||
* its filter was deleted would otherwise pass.
|
||||
*/
|
||||
function usage(rel: string): string {
|
||||
return code(rel).replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, "");
|
||||
}
|
||||
|
||||
const readers = sourceFiles().filter((rel) => SCHEDULE_READ.test(code(rel)));
|
||||
|
||||
describe("medication intake tracking — schedule readers honour the switch", () => {
|
||||
it("finds the schedule readers it exists to police", () => {
|
||||
// An empty match set would agree with any allowlist. Pin the floor and
|
||||
// the readers whose silence would matter most.
|
||||
expect(readers.length).toBeGreaterThanOrEqual(30);
|
||||
for (const known of [
|
||||
"lib/jobs/reminder/medication-reminder-check.ts",
|
||||
"lib/medications/scheduling/project-today-intakes.ts",
|
||||
"lib/dashboard/meds-today.ts",
|
||||
"lib/medications/list-read.ts",
|
||||
"lib/jobs/intake-auto-skip.ts",
|
||||
]) {
|
||||
expect(readers).toContain(known);
|
||||
}
|
||||
});
|
||||
|
||||
it("every schedule reader uses the vocabulary or is allowlisted", () => {
|
||||
const offenders = readers.filter(
|
||||
(rel) => !(rel in MAY_READ_EVERY_ROW) && !HONOURS.test(usage(rel)),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"These files read medication schedules without honouring " +
|
||||
"Medication.trackIntake. Filter with TRACKED_INTAKE_WHERE, derive " +
|
||||
"from dueSchedules(), shape the wire with scheduleWireFields(), or " +
|
||||
"add the file to MAY_READ_EVERY_ROW with the reason it derives " +
|
||||
"nothing due.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the allowlist names no file that stopped reading schedules", () => {
|
||||
const stale = Object.keys(MAY_READ_EVERY_ROW).filter(
|
||||
(rel) => !readers.includes(rel),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matchers catch the shapes they claim to", () => {
|
||||
// Defeat check: the guard must be able to fail.
|
||||
expect(SCHEDULE_READ.test("include: { schedules: true }")).toBe(true);
|
||||
expect(SCHEDULE_READ.test("schedules: {\n select: X }")).toBe(true);
|
||||
expect(
|
||||
SCHEDULE_READ.test("prisma.medicationSchedule\n .findMany({})"),
|
||||
).toBe(true);
|
||||
expect(SCHEDULE_READ.test("const schedules = m.schedules;")).toBe(false);
|
||||
expect(HONOURS.test(stripComments("// dueSchedules(m)"))).toBe(false);
|
||||
expect(HONOURS.test("where: { ...TRACKED_INTAKE_WHERE }")).toBe(true);
|
||||
// An import alone is not a use.
|
||||
const importOnly =
|
||||
'import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";\n' +
|
||||
"const rows = await prisma.medication.findMany({ where: { active: true } });";
|
||||
expect(
|
||||
HONOURS.test(
|
||||
importOnly.replace(/^import[\s\S]*?from\s+["'][^"']+["'];?$/gm, ""),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const intakeReaders = sourceFiles().filter((rel) =>
|
||||
INTAKE_READ.test(code(rel)),
|
||||
);
|
||||
|
||||
describe("medication intake tracking — intake-row readers honour the switch", () => {
|
||||
it("finds the intake readers it exists to police", () => {
|
||||
expect(intakeReaders.length).toBeGreaterThanOrEqual(30);
|
||||
for (const known of [
|
||||
"lib/jobs/coach-nudge.ts",
|
||||
"lib/insights/general-status.ts",
|
||||
"lib/analytics/correlations-fast-path.ts",
|
||||
"lib/medications/outstanding-doses.ts",
|
||||
]) {
|
||||
expect(intakeReaders).toContain(known);
|
||||
}
|
||||
});
|
||||
|
||||
it("every intake reader uses the vocabulary or is allowlisted", () => {
|
||||
const offenders = intakeReaders.filter(
|
||||
(rel) => !(rel in INTAKE_MAY_SEE_EVERY_ROW) && !HONOURS.test(usage(rel)),
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
"These files read intake rows without honouring Medication.trackIntake. " +
|
||||
"Filter with TRACKED_INTAKE_EVENT_WHERE (or read only the ids of " +
|
||||
"medications filtered with TRACKED_INTAKE_WHERE), or add the file to " +
|
||||
"INTAKE_MAY_SEE_EVERY_ROW with the reason it computes no rate.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the intake allowlist names no file that stopped reading intake rows", () => {
|
||||
expect(
|
||||
Object.keys(INTAKE_MAY_SEE_EVERY_ROW).filter(
|
||||
(rel) => !intakeReaders.includes(rel),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the intake matcher catches the shapes it claims to", () => {
|
||||
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.findMany({")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
INTAKE_READ.test("prisma.medicationIntakeEvent\n .groupBy({"),
|
||||
).toBe(true);
|
||||
expect(INTAKE_READ.test("tx.medicationIntakeEvent . count (")).toBe(true);
|
||||
expect(INTAKE_READ.test("prisma.medicationIntakeEvent.create({")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* The key-rotation walks page by `id > last`, never by Prisma's cursor.
|
||||
*
|
||||
* `cursor: { id }, skip: 1` looks the last row of a page up again. When that
|
||||
* row was hard-deleted between pages, Prisma answers the next page with
|
||||
* nothing, the walk stops early, and the scan reports a corpus smaller than
|
||||
* the one on disk, which can read as "nothing left on the old key". The
|
||||
* library walk is proven against Postgres in
|
||||
* `tests/integration/encryption-corpus-cursor-row-deleted.test.ts`; the
|
||||
* rotation script calls `process.exit` on import and cannot be driven from a
|
||||
* test, so its walks are held to the same shape here.
|
||||
*
|
||||
* Mutation check: put `cursor: { id: cursor }, skip: 1` back in either file
|
||||
* and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const WALKS = [
|
||||
"scripts/rotate-encryption-key.ts",
|
||||
"src/lib/crypto/encryption-corpus.ts",
|
||||
] as const;
|
||||
|
||||
describe("rotation walks page by key", () => {
|
||||
it.each(WALKS)("%s pages with `id > last`, not cursor + skip", (path) => {
|
||||
const code = readFileSync(path, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("//"))
|
||||
.join("\n");
|
||||
expect(code).not.toMatch(/cursor\s*:\s*\{[^}]*\}\s*,\s*skip\s*:/);
|
||||
// Non-zero proof: the walk is still there and still pages by key.
|
||||
expect(code).toMatch(/\{\s*gt\s*:\s*cursor\s*\}/);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@
|
||||
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
|
||||
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
|
||||
*
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so an eighteenth
|
||||
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
|
||||
* structured leaf cannot be added to the catalogue without deciding here
|
||||
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
|
||||
* the lock, and it is the compiler's, not this suite's.
|
||||
@@ -332,6 +332,22 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
},
|
||||
marker: "Palpitations",
|
||||
},
|
||||
SURGICAL_HISTORY: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
surgicalHistory: [
|
||||
{
|
||||
occurredAt: "2011-03-14T09:00:00.000Z",
|
||||
procedure: "Meniscus repair",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
outcome: null,
|
||||
practitionerName: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
marker: "Meniscus repair",
|
||||
},
|
||||
IMMUNIZATIONS: {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
@@ -419,7 +435,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a degraded import would satisfy every loop below
|
||||
// vacuously, which is the failure mode a pair guard dies of.
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(17);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
|
||||
[...STRUCTURED_LEAF_IDS].sort(),
|
||||
);
|
||||
|
||||
@@ -943,6 +943,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
|
||||
},
|
||||
"app/api/encounters/procedures/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's procedure and surgery history: the visits of kind PROCEDURE that happened, searched by body site. The same rows the visit list serves, read one way, so it sits under the same domain and level; the search runs over the resolved user's own rows after the decrypt and cannot address anyone else's.",
|
||||
},
|
||||
"app/api/encounters/suggest/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
|
||||
@@ -1945,8 +1949,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* The screener detail read adds one: the per-administration read that
|
||||
* decrypts the stored item answers, on the record list at the same level as
|
||||
* the screener history beside it. 229 -> 230.
|
||||
*
|
||||
* v1.39.1 -- the procedure history adds one: a read on the record list at the
|
||||
* level and domain of the visit list it is a view of. 230 -> 231.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 230;
|
||||
const FROZEN_ENTRY_COUNT = 231;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -248,6 +248,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/sections/inventory-dialogs.tsx": {
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/medications/sections/intake-tracking-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/inventory-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* v1.39.1 — every wire change in this release is additive for the iPhone app
|
||||
* already in people's hands (1.0.3 in the App Store, 1.0.4 on TestFlight).
|
||||
*
|
||||
* What changed on shapes those builds decode:
|
||||
*
|
||||
* - `AppleHealthBatchEntryResult` gained `convertedValue` and `range`, on a
|
||||
* `value_out_of_range` skip only;
|
||||
* - the batch validation 422 gained `meta.errorCode`;
|
||||
* - `ProfileResponse.timezone` now holds the resolved zone (same type);
|
||||
* - the nutrient and cycle batch responses gained a `Cache-Control` header
|
||||
* on one outcome, and the nutrient entry's description changed.
|
||||
*
|
||||
* Two directions are pinned, because "additive" is a claim about both:
|
||||
*
|
||||
* 1. A payload the new server sends still decodes with a decoder written
|
||||
* against the old shape. The decoders below are that: the fields the
|
||||
* shipped client reads non-optionally, with their types, and nothing
|
||||
* else — unknown keys are ignored, as Swift's `Decodable` ignores them.
|
||||
* 2. A payload of the old shape still satisfies the new document: no field
|
||||
* a shipped client might omit or never see became required.
|
||||
*
|
||||
* The document is built from the registry rather than read off disk;
|
||||
* `openapi:check` already pins the YAML to exactly this build.
|
||||
*
|
||||
* ## Mutation check
|
||||
*
|
||||
* Drop `.optional()` from `convertedValue` or `range` in the registry and the
|
||||
* `required` assertion goes red. Rename a field the old decoder reads and the
|
||||
* decode assertion goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
type Schema = {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, Schema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | Schema;
|
||||
$ref?: string;
|
||||
anyOf?: Schema[];
|
||||
};
|
||||
|
||||
const doc = buildOpenApiDocument() as unknown as {
|
||||
components: { schemas: Record<string, Schema> };
|
||||
};
|
||||
const schemas = doc.components.schemas;
|
||||
|
||||
function component(name: string): Schema {
|
||||
const found = schemas[name];
|
||||
if (!found) throw new Error(`component ${name} is missing`);
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── The shapes as 1.0.3 decodes them ────────────────────────────────────
|
||||
|
||||
const oldBatchEntry = z.object({
|
||||
index: z.number().int(),
|
||||
status: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const oldBatchResponse = z.object({
|
||||
data: z.object({
|
||||
processed: z.number(),
|
||||
inserted: z.number(),
|
||||
updated: z.number(),
|
||||
duplicates: z.number(),
|
||||
failed: z.number(),
|
||||
skipped: z.array(z.object({ index: z.number(), reason: z.string() })),
|
||||
entries: z.array(oldBatchEntry),
|
||||
}),
|
||||
});
|
||||
|
||||
const oldErrorEnvelope = z.object({
|
||||
data: z.null(),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
const oldProfile = z.object({
|
||||
data: z.object({
|
||||
username: z.string(),
|
||||
email: z.string().nullable(),
|
||||
timezone: z.string(),
|
||||
timeFormat: z.string(),
|
||||
dateFormat: z.string(),
|
||||
moodReminderEnabled: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
const oldNutrientResponse = z.object({
|
||||
data: z.object({
|
||||
processed: z.number(),
|
||||
inserted: z.number(),
|
||||
updated: z.number(),
|
||||
skipped: z.array(z.object({ index: z.number(), reason: z.string() })),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
index: z.number(),
|
||||
status: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
/** A v1.39.0 batch answer: no range detail anywhere. */
|
||||
const OLD_BATCH = {
|
||||
data: {
|
||||
processed: 2,
|
||||
inserted: 1,
|
||||
updated: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
skipped: [{ index: 1, reason: "value_out_of_range" }],
|
||||
entries: [
|
||||
{ index: 0, status: "inserted" },
|
||||
{ index: 1, status: "skipped", reason: "value_out_of_range" },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
/** The same batch answered by v1.39.1. */
|
||||
const NEW_BATCH = {
|
||||
data: {
|
||||
...OLD_BATCH.data,
|
||||
entries: [
|
||||
{ index: 0, status: "inserted" },
|
||||
{
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 90,
|
||||
range: { min: 1, max: 80, unit: "%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const NEW_BATCH_422 = {
|
||||
data: null,
|
||||
error: "Invalid input: expected string, received undefined",
|
||||
details: {
|
||||
issues: [{ path: ["entries", 0, "unit"], message: "Required" }],
|
||||
},
|
||||
meta: { errorCode: "measurement.batch.invalid" },
|
||||
};
|
||||
|
||||
const NEW_PROFILE = {
|
||||
data: {
|
||||
username: "someone",
|
||||
displayName: null,
|
||||
email: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: null,
|
||||
timezone: "Europe/Berlin",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
modules: {},
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const NEW_NUTRIENTS = {
|
||||
data: {
|
||||
processed: 1,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: [{ index: 0, reason: "upsert_failed" }],
|
||||
entries: [{ index: 0, status: "skipped", reason: "upsert_failed" }],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
describe("v1.39.1 — shipped decoders still decode what the server now sends", () => {
|
||||
it("the batch answer, with the new range detail", () => {
|
||||
expect(oldBatchResponse.safeParse(NEW_BATCH).success).toBe(true);
|
||||
});
|
||||
|
||||
it("the batch validation 422, with its new code", () => {
|
||||
expect(oldErrorEnvelope.safeParse(NEW_BATCH_422).success).toBe(true);
|
||||
});
|
||||
|
||||
it("the profile, whose timezone is still a string", () => {
|
||||
expect(oldProfile.safeParse(NEW_PROFILE).success).toBe(true);
|
||||
});
|
||||
|
||||
it("the nutrient batch answer carrying upsert_failed", () => {
|
||||
expect(oldNutrientResponse.safeParse(NEW_NUTRIENTS).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.39.1 — the new document still admits the old shapes", () => {
|
||||
it("adds convertedValue and range to the batch entry as optional fields", () => {
|
||||
const entry = component("AppleHealthBatchEntryResult");
|
||||
expect(entry.properties?.convertedValue?.type).toBe("number");
|
||||
expect(
|
||||
Object.keys(entry.properties?.range?.properties ?? {}).sort(),
|
||||
).toEqual(["max", "min", "unit"]);
|
||||
expect([...(entry.required ?? [])].sort()).toEqual(["index", "status"]);
|
||||
});
|
||||
|
||||
it("an old batch entry carries every field the new document requires", () => {
|
||||
const required = component("AppleHealthBatchEntryResult").required ?? [];
|
||||
for (const e of OLD_BATCH.data.entries) {
|
||||
for (const key of required) expect(e).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the error envelope's meta optional", () => {
|
||||
const envelope = component("ErrorEnvelope");
|
||||
expect(envelope.required ?? []).not.toContain("meta");
|
||||
expect(envelope.properties?.meta?.required ?? []).not.toContain(
|
||||
"errorCode",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the profile timezone a required string", () => {
|
||||
const profile = component("ProfileResponse");
|
||||
expect(profile.properties?.timezone?.type).toBe("string");
|
||||
expect(profile.required).toContain("timezone");
|
||||
});
|
||||
|
||||
it("keeps the nutrient entry result's shape", () => {
|
||||
const entry = component("NutrientEntryResult");
|
||||
expect(Object.keys(entry.properties ?? {}).sort()).toEqual([
|
||||
"index",
|
||||
"reason",
|
||||
"status",
|
||||
]);
|
||||
expect([...(entry.required ?? [])].sort()).toEqual(["index", "status"]);
|
||||
});
|
||||
|
||||
it("puts the named sleep-stage answer beside the old create response, not in place of it", () => {
|
||||
const named = component("NamedSleepStageMeasurement");
|
||||
expect(named.required).toContain("status");
|
||||
const resource = component("MeasurementResource");
|
||||
expect(resource.properties).not.toHaveProperty("status");
|
||||
});
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe("T2 — the set of files writing Workout rows is frozen", () => {
|
||||
"app/api/workouts/batch/route.ts": "client-supplied enum",
|
||||
// Backup restore replays rows this app previously wrote, provenance and
|
||||
// all.
|
||||
"app/api/admin/backups/[id]/restore/route.ts": "restore replay",
|
||||
"lib/export/restore-backup.ts": "restore replay",
|
||||
};
|
||||
|
||||
it("only the registered ingests and the declared non-mappers create Workout rows", () => {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* at-rest contract: the ciphertext stays in the DB, the plaintext is only
|
||||
* materialised inside the request handler and streamed to the admin.
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
@@ -22,8 +24,13 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
BACKUP_UNDECRYPTABLE_CODE,
|
||||
BACKUP_UNDECRYPTABLE_ERROR,
|
||||
unpackBackupBlob,
|
||||
openBackupBlob,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
type BackupSource,
|
||||
type StreamedBackup,
|
||||
} from "@/lib/export/streamed-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
@@ -54,9 +61,11 @@ export const GET = apiHandler(
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
// Opened and streamed, never unpacked into one string: a large record's
|
||||
// JSON is longer than any string V8 can hold (#1031).
|
||||
let source: BackupSource;
|
||||
try {
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
source = openBackupBlob(backup.data);
|
||||
} catch (err) {
|
||||
// A rotated or dropped key, or a stored copy that is no longer the one
|
||||
// that was written. Both are bad stored input rather than a fault in
|
||||
@@ -80,8 +89,10 @@ export const GET = apiHandler(
|
||||
// pathological case where the worker wrote something the upload
|
||||
// route would later reject — better to fail loudly here than to ship
|
||||
// a junk file that breaks an admin's restore plan.
|
||||
let streamed: StreamedBackup;
|
||||
try {
|
||||
parseBackupPayload(plaintext);
|
||||
streamed = await readStreamedBackup(source);
|
||||
parseBackupPayload(streamed.raw);
|
||||
} catch (err) {
|
||||
await auditLog("admin.backups.download.failed", {
|
||||
userId: admin.id,
|
||||
@@ -107,22 +118,26 @@ export const GET = apiHandler(
|
||||
ownerId: backup.userId,
|
||||
ownerUsername: backup.user.username,
|
||||
type: backup.type,
|
||||
sizeBytes: Buffer.byteLength(plaintext, "utf8"),
|
||||
sizeBytes: streamed.bytes,
|
||||
},
|
||||
});
|
||||
|
||||
// Returning the plaintext as-is keeps the response a faithful copy of
|
||||
// Returning the stored bytes as-is keeps the response a faithful copy of
|
||||
// what the worker wrote (formatting, key order). The file is the
|
||||
// canonical artefact admins will store / re-upload.
|
||||
return new NextResponse(plaintext, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
// Backups contain sensitive health data — keep them out of any
|
||||
// shared cache (CDN, browser disk cache).
|
||||
"cache-control": "no-store, max-age=0",
|
||||
return new NextResponse(
|
||||
Readable.toWeb(Readable.from(source())) as ReadableStream<Uint8Array>,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
// Backups contain sensitive health data — keep them out of any
|
||||
// shared cache (CDN, browser disk cache).
|
||||
"cache-control": "no-store, max-age=0",
|
||||
"content-length": String(streamed.bytes),
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -44,6 +44,11 @@ vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
|
||||
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
|
||||
isCompatibleSchemaVersion: vi.fn(() => true),
|
||||
// The measurements are read as a stream and counted one by one.
|
||||
BACKUP_SCHEMA_VERSION: "2",
|
||||
backupMeasurementSchema: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -62,9 +67,14 @@ beforeEach(() => {
|
||||
createdAt: new Date("2026-08-01T00:00:00Z"),
|
||||
user: { id: "u1", username: "self-hoster" },
|
||||
});
|
||||
decryptMock.mockReturnValue("{}");
|
||||
// Twelve measurements in the file: the preview counts them from the stream.
|
||||
decryptMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
measurements: Array.from({ length: 12 }, (_, i) => ({ id: `m${i}` })),
|
||||
}),
|
||||
);
|
||||
parseMock.mockReturnValue({ schemaVersion: 3 });
|
||||
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
|
||||
summarizeMock.mockReturnValue({ measurements: 0, moodEntries: 3 });
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups/[id]/summary", () => {
|
||||
|
||||
@@ -19,8 +19,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
BACKUP_UNDECRYPTABLE_CODE,
|
||||
BACKUP_UNDECRYPTABLE_ERROR,
|
||||
unpackBackupBlob,
|
||||
openBackupBlob,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
type BackupSource,
|
||||
} from "@/lib/export/streamed-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
@@ -46,9 +50,11 @@ export const GET = apiHandler(
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
// Opened and read as a stream: a large record's JSON is longer than any
|
||||
// string V8 can hold (#1031).
|
||||
let source: BackupSource;
|
||||
try {
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
source = openBackupBlob(backup.data);
|
||||
} catch {
|
||||
// Same refusal the restore and the download give, for the same reason:
|
||||
// a copy this instance cannot open is bad stored input, and the preview
|
||||
@@ -59,8 +65,11 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
let payload;
|
||||
let measurementCount: number;
|
||||
try {
|
||||
payload = parseBackupPayload(plaintext);
|
||||
const streamed = await readStreamedBackup(source);
|
||||
measurementCount = streamed.measurementCount;
|
||||
payload = parseBackupPayload(streamed.raw);
|
||||
} catch {
|
||||
return apiError("Backup payload failed schema validation", 422);
|
||||
}
|
||||
@@ -72,7 +81,10 @@ export const GET = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
const summary = {
|
||||
...summarizeBackup(payload),
|
||||
measurements: measurementCount,
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.backups.summary" },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* GET /api/admin/backups/restores/[jobId] — how one restore is going.
|
||||
*
|
||||
* The status, the phase and the counts while it runs; what it wrote, cleared
|
||||
* and skipped once it succeeded; a stable code and a sentence an operator can
|
||||
* act on once it failed.
|
||||
*/
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
evictAfterRestore,
|
||||
readBackupRestoreJob,
|
||||
} from "@/lib/jobs/backup-restore";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ jobId: string }> },
|
||||
) => {
|
||||
await requireAdmin();
|
||||
const { jobId } = await params;
|
||||
annotate({
|
||||
action: { name: "admin.backups.restores.read" },
|
||||
meta: { jobId },
|
||||
});
|
||||
const job = await readBackupRestoreJob(jobId);
|
||||
if (!job) throw new HttpError(404, "Restore job not found");
|
||||
evictAfterRestore(job);
|
||||
return apiSuccess(job);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* GET /api/admin/backups/restores — the restores the backups console shows.
|
||||
*
|
||||
* Every queued or running restore, and those that finished in the last day,
|
||||
* newest first. The console reads it when it opens, so a reload during a
|
||||
* restore picks the progress up again, and a restore that finished while
|
||||
* nobody was looking still says how it ended.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
evictAfterRestore,
|
||||
listRecentBackupRestoreJobs,
|
||||
} from "@/lib/jobs/backup-restore";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.restores.list" } });
|
||||
const jobs = await listRecentBackupRestoreJobs();
|
||||
for (const job of jobs) evictAfterRestore(job);
|
||||
return apiSuccess({ jobs });
|
||||
});
|
||||
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* POST /api/admin/backups/upload — admin-only ingest of a backup file.
|
||||
*
|
||||
* Accepts a `multipart/form-data` body with a single `file` field that
|
||||
* carries a JSON backup matching `backupPayloadSchema`. The route:
|
||||
* Accepts the backup file either as the raw request body (JSON, or JSON
|
||||
* compressed with gzip) or, for a small file, as a `multipart/form-data`
|
||||
* body with a single `file` field. The route:
|
||||
*
|
||||
* 1. Authenticates the caller as an admin (cookie session only).
|
||||
* 2. Reads the file (size capped — see `MAX_UPLOAD_BYTES`).
|
||||
* 3. Parses and validates the JSON against the canonical schema.
|
||||
* 4. Rejects files written by a future schema version (incompatible).
|
||||
* 5. Encrypts the plaintext and inserts a new `DataBackup` row of
|
||||
* type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
|
||||
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
|
||||
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
|
||||
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
|
||||
*
|
||||
* The file is read as a stream and never held whole (#1031): an export of an
|
||||
* account with 1.25 million measurements is a few hundred megabytes of JSON,
|
||||
* which neither fits a 1 GB container beside the running app nor, in the
|
||||
* disaster-recovery shape, fits in one JavaScript string at all. It is
|
||||
* checked as it goes and stored in the same pass; a file that fails any check
|
||||
* rolls the store back, so nothing of it remains.
|
||||
*
|
||||
* Crucially, this route does NOT execute a restore. It only stores the
|
||||
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
|
||||
* (criterion 3) actually replaces user data — keeping the two phases
|
||||
@@ -20,46 +28,124 @@
|
||||
*
|
||||
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
import { createGunzip } from "node:zlib";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { packBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
BACKUP_UPLOAD_TOO_LARGE_CODE,
|
||||
BackupBlobTooLargeError,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
||||
import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
BACKUP_SCHEMA_VERSION,
|
||||
backupMeasurementSchema,
|
||||
backupPayloadSchema,
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
type BackupPayload,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cap on uploaded file size. The largest production backups today are
|
||||
* ~2 MB; 10 MB leaves headroom while keeping a malicious admin client
|
||||
* from blowing memory by streaming a 4 GB file. Enforced by reading the
|
||||
* `Content-Length` header AND the actual buffered size after decoding —
|
||||
* a missing/wrong content-length doesn't bypass the limit.
|
||||
* Cap on a file sent as a multipart form. The form parser holds the whole
|
||||
* file in memory, so this path stays for small files only; anything larger
|
||||
* is sent as the raw request body, which is read as a stream.
|
||||
*/
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Cap on a file sent as the raw request body, compressed or not. The same
|
||||
* ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
|
||||
* `next.config.ts`), beyond which the body would arrive truncated. A
|
||||
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
|
||||
* JSON and 64 MB compressed, so large files go up compressed.
|
||||
*/
|
||||
const MAX_RAW_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
interface UploadResponse {
|
||||
id: string;
|
||||
valid: true;
|
||||
summary: BackupSummary;
|
||||
}
|
||||
|
||||
/** A file the route refuses: the answer, and what the audit row records. */
|
||||
class UploadRefused extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly answer: string,
|
||||
readonly details: Record<string, unknown>,
|
||||
readonly meta?: Record<string, unknown>,
|
||||
) {
|
||||
super(answer);
|
||||
this.name = "UploadRefused";
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
|
||||
async function* decodedBytes(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
limit: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
let seen = 0;
|
||||
async function* counted() {
|
||||
for await (const chunk of source) {
|
||||
seen += chunk.byteLength;
|
||||
if (seen > limit) {
|
||||
throw new UploadRefused(
|
||||
413,
|
||||
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
|
||||
{ reason: "file_size_exceeded", size: seen },
|
||||
);
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
const iterator = counted()[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
if (first.done) return;
|
||||
const rest = (async function* () {
|
||||
yield first.value;
|
||||
for (;;) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) return;
|
||||
yield next.value;
|
||||
}
|
||||
})();
|
||||
const head = first.value;
|
||||
if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
|
||||
const gunzip = Readable.from(rest).pipe(createGunzip());
|
||||
try {
|
||||
for await (const chunk of gunzip) yield chunk as Buffer;
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) throw err;
|
||||
throw new UploadRefused(422, "Uploaded file is not valid gzip", {
|
||||
reason: "invalid_gzip",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
yield* rest;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.upload" } });
|
||||
const ipAddress = getClientIp(request);
|
||||
|
||||
// Rate-limit identical to the manual-run endpoint. An admin uploading
|
||||
// a 10 MB blob 60× per minute would just be wasteful — three is plenty
|
||||
// for a deliberate restore-prep workflow.
|
||||
// Rate-limit identical to the manual-run endpoint. Three is plenty for a
|
||||
// deliberate restore-prep workflow.
|
||||
const rl = await checkRateLimit(
|
||||
`admin-backups-upload:${admin.id}`,
|
||||
3,
|
||||
@@ -69,120 +155,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
throw new HttpError(429, "Too many backup uploads");
|
||||
}
|
||||
|
||||
// Cheap pre-flight on the declared content length. The actual buffer
|
||||
// size check below is the hard limit; this just rejects obvious abuse
|
||||
// before allocating the FormData parser.
|
||||
const denied = (reason: string, extra: Record<string, unknown> = {}) =>
|
||||
auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress,
|
||||
details: { reason, ...extra },
|
||||
});
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const multipart = contentType.startsWith("multipart/form-data");
|
||||
const limit = multipart ? MAX_UPLOAD_BYTES : MAX_RAW_UPLOAD_BYTES;
|
||||
|
||||
// Cheap pre-flight on the declared content length. The byte count while
|
||||
// reading is the hard limit; this just rejects obvious abuse early.
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > MAX_UPLOAD_BYTES) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "content_length_exceeded", contentLength },
|
||||
});
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch (err) {
|
||||
if (contentLength > limit) {
|
||||
await denied("content_length_exceeded", { contentLength });
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "file_size_exceeded", size: file.size },
|
||||
});
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await file.text();
|
||||
} catch {
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "invalid_json",
|
||||
message: err instanceof Error ? err.message : "parse_failed",
|
||||
},
|
||||
});
|
||||
return apiError("Uploaded file is not valid JSON", 422);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(parsedJson);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "schema_invalid", issues },
|
||||
});
|
||||
return apiError("Backup payload failed schema validation", 422, {
|
||||
issues,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
422,
|
||||
let source: AsyncIterable<Uint8Array>;
|
||||
if (multipart) {
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch (err) {
|
||||
return apiError(
|
||||
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
await denied("file_size_exceeded", { size: file.size });
|
||||
return apiError("Upload exceeds 10 MB limit", 413);
|
||||
}
|
||||
source = Readable.fromWeb(
|
||||
file.stream() as Parameters<typeof Readable.fromWeb>[0],
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
await auditLog("admin.backups.upload.denied", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
reason: "owner_not_found",
|
||||
ownerId: payload.userId,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
422,
|
||||
} else {
|
||||
if (!request.body) {
|
||||
return apiError("The backup file is missing from the request body", 422);
|
||||
}
|
||||
source = Readable.fromWeb(
|
||||
request.body as Parameters<typeof Readable.fromWeb>[0],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,25 +212,161 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// intact so multiple uploads can coexist for the same user without
|
||||
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
|
||||
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
|
||||
const encrypted = packBackupBlob(JSON.stringify(payload));
|
||||
let payload: BackupPayload | undefined;
|
||||
let measurementCount = 0;
|
||||
let owner: { id: string; username: string } | null = null;
|
||||
|
||||
const created = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: uploadType,
|
||||
data: encrypted,
|
||||
},
|
||||
});
|
||||
let created: { id: string };
|
||||
try {
|
||||
created = await storeBackupBlob(
|
||||
prisma,
|
||||
() => ({ userId: owner!.id, type: uploadType }),
|
||||
async (write) => {
|
||||
// One pass: every chunk is stored as it is checked, and a check that
|
||||
// fails throws, which rolls the store back.
|
||||
async function* storedAsRead() {
|
||||
for await (const chunk of decodedBytes(source, limit)) {
|
||||
await write(Buffer.from(chunk));
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
let firstWithoutId: number | null = null;
|
||||
let scanned;
|
||||
try {
|
||||
scanned = await scanBackupJson(storedAsRead(), {
|
||||
streamKeys: new Set(["measurements"]),
|
||||
onElement: (_key, element, index) => {
|
||||
const parsed = backupMeasurementSchema.safeParse(element);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
throw new StreamedBackupInvalidError(
|
||||
["measurements", index, ...(issue?.path ?? [])].join("."),
|
||||
issue?.message ?? "invalid measurement",
|
||||
);
|
||||
}
|
||||
if (!parsed.data.id && firstWithoutId === null) {
|
||||
firstWithoutId = index;
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
const issues = [{ path: err.path, message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
measurementCount = scanned.streamedCounts.measurements ?? 0;
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(scanned.document);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
if (
|
||||
payload.schemaVersion === BACKUP_SCHEMA_VERSION &&
|
||||
firstWithoutId !== null
|
||||
) {
|
||||
const issues = [
|
||||
{
|
||||
path: `measurements.${firstWithoutId}.id`,
|
||||
message: "Canonical v2 measurements require a stable id",
|
||||
},
|
||||
];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
owner = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
{ reason: "owner_not_found", ownerId: payload.userId },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
}
|
||||
// The file was fine; its encrypted copy is larger than one stored backup
|
||||
// may be on this host (a fifth of the heap limit). That is the operator's
|
||||
// to act on, with the numbers in the message, and not a server fault for
|
||||
// the error reporter. The store rolled back, so nothing was kept.
|
||||
if (err instanceof BackupBlobTooLargeError) {
|
||||
await denied("stored_copy_too_large", {
|
||||
bytes: err.bytes,
|
||||
limitBytes: err.limitBytes,
|
||||
});
|
||||
return apiError(
|
||||
`This backup is too large to store on this server. ${err.message}`,
|
||||
413,
|
||||
{ errorCode: BACKUP_UPLOAD_TOO_LARGE_CODE },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const summary: BackupSummary = {
|
||||
...summarizeBackup(payload!),
|
||||
measurements: measurementCount,
|
||||
};
|
||||
const storedOwner = owner as { id: string; username: string } | null;
|
||||
|
||||
await auditLog("admin.backups.upload", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
ipAddress,
|
||||
details: {
|
||||
backupId: created.id,
|
||||
ownerId: owner.id,
|
||||
ownerUsername: owner.username,
|
||||
ownerId: storedOwner?.id,
|
||||
ownerUsername: storedOwner?.username,
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
counts: {
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { scoped: boolean } };
|
||||
expect(json.data.scoped).toBe(false);
|
||||
expect(whereOfLastFindMany()).toEqual({ active: true });
|
||||
expect(whereOfLastFindMany()).toEqual({ active: true, trackIntake: true });
|
||||
});
|
||||
|
||||
it("narrows the sweep to the named account", async () => {
|
||||
@@ -102,6 +102,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
|
||||
expect(json.data.scoped).toBe(true);
|
||||
expect(whereOfLastFindMany()).toEqual({
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
userId: "cku0mock0account0id",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -99,6 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
// v1.39.1 (#1033) — the manual check reminds exactly what the worker
|
||||
// reminds: never a medication with intake tracking off.
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
|
||||
...(scopedUserId ? { userId: scopedUserId } : {}),
|
||||
},
|
||||
|
||||
@@ -49,10 +49,7 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
resolveModuleMap,
|
||||
@@ -279,9 +276,7 @@ export const GET = apiHandler(async () => {
|
||||
// The zone the server cuts this account's days in: the stored zone, or
|
||||
// the instance default when the stored one is unusable. Clients bucket
|
||||
// by this value, so it must be the resolved one, not the raw column.
|
||||
timezone: isValidTimezone(user.timezone)
|
||||
? user.timezone
|
||||
: await resolveServerDefaultTimezone(),
|
||||
timezone: await resolveStoredTimezone(user.timezone),
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* `GET /api/cycle/calendar` — what a person logged on a day reaches the grid.
|
||||
*
|
||||
* The calendar used to carry flow, symptoms, BBT, the ovulation test and the
|
||||
* mucus reading, and nothing else from the day log. Intercourse, spotting, the
|
||||
* pregnancy and progesterone tests, contraception, the cervix signs and the
|
||||
* presence of a note were all dropped between the row and the wire, so a day
|
||||
* with only one of those logged looked exactly like a day with nothing logged.
|
||||
*
|
||||
* Same harness as the verdict round trip: the real exported `GET`, the real
|
||||
* builder, the real DTO mapping, the response read back as text. Two ends are
|
||||
* pinned — what the route asks Prisma for (a field that is not selected never
|
||||
* reaches the builder) and what comes back on the wire.
|
||||
*
|
||||
* The intent fields can sit in the encrypted envelope instead of the plaintext
|
||||
* columns (`sensitiveCategoryEncryption`), so one case writes them there and
|
||||
* expects the grid to read them just as the day-log read does.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { cycleCalendarDayDto } from "@/lib/openapi/routes/cycle";
|
||||
|
||||
function session() {
|
||||
return {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE",
|
||||
timezone: "UTC",
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profile() {
|
||||
return {
|
||||
userId: "user-1",
|
||||
goal: "GENERAL_HEALTH",
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
predictionEnabled: true,
|
||||
rawChartMode: false,
|
||||
secondarySymptom: "MUCUS",
|
||||
cycleTrackingEnabled: true,
|
||||
sensitiveCategoryEncryption: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** A day-log row as Prisma returns it for the route's select. */
|
||||
function logRow(date: string, over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
date,
|
||||
flow: null,
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: null,
|
||||
notesEncrypted: null,
|
||||
_count: { symptomLinks: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function callCalendar(): Promise<Record<string, unknown>[]> {
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/cycle/calendar?from=2026-09-01&to=2026-09-30",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(await res.text());
|
||||
return body.data.days as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function day(days: Record<string, unknown>[], date: string) {
|
||||
const found = days.find((d) => d.date === date);
|
||||
if (!found) throw new Error(`no grid day ${date}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(session() as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: profile(),
|
||||
} as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "cyc-1",
|
||||
userId: "user-1",
|
||||
startDate: "2025-12-29",
|
||||
endDate: null,
|
||||
periodEndDate: null,
|
||||
ovulationDate: null,
|
||||
ovulationConfirmed: false,
|
||||
isPredicted: false,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/calendar carries every per-day entry", () => {
|
||||
it("asks Prisma for the fields the grid now shows", async () => {
|
||||
await callCalendar();
|
||||
const args = vi.mocked(prisma.cycleDayLog.findMany).mock.calls[0][0] as {
|
||||
select: Record<string, unknown>;
|
||||
};
|
||||
expect(args.select).toMatchObject({
|
||||
intermenstrualBleeding: true,
|
||||
sexualActivity: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
notesEncrypted: true,
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a day with intercourse logged, and only that day", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", { sexualActivity: true }),
|
||||
] as never);
|
||||
|
||||
const days = await callCalendar();
|
||||
expect(day(days, "2026-09-05").sexualActivity).toBe(true);
|
||||
expect(day(days, "2026-09-04").sexualActivity).toBe(false);
|
||||
expect(day(days, "2026-09-06").sexualActivity).toBe(false);
|
||||
});
|
||||
|
||||
it("reads intercourse and the tests from the encrypted envelope", async () => {
|
||||
// Encryption ON at write: the plaintext columns are NULL/false and the
|
||||
// envelope carries the values. The grid must read what the day log reads.
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", {
|
||||
sexualActivity: false,
|
||||
sensitiveEncrypted: encrypt(
|
||||
JSON.stringify({
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: "POSITIVE",
|
||||
contraceptive: "ORAL",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const d = day(await callCalendar(), "2026-09-05");
|
||||
expect(d.sexualActivity).toBe(true);
|
||||
expect(d.pregnancyTest).toBe("NEGATIVE");
|
||||
expect(d.progesteroneTest).toBe("POSITIVE");
|
||||
expect(d.contraceptive).toBe("ORAL");
|
||||
});
|
||||
|
||||
it("an undecryptable envelope reads as nothing logged, never a 500", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-05", { sensitiveEncrypted: "v1:not-a-real-envelope" }),
|
||||
] as never);
|
||||
|
||||
const d = day(await callCalendar(), "2026-09-05");
|
||||
expect(d.sexualActivity).toBe(false);
|
||||
expect(d.pregnancyTest).toBeNull();
|
||||
});
|
||||
|
||||
it("carries spotting, the cervix signs and whether a note exists", async () => {
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
logRow("2026-09-10", {
|
||||
intermenstrualBleeding: true,
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
notesEncrypted: encrypt("a note"),
|
||||
}),
|
||||
] as never);
|
||||
|
||||
const days = await callCalendar();
|
||||
const d = day(days, "2026-09-10");
|
||||
expect(d.intermenstrualBleeding).toBe(true);
|
||||
expect(d.cervixPosition).toBe("HIGH");
|
||||
expect(d.cervixFirmness).toBe("SOFT");
|
||||
expect(d.cervixOpening).toBe("OPEN");
|
||||
expect(d.hasNote).toBe(true);
|
||||
// The note's text never rides the grid, only its presence.
|
||||
expect(JSON.stringify(d)).not.toContain("a note");
|
||||
expect(day(days, "2026-09-11").hasNote).toBe(false);
|
||||
});
|
||||
|
||||
it("every grid day carries exactly the fields the published contract names", async () => {
|
||||
// The contract promised the cervix signs while the route never sent them.
|
||||
// Pin the two against each other so neither can drift alone again.
|
||||
const days = await callCalendar();
|
||||
const contractKeys = Object.keys(cycleCalendarDayDto.shape).sort();
|
||||
expect(Object.keys(days[0]).sort()).toEqual(contractKeys);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toCyclePredictionDTO,
|
||||
goalAllowsFertileWindow,
|
||||
cycleDisclaimerKey,
|
||||
resolveSensitiveFields,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
@@ -115,6 +116,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
intermenstrualBleeding: true,
|
||||
// The intent fields and their encrypted envelope, resolved below
|
||||
// through the same reader the day-log route uses. Same `cycle` grant
|
||||
// as that route (see sharing-surface-guard), so the grid shows a
|
||||
// delegate nothing the day log would not.
|
||||
sexualActivity: true,
|
||||
protectedSex: true,
|
||||
pregnancyTest: true,
|
||||
progesteroneTest: true,
|
||||
contraceptive: true,
|
||||
sensitiveEncrypted: true,
|
||||
// Selected for presence only; the text is never decrypted here.
|
||||
notesEncrypted: true,
|
||||
_count: { select: { symptomLinks: true } },
|
||||
},
|
||||
}),
|
||||
@@ -141,18 +155,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
temperatureExcluded: l.temperatureExcluded,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
cervixPosition: l.cervixPosition,
|
||||
cervixFirmness: l.cervixFirmness,
|
||||
cervixOpening: l.cervixOpening,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
}));
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => {
|
||||
const sensitive = resolveSensitiveFields(l);
|
||||
return {
|
||||
date: l.date,
|
||||
flow: l.flow,
|
||||
basalBodyTempC: l.basalBodyTempC,
|
||||
temperatureExcluded: l.temperatureExcluded,
|
||||
ovulationTest: l.ovulationTest,
|
||||
cervicalMucus: l.cervicalMucus,
|
||||
cervixPosition: l.cervixPosition,
|
||||
cervixFirmness: l.cervixFirmness,
|
||||
cervixOpening: l.cervixOpening,
|
||||
intermenstrualBleeding: l.intermenstrualBleeding,
|
||||
sexualActivity: sensitive.sexualActivity,
|
||||
pregnancyTest: sensitive.pregnancyTest,
|
||||
progesteroneTest: sensitive.progesteroneTest,
|
||||
contraceptive: sensitive.contraceptive,
|
||||
hasSymptoms: l._count.symptomLinks > 0,
|
||||
hasNote: l.notesEncrypted != null && l.notesEncrypted !== "",
|
||||
};
|
||||
});
|
||||
|
||||
const nights = nightlyTemps.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, tz),
|
||||
|
||||
@@ -203,3 +203,82 @@ describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () =>
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — refusal codes", () => {
|
||||
/**
|
||||
* The native client files rows under a permanent-refusal register as soon
|
||||
* as it can tell the refusal is permanent. It can only tell from the code,
|
||||
* so both whole-batch 422s are pinned here, with the envelope they ride.
|
||||
*/
|
||||
async function refusal(body: unknown) {
|
||||
const res = await POST(postReq(body));
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details?: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("names a malformed batch cycle.bulk.invalid, with every issue listed", async () => {
|
||||
const { status, body } = await refusal({
|
||||
entries: [{ date: "not-a-day" }, { date: "2026-13-40" }],
|
||||
});
|
||||
expect(status).toBe(422);
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
expect(body.details?.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a batch with no entries array cycle.bulk.invalid", async () => {
|
||||
const { status, body } = await refusal({});
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
});
|
||||
|
||||
it("names an over-cap batch cycle.bulk.too_large", async () => {
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
date: "2026-01-01",
|
||||
externalId: `e${i}`,
|
||||
}));
|
||||
const { status, body } = await refusal({ entries });
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.too_large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — a failed write is not cached", () => {
|
||||
it("marks a response carrying upsert_failed no-store and leaves a clean one alone", async () => {
|
||||
vi.mocked(upsertCycleDayLog).mockRejectedValueOnce(new Error("reset"));
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-01", loggedAt: "2026-01-01T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
const body = (await failed.json()) as {
|
||||
data: { entries: Array<{ status: string; reason?: string }> };
|
||||
};
|
||||
expect(body.data.entries[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "upsert_failed",
|
||||
});
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
|
||||
vi.mocked(upsertCycleDayLog).mockResolvedValueOnce({
|
||||
id: "d1",
|
||||
existed: false,
|
||||
changed: true,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-02", loggedAt: "2026-01-02T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
@@ -257,4 +257,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
// `upsert_failed` is a write that did not happen, not a verdict on the
|
||||
// entry, and the client sends it again. `no-store` keeps the idempotency
|
||||
// layer from caching this answer, so a retry under the same key writes
|
||||
// instead of replaying the failure.
|
||||
if (results.some((r) => r.reason === "upsert_failed")) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -170,6 +170,12 @@ export const PATCH = apiHandler(
|
||||
? encryptToBytes(entry.outcome)
|
||||
: null;
|
||||
}
|
||||
if (entry.bodySite !== undefined) {
|
||||
data.bodySiteEncrypted = entry.bodySite?.trim()
|
||||
? encryptToBytes(entry.bodySite.trim())
|
||||
: null;
|
||||
}
|
||||
if (entry.laterality !== undefined) data.laterality = entry.laterality;
|
||||
if (entry.practitionerId !== undefined) {
|
||||
data.practitioner = practitionerId
|
||||
? { connect: { id: practitionerId } }
|
||||
@@ -331,12 +337,14 @@ export const PATCH = apiHandler(
|
||||
occurredAt: existing.occurredAt,
|
||||
status: existing.status,
|
||||
kind: existing.kind,
|
||||
laterality: existing.laterality,
|
||||
practitionerId: existing.practitionerId,
|
||||
},
|
||||
after: {
|
||||
occurredAt: outcome.updated.occurredAt,
|
||||
status: outcome.updated.status,
|
||||
kind: outcome.updated.kind,
|
||||
laterality: outcome.updated.laterality,
|
||||
practitionerId: outcome.updated.practitionerId,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* `GET /api/encounters/procedures` — the procedure history, searched after the
|
||||
* decrypt.
|
||||
*
|
||||
* The body site is ciphertext in the fixture as it is in the database, so a
|
||||
* route that searched the stored bytes instead of the decrypted text would
|
||||
* find nothing and go red here. The fake honours nothing about the `where`, so
|
||||
* the test asserts the `where` it was handed directly: a procedure history
|
||||
* that forgot the kind or the status filter would otherwise pass against a
|
||||
* fixture of procedures only.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - drop `kind: "PROCEDURE"` from the read → "reads only procedures that
|
||||
* happened" goes red;
|
||||
* - compute the facets over the filtered list → "offers every body site
|
||||
* whatever the filter" goes red with one facet;
|
||||
* - search `reasonEncrypted` bytes instead of the DTO → the knee search
|
||||
* returns nothing and goes red.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
encounterFindMany: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
requireRecordAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireRecordAuth: mocks.requireRecordAuth,
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => undefined,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
encounter: { findMany: mocks.encounterFindMany },
|
||||
user: { findUnique: mocks.userFindUnique },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/sharing/acting-domains", () => ({
|
||||
actingDomainVisibility: vi.fn(async () => () => true),
|
||||
}));
|
||||
vi.mock("@/lib/links", () => ({
|
||||
listTargets: vi.fn(async () => []),
|
||||
listTargetsBySource: vi.fn(async () => new Map()),
|
||||
replaceTargets: vi.fn(),
|
||||
}));
|
||||
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { GET } from "../route";
|
||||
|
||||
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function request(query = ""): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/encounters/procedures${query ? `?${query}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
day: number,
|
||||
bodySite: string | null,
|
||||
laterality: "LEFT" | "RIGHT" | "BOTH" | null,
|
||||
reason: string | null,
|
||||
) {
|
||||
const at = new Date(Date.UTC(2020, 0, day));
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
occurredAt: at,
|
||||
status: "DONE",
|
||||
kind: "PROCEDURE",
|
||||
practitionerId: null,
|
||||
practitioner: null,
|
||||
reminder: null,
|
||||
reminderId: null,
|
||||
reasonEncrypted: reason ? encryptToBytes(reason) : null,
|
||||
outcomeEncrypted: null,
|
||||
bodySiteEncrypted: bodySite ? encryptToBytes(bodySite) : null,
|
||||
laterality,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
deletedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface Body {
|
||||
data: {
|
||||
procedures: Array<{ id: string; bodySite: string | null }>;
|
||||
bodySites: Array<{ bodySite: string; laterality: string | null }>;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
});
|
||||
mocks.userFindUnique.mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
});
|
||||
mocks.encounterFindMany.mockResolvedValue([
|
||||
row("p3", 30, "Knie", "RIGHT", "Arthroskopie"),
|
||||
row("p2", 20, "Gallenblase", null, "Cholezystektomie"),
|
||||
row("p1", 10, "Knie", "LEFT", "Meniskus"),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("GET /api/encounters/procedures", () => {
|
||||
it("reads the health background at READ", async () => {
|
||||
await handler(request());
|
||||
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "profile");
|
||||
});
|
||||
|
||||
it("reads only procedures that happened, for the caller", async () => {
|
||||
await handler(request());
|
||||
const args = mocks.encounterFindMany.mock.calls[0][0];
|
||||
expect(args.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
kind: "PROCEDURE",
|
||||
status: "DONE",
|
||||
});
|
||||
expect(args.orderBy).toEqual({ occurredAt: "desc" });
|
||||
expect(args.take).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns the whole history, newest first, with the decrypted site", async () => {
|
||||
const res = await handler(request());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3", "p2", "p1"]);
|
||||
expect(body.data.procedures[0].bodySite).toBe("Knie");
|
||||
expect(body.data.total).toBe(3);
|
||||
});
|
||||
|
||||
it("searches the decrypted site together with the side, in the owner's language", async () => {
|
||||
const res = await handler(request("q=knie%20links"));
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("finds the English side word as well", async () => {
|
||||
const res = await handler(request("q=left"));
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.data.procedures.map((p) => p.id)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("filters the side exactly", async () => {
|
||||
const res = await handler(request("laterality=RIGHT"));
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.data.procedures.map((p) => p.id)).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("offers every body site whatever the filter", async () => {
|
||||
const res = await handler(request("q=galle"));
|
||||
const body = (await res.json()) as Body;
|
||||
expect(body.data.procedures.map((p) => p.id)).toEqual(["p2"]);
|
||||
expect(body.data.bodySites).toHaveLength(3);
|
||||
expect(body.data.total).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses an unknown side with 422", async () => {
|
||||
const res = await handler(request("laterality=UP"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.encounterFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `GET /api/encounters/procedures` — the procedure and surgery history.
|
||||
*
|
||||
* Every visit filed as PROCEDURE that happened, newest first, with the body
|
||||
* sites it holds as filter choices. `q` searches the body site, the side and
|
||||
* the reason; `laterality` filters the side exactly. Both run after the
|
||||
* decrypt, because the body site is ciphertext at rest; the reasoning is
|
||||
* written at `Encounter.bodySiteEncrypted` and in
|
||||
* `src/lib/encounters/procedures.ts`.
|
||||
*
|
||||
* Same section and level as the visit list (`("read", "profile")`): a
|
||||
* procedure is a visit, and a grant that reads the visits reads this.
|
||||
* `userId` is narrowed from auth and fed to the Prisma `where`.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { procedureListQuerySchema } from "@/lib/validations/encounters";
|
||||
import { resolveOwnerNotificationContext } from "@/lib/encounters/service";
|
||||
import { loadProcedureHistory } from "@/lib/encounters/procedures";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = procedureListQuerySchema.safeParse({
|
||||
q: params.get("q") ?? undefined,
|
||||
laterality: params.get("laterality") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "encounter.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
// The side words are the record owner's, like every other label a visit
|
||||
// resolves: a delegate searching somebody's history types in that person's
|
||||
// record, and the English word is matched as well either way.
|
||||
const { locale } = await resolveOwnerNotificationContext(user.id);
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const body = await loadProcedureHistory(prisma, user.id, parsed.data, {
|
||||
locale,
|
||||
visible,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "encounter.procedure.list", entity_type: "encounter" },
|
||||
meta: {
|
||||
total: body.total,
|
||||
matched: body.procedures.length,
|
||||
filtered: Boolean(parsed.data.q?.trim() || parsed.data.laterality),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
@@ -216,6 +216,10 @@ async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
practitionerId: practitioner?.id ?? null,
|
||||
reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null,
|
||||
outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null,
|
||||
bodySiteEncrypted: entry.bodySite?.trim()
|
||||
? encryptToBytes(entry.bodySite.trim())
|
||||
: null,
|
||||
laterality: entry.laterality ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -64,6 +64,29 @@ vi.mock("@/lib/db", () => ({
|
||||
documentConditionLink: { findMany: vi.fn() },
|
||||
extractedFact: { findMany: vi.fn() },
|
||||
ecgRecording: { findMany: vi.fn() },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -177,6 +200,7 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
@@ -192,7 +216,13 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
|
||||
it("/api/export/full-backup scopes the measurement read to deletedAt: null", async () => {
|
||||
const { GET } = await import("../full-backup/route");
|
||||
await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
// The file is written into the response as it is produced, so the reads
|
||||
// happen while the body is consumed. The mocked client does not cover
|
||||
// every section the builder reads, so the body errors part-way (as the
|
||||
// whole response did before it streamed); the reads asserted here have
|
||||
// been issued by then.
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
await res.text();
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ deletedAt: null }),
|
||||
@@ -202,7 +232,10 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
|
||||
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
|
||||
const { GET } = await import("../full-backup/route");
|
||||
await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
// The file is written into the response as it is produced, so the reads
|
||||
// happen while the body is consumed.
|
||||
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
|
||||
await res.text();
|
||||
|
||||
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -42,13 +42,44 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readdir, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import {
|
||||
encryptArchive,
|
||||
encryptArchiveToFile,
|
||||
MIN_EXPORT_PASSPHRASE_LENGTH,
|
||||
type SpooledArchive,
|
||||
} from "@/lib/export/passphrase-archive";
|
||||
import { streamToResponseBody } from "@/lib/export/response-stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SPOOL_PREFIX = "healthlog-export-";
|
||||
|
||||
/**
|
||||
* Remove spool files an earlier export left behind: a process that died
|
||||
* mid-export never reached its cleanup. An hour is far longer than any export
|
||||
* takes, so nothing still being written or sent is touched.
|
||||
*/
|
||||
async function removeStaleSpools(): Promise<void> {
|
||||
try {
|
||||
const dir = tmpdir();
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
for (const name of await readdir(dir)) {
|
||||
if (!name.startsWith(SPOOL_PREFIX)) continue;
|
||||
const path = join(dir, name);
|
||||
const info = await stat(path).catch(() => null);
|
||||
if (info && info.mtimeMs < cutoff) await rm(path, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// Housekeeping only; an export must not fail over it.
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedExportSchema = z
|
||||
.object({
|
||||
passphrase: z
|
||||
@@ -89,10 +120,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const { passphrase } = parsed.data;
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
|
||||
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
|
||||
// Sealed as it is produced and spooled to a temporary file, then sent: the
|
||||
// format puts the tag in front of the ciphertext, so the archive cannot go
|
||||
// out before its last byte is encrypted, and holding it in memory is what
|
||||
// took the app down on an account of 1.25 million measurements (#1031). Only
|
||||
// ciphertext touches the disk, and the file is removed once it is sent or
|
||||
// the client leaves.
|
||||
let counts: FullBackupCounts | undefined;
|
||||
await removeStaleSpools();
|
||||
const bodyPath = join(
|
||||
tmpdir(),
|
||||
`${SPOOL_PREFIX}${randomBytes(12).toString("hex")}.hlx.part`,
|
||||
);
|
||||
let archive: SpooledArchive;
|
||||
try {
|
||||
archive = await encryptArchiveToFile(
|
||||
async (write) => {
|
||||
counts = await streamFullBackupJson(prisma, user.id, write);
|
||||
},
|
||||
passphrase,
|
||||
bodyPath,
|
||||
);
|
||||
} catch (err) {
|
||||
await rm(bodyPath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditLog("user.export.encrypted", {
|
||||
userId: user.id,
|
||||
@@ -102,38 +154,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_medication_side_effect_count: counts.medicationSideEffects,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_lab_result_count: counts.labResults,
|
||||
export_biomarker_count: counts.biomarkers,
|
||||
export_illness_episode_count: counts.illnessEpisodes,
|
||||
export_illness_day_log_count: counts.illnessDayLogs,
|
||||
export_allergy_count: counts.allergies,
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_intraday_profile_count: counts.intradayProfiles,
|
||||
export_measurements_count: counts?.measurements,
|
||||
export_medications_count: counts?.medications,
|
||||
export_intake_count: counts?.intakeEvents,
|
||||
export_mood_count: counts?.moodEntries,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
const archiveBody = streamToResponseBody(
|
||||
async (write) => {
|
||||
await write(archive.prefix);
|
||||
for await (const chunk of createReadStream(archive.bodyPath)) {
|
||||
await write(chunk as Buffer);
|
||||
}
|
||||
},
|
||||
{
|
||||
onComplete: () => rm(archive.bodyPath, { force: true }),
|
||||
onError: () => rm(archive.bodyPath, { force: true }),
|
||||
},
|
||||
);
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
|
||||
// self-contained `.hlx` archive openable with the user's passphrase via
|
||||
// scripts/decrypt-export.ts.
|
||||
return new NextResponse(new Uint8Array(archive), {
|
||||
return new NextResponse(archiveBody, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(archive.byteLength),
|
||||
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
|
||||
@@ -35,7 +35,9 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { streamToResponseBody } from "@/lib/export/response-stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -47,48 +49,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// v1.23 — the payload builder is shared with the passphrase-encrypted
|
||||
// export route so both emit the byte-for-byte same shape (the measurement/
|
||||
// medication/mood/cycle domains are restore-compatible; the newer records
|
||||
// domains are export-only for now — see the module doc comment above).
|
||||
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
|
||||
|
||||
await auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { counts },
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_medication_side_effect_count: counts.medicationSideEffects,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
export_lab_result_count: counts.labResults,
|
||||
export_biomarker_count: counts.biomarkers,
|
||||
export_illness_episode_count: counts.illnessEpisodes,
|
||||
export_illness_day_log_count: counts.illnessDayLogs,
|
||||
export_allergy_count: counts.allergies,
|
||||
export_family_history_count: counts.familyHistory,
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_intraday_profile_count: counts.intradayProfiles,
|
||||
// Written into the response as it is produced (`streamFullBackupJson`), not
|
||||
// built and stringified first: for an account of 1.25 million measurements
|
||||
// the built payload and its JSON string did not fit a 1 GB container, and
|
||||
// the export took the whole app down (#1031). The file is byte for byte
|
||||
// what `JSON.stringify` of the payload builder's object gives; the passphrase
|
||||
// export beside this route writes the same bytes, encrypted.
|
||||
//
|
||||
// The counts are only known once the last row has gone out, so the audit
|
||||
// row is written then. A file that stops early is audited as a failure.
|
||||
const ipAddress = getClientIp(request);
|
||||
let counts: FullBackupCounts | undefined;
|
||||
const body = streamToResponseBody(
|
||||
async (write) => {
|
||||
counts = await streamFullBackupJson(prisma, user.id, write);
|
||||
},
|
||||
});
|
||||
{
|
||||
onComplete: () =>
|
||||
auditLog("user.export.full-backup", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
details: { counts },
|
||||
}),
|
||||
onError: (err) =>
|
||||
auditLog("user.export.full-backup.failed", {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
details: {
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
// Stream the JSON directly (NOT wrapped in the apiSuccess envelope) so
|
||||
// the file is a self-contained backup — admin upload + restore expect
|
||||
// the raw payload, not `{ data: { ... } }`.
|
||||
return new NextResponse(JSON.stringify(payload), {
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
|
||||
@@ -66,6 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
|
||||
@@ -101,7 +102,12 @@ export const GET = apiHandler(async () => {
|
||||
prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId: user.id, active: true, asNeeded: false },
|
||||
where: {
|
||||
userId: user.id,
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
...TRACKED_INTAKE_WHERE,
|
||||
},
|
||||
include: {
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach the engine like every other surface.
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -381,7 +382,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* `POST /api/measurements/batch` — what a refusal tells the client.
|
||||
*
|
||||
* Two gaps the native client reported against v1.39.0:
|
||||
*
|
||||
* - The whole-batch validation 422 carried no `meta.errorCode`, so the
|
||||
* client could not tell a permanent refusal from a transient one and had
|
||||
* to hold its anchor for several syncs before giving up on the rows.
|
||||
* The too-large and source refusals already had codes; this one now has
|
||||
* `measurement.batch.invalid`.
|
||||
* - A `value_out_of_range` skip said neither what the value became after
|
||||
* conversion nor which band it broke, so a unit-mapping slip (a fraction
|
||||
* sent where a percent was expected) looked the same as a sensor glitch.
|
||||
* The entry now carries `convertedValue` and `range`.
|
||||
*
|
||||
* Both are additive: status codes, the envelope and every existing field
|
||||
* are unchanged.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
return (fn as (tx: unknown) => unknown)(prisma);
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: vi.fn(
|
||||
async (_tx: unknown, input: Record<string, unknown>) => ({
|
||||
status: "inserted",
|
||||
row: { id: "row-1", ...input },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
|
||||
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({
|
||||
emitDataArrival: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const AT = new Date(Date.now() - 3_600_000).toISOString();
|
||||
|
||||
function entry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 64,
|
||||
unit: "count/min",
|
||||
startDate: AT,
|
||||
endDate: AT,
|
||||
externalId: "uuid-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type EntryResult = {
|
||||
index: number;
|
||||
status: string;
|
||||
reason?: string;
|
||||
convertedValue?: number;
|
||||
range?: { min: number; max: number; unit: string };
|
||||
};
|
||||
|
||||
async function entriesFor(entries: unknown[]): Promise<EntryResult[]> {
|
||||
const res = await post({ entries });
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { entries: EntryResult[] } }).data
|
||||
.entries;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — validation refusal code", () => {
|
||||
it("names a malformed batch measurement.batch.invalid and keeps the 422 envelope", async () => {
|
||||
const res = await post({ entries: [entry({ startDate: "yesterday" })] });
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.details.issues.length).toBeGreaterThan(0);
|
||||
expect(body.meta?.errorCode).toBe("measurement.batch.invalid");
|
||||
});
|
||||
|
||||
it("names an empty entries array the same way", async () => {
|
||||
const res = await post({ entries: [] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(
|
||||
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
|
||||
).toBe("measurement.batch.invalid");
|
||||
});
|
||||
|
||||
it("keeps measurement.batch.too_large for an over-cap batch", async () => {
|
||||
const entries = Array.from({ length: 501 }, (_, i) =>
|
||||
entry({ externalId: `uuid-${i}` }),
|
||||
);
|
||||
const res = await post({ entries });
|
||||
expect(res.status).toBe(422);
|
||||
expect(
|
||||
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
|
||||
).toBe("measurement.batch.too_large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — value_out_of_range detail", () => {
|
||||
it("reports the converted value and the band it broke, in the stored unit", async () => {
|
||||
// HealthKit reports body fat as a fraction; 0.9 becomes 90 %, above 80.
|
||||
const [result] = await entriesFor([
|
||||
entry({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
|
||||
value: 0.9,
|
||||
unit: "%",
|
||||
}),
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
index: 0,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 90,
|
||||
range: { min: 1, max: 80, unit: "%" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the lower edge too", async () => {
|
||||
const [result] = await entriesFor([entry({ value: 12 })]);
|
||||
expect(result).toMatchObject({
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 12,
|
||||
range: { min: 20, max: 300, unit: "bpm" },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the same detail when the write-side gate is the one that refuses", async () => {
|
||||
vi.mocked(reconcileExternalMeasurement).mockResolvedValueOnce({
|
||||
status: "rejected_range",
|
||||
rejection: { type: "PULSE", direction: "above_max" },
|
||||
});
|
||||
const [result] = await entriesFor([entry({ value: 64 })]);
|
||||
expect(result).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 64,
|
||||
range: { min: 20, max: 300, unit: "bpm" },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds nothing to an entry that landed or was skipped for another reason", async () => {
|
||||
const results = await entriesFor([
|
||||
entry({ externalId: "uuid-ok" }),
|
||||
entry({ hkIdentifier: "HKQuantityTypeIdentifierNotAThing" }),
|
||||
]);
|
||||
expect(results[0]).toEqual({ index: 0, status: "inserted" });
|
||||
expect(results[1]).toEqual({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unmappable_identifier",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,11 @@ import {
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import {
|
||||
VALUE_RANGES,
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
@@ -217,6 +221,36 @@ interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
/**
|
||||
* Only on a `value_out_of_range` skip: the value after conversion into the
|
||||
* stored unit, which is what the band was checked against.
|
||||
*/
|
||||
convertedValue?: number;
|
||||
/** Only on a `value_out_of_range` skip: the band applied, in the stored unit. */
|
||||
range?: { min: number; max: number; unit: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `value_out_of_range` skip, with what a client needs to say which limit
|
||||
* applied. The value is the entry's own reading after conversion, handed back
|
||||
* to the account that sent it; a fraction sent where a percent was expected
|
||||
* shows up here as a converted value a hundred times too small or too large.
|
||||
*/
|
||||
function outOfRangeResult(
|
||||
index: number,
|
||||
type: string,
|
||||
convertedValue: number,
|
||||
): EntryResult {
|
||||
const band = VALUE_RANGES[type];
|
||||
return {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
...(Number.isFinite(convertedValue) ? { convertedValue } : {}),
|
||||
...(band
|
||||
? { range: { min: band.min, max: band.max, unit: getUnitForType(type) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
@@ -277,10 +311,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = batchPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
// The code is what lets a client tell this refusal from a transient one:
|
||||
// the batch as sent will never be accepted, so resending it is pointless.
|
||||
return apiValidationError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid batch",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "measurement.batch.invalid" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,11 +410,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// sensor reading shouldn't poison an otherwise clean ingest.
|
||||
const rangeError = validateMeasurementRange(mapped.type, mapped.value);
|
||||
if (rangeError !== null) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
};
|
||||
results[index] = outOfRangeResult(index, mapped.type, mapped.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -678,11 +711,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// already applied above, so reaching here means the two disagreed —
|
||||
// report it exactly as the earlier guard would have, and let the
|
||||
// wide-event tally record which end refused.
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
};
|
||||
results[p.index] = outOfRangeResult(
|
||||
p.index,
|
||||
p.row.type as string,
|
||||
p.row.value as number,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,21 @@ import {
|
||||
pickMainNightAndNaps,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
isNamedSleepStageBody,
|
||||
sleepStageEntrySchema,
|
||||
toSleepStageSegment,
|
||||
} from "@/lib/validations/sleep-stage-entry";
|
||||
import {
|
||||
MeasurementReconciliationError,
|
||||
reconcileExternalMeasurement,
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
isMergeableSource,
|
||||
isSameReadingAcrossSource,
|
||||
oppositeMergeSource,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -765,6 +780,13 @@ async function postMeasurement(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// One sleep-stage segment, the stage named rather than coded. A bridge
|
||||
// (Tasker, Home Assistant) posts here with its narrow token; the row and
|
||||
// its dedupe are the batch route's. See `sleep-stage-entry.ts`.
|
||||
if (isNamedSleepStageBody(body)) {
|
||||
return postNamedSleepStage(request, user.id, body, scoped);
|
||||
}
|
||||
|
||||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||||
if (Array.isArray(body)) {
|
||||
const parsed = createBatchMeasurementSchema.safeParse({
|
||||
@@ -1084,3 +1106,177 @@ async function postMeasurement(request: NextRequest) {
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/measurements` with a named `sleepStage`: one segment, stored as
|
||||
* the batch route stores a HealthKit sleep sample.
|
||||
*
|
||||
* The same three steps as the batch route, in the same order: a same-reading
|
||||
* row of the opposite source (MANUAL against APPLE_HEALTH) makes this one a
|
||||
* duplicate; otherwise `reconcileExternalMeasurement` settles both unique
|
||||
* identities at once, reporting an exact external-id match as a duplicate
|
||||
* and adopting a row that only shares the natural identity. So a bridge that
|
||||
* re-posts the same segment gets one row, whichever path it used first.
|
||||
*
|
||||
* `201` with the stored row when it was inserted, `200` when it was already
|
||||
* there (`duplicate`) or was corrected in place (`updated`). The row carries
|
||||
* a `status` saying which.
|
||||
*/
|
||||
async function postNamedSleepStage(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
body: unknown,
|
||||
scoped: boolean,
|
||||
): Promise<Response> {
|
||||
const parsed = sleepStageEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.create.sleep-stage.validation-failed" },
|
||||
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const segment = toSleepStageSegment(parsed.data);
|
||||
// Under a scoped credential the source is resolved from the credential,
|
||||
// as on the other arms of this route: the refusal above already returned
|
||||
// on any body that named one.
|
||||
const source = (
|
||||
scoped ? EXTERNAL_SOURCE : parsed.data.source
|
||||
) as MeasurementSource;
|
||||
const type: MeasurementType = "SLEEP_DURATION";
|
||||
|
||||
// Cross-source same reading, exactly as the batch route decides it.
|
||||
if (isMergeableSource(source)) {
|
||||
const candidates = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
type,
|
||||
source: oppositeMergeSource(source),
|
||||
measuredAt: {
|
||||
gte: new Date(segment.end.getTime() - MEASURED_AT_TOLERANCE_MS),
|
||||
lte: new Date(segment.end.getTime() + MEASURED_AT_TOLERANCE_MS),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
source: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
const twin = candidates.find((candidate) =>
|
||||
isSameReadingAcrossSource(
|
||||
{ type, source, value: segment.minutes, measuredAt: segment.end },
|
||||
candidate,
|
||||
),
|
||||
);
|
||||
if (twin) {
|
||||
return namedSleepStageResponse(twin.id, "duplicate", {
|
||||
stage: segment.stage,
|
||||
source,
|
||||
crossSource: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = await prisma.$transaction((tx) =>
|
||||
reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
value: segment.minutes,
|
||||
unit: getUnitForType(type),
|
||||
source,
|
||||
measuredAt: segment.end,
|
||||
externalId: segment.externalId,
|
||||
externalSourceVersion: null,
|
||||
sleepStage: segment.stage,
|
||||
deviceType: parsed.data.deviceType ?? null,
|
||||
},
|
||||
{ exactExternalMatch: "duplicate" },
|
||||
),
|
||||
);
|
||||
|
||||
if (verdict.status === "failed") {
|
||||
throw new MeasurementReconciliationError(verdict);
|
||||
}
|
||||
if (verdict.status === "rejected_range") {
|
||||
// The schema applies the same band, so this is the two gates disagreeing.
|
||||
return apiError("Value out of plausible range", 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const status =
|
||||
verdict.status === "inserted"
|
||||
? "inserted"
|
||||
: verdict.status === "duplicate"
|
||||
? "duplicate"
|
||||
: "updated";
|
||||
|
||||
if (status !== "duplicate") {
|
||||
await auditLog("measurement.create", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
type,
|
||||
measurementId: verdict.row.id,
|
||||
sleepStage: segment.stage,
|
||||
status,
|
||||
},
|
||||
});
|
||||
invalidateUserMeasurements(userId, { evict: true });
|
||||
fireAndForget(enqueueReminderSatisfy(userId), {
|
||||
action: "reminder.satisfy.enqueue",
|
||||
});
|
||||
if (status === "inserted") {
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
[verdict.row],
|
||||
"manual",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
|
||||
}
|
||||
await afterMeasurementMutation(userId, [
|
||||
{ type, measuredAt: segment.end },
|
||||
...(verdict.dirtyIdentities ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
return namedSleepStageResponse(verdict.row.id, status, {
|
||||
stage: segment.stage,
|
||||
source,
|
||||
crossSource: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function namedSleepStageResponse(
|
||||
id: string,
|
||||
status: "inserted" | "updated" | "duplicate",
|
||||
meta: { stage: string; source: string; crossSource: boolean },
|
||||
): Promise<Response> {
|
||||
annotate({
|
||||
action: {
|
||||
name: "measurement.create.sleep-stage",
|
||||
entity_type: "measurement",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
status,
|
||||
stage: meta.stage,
|
||||
source: meta.source,
|
||||
cross_source_merge: meta.crossSource,
|
||||
},
|
||||
});
|
||||
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
|
||||
return apiSuccess(
|
||||
{ ...shapeMeasurementNotes(row), status },
|
||||
status === "inserted" ? 201 : 200,
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,10 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
medicationPauseEra: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
@@ -776,3 +780,336 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
const LIVE_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
function arrange(trackIntake: boolean) {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
trackIntake,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
trackIntake: !trackIntake,
|
||||
unitsPerDose: 1,
|
||||
schedules: [{ ...LIVE_ROW, unitsPerDose: null }],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
LIVE_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
|
||||
vi.mocked(prisma.medication.update).mockClear();
|
||||
}
|
||||
|
||||
it("switching off archives the schedule in force and tombstones open slots", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq({ trackIntake: false }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The tracked stretch keeps its expected doses: the archived era
|
||||
// carries the live schedule and ends now.
|
||||
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(revisions).toHaveLength(1);
|
||||
const era = revisions[0][0].data as {
|
||||
payload: Array<{ timesOfDay: string[] }>;
|
||||
validFrom: Date;
|
||||
};
|
||||
expect(era.payload).toHaveLength(1);
|
||||
expect(era.payload[0].timesOfDay).toEqual(["08:00"]);
|
||||
expect(era.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
|
||||
|
||||
// Open placeholders of today and later go; actioned rows never do.
|
||||
const tombstones = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
|
||||
.calls;
|
||||
expect(tombstones).toHaveLength(1);
|
||||
expect(tombstones[0][0].where).toMatchObject({
|
||||
medicationId: "m1",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
// The flag is written field-by-field; the schedule rows are kept.
|
||||
const update = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
expect(update.data).toMatchObject({ trackIntake: false });
|
||||
expect(update.data).not.toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
|
||||
// The wire serves it like an as-needed medication, schedule on record.
|
||||
const body = (await res.json()) as {
|
||||
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
|
||||
};
|
||||
expect(body.data.schedules).toEqual([]);
|
||||
expect(body.data.recordedSchedules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("switching back on archives the untracked stretch as expecting nothing", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(revisions).toHaveLength(1);
|
||||
const era = revisions[0][0].data as { payload: unknown[] };
|
||||
// No retroactive misses: the stretch that was off expected no dose.
|
||||
expect(era.payload).toEqual([]);
|
||||
// Nothing is tombstoned on the way back on.
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
const body = (await res.json()) as {
|
||||
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
|
||||
};
|
||||
expect(body.data.schedules).toHaveLength(1);
|
||||
expect(body.data).not.toHaveProperty("recordedSchedules");
|
||||
});
|
||||
|
||||
it("an unchanged flag archives nothing", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a schedule edit while untracked archives the stretch as empty", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
// The web editor names the flag on a record-only schedule save.
|
||||
trackIntake: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const era = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls[0][0].data as { payload: unknown[] };
|
||||
expect(era.payload).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1033 — the shipped iPhone app (1.0.3 / 1.0.4) cannot see the schedule of a
|
||||
* record-only medication (it is served with `schedules: []`). These are the
|
||||
* exact bodies its edit sheet sends (`EditMedicationSheet.save()` →
|
||||
* `MedicationPatch`, nil keys omitted): it never names `trackIntake`, always
|
||||
* sends `active` and `notificationsEnabled`, and sends the schedule shape
|
||||
* only when a schedule field was touched, rebuilt from the "daily at 08:00"
|
||||
* default its form shows for a medication without schedule rows.
|
||||
*/
|
||||
describe("PUT /api/medications/[id] — shipped client on a record-only medication (#1033)", () => {
|
||||
const STORED_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "21:00",
|
||||
windowEnd: "21:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
// `name`/`dose`/`treatmentClass`/`category`/`active`/`notificationsEnabled`/
|
||||
// `deliveryForm` ride every save; `dosesPerUnit` is nil for an empty field.
|
||||
const IOS_RENAME = {
|
||||
name: "Atorvastatin 20",
|
||||
dose: "20 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
deliveryForm: "ORAL",
|
||||
};
|
||||
// The same save after the dose time was moved from the shown 08:00 default
|
||||
// to 09:00: `scheduleWrite(.daily)` + `oneShot` + `startsOn` + `asNeeded`.
|
||||
const IOS_SCHEDULE_TOUCHED = {
|
||||
...IOS_RENAME,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["09:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
oneShot: false,
|
||||
startsOn: "2026-06-01",
|
||||
asNeeded: false,
|
||||
};
|
||||
// The same save after picking the "as needed" cadence.
|
||||
const IOS_AS_NEEDED = {
|
||||
...IOS_RENAME,
|
||||
schedules: [],
|
||||
oneShot: false,
|
||||
asNeeded: true,
|
||||
};
|
||||
|
||||
function arrange(trackIntake: boolean) {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
trackIntake,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
_count: { schedules: 1 },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
trackIntake,
|
||||
unitsPerDose: 1,
|
||||
schedules: [{ ...STORED_ROW, unitsPerDose: null }],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
STORED_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.update).mockClear();
|
||||
vi.mocked(prisma.medicationSchedule.deleteMany).mockClear();
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
|
||||
}
|
||||
|
||||
function updateData(): Record<string, unknown> {
|
||||
return vi.mocked(prisma.medication.update).mock.calls[0][0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
it("a rename applies and touches no schedule", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_RENAME), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toMatchObject({
|
||||
name: "Atorvastatin 20",
|
||||
notificationsEnabled: true,
|
||||
});
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
expect(updateData()).not.toHaveProperty("trackIntake");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a schedule-touched save keeps the recorded schedule and applies the rest", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const data = updateData();
|
||||
// The 09:00 default the person never chose is not written.
|
||||
expect(data).not.toHaveProperty("schedules");
|
||||
expect(data).not.toHaveProperty("asNeeded");
|
||||
expect(data).not.toHaveProperty("oneShot");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
// Everything else in the save applies.
|
||||
expect(data).toMatchObject({ name: "Atorvastatin 20" });
|
||||
expect(data).toHaveProperty("startsOn");
|
||||
// And it is still a record: the stored schedule comes back on record.
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
schedules: unknown[];
|
||||
recordedSchedules?: Array<{ timesOfDay: string[] }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.schedules).toEqual([]);
|
||||
expect(body.data.recordedSchedules?.[0]?.timesOfDay).toEqual(["21:00"]);
|
||||
});
|
||||
|
||||
it("picking 'as needed' neither 422s nor deletes the recorded schedule", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_AS_NEEDED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).not.toHaveProperty("asNeeded");
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the same schedule write applies once it names trackIntake", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(
|
||||
putReq({ ...IOS_SCHEDULE_TOUCHED, trackIntake: false }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("a tracked medication takes the shipped client's schedule write as before", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toHaveProperty("schedules");
|
||||
expect(updateData()).toMatchObject({ asNeeded: false, oneShot: false });
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pause", { active: false }],
|
||||
["reactivate", { active: true }],
|
||||
["end course", { endsOn: "2026-06-12" }],
|
||||
])("the %s write applies unchanged", async (_label, body) => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(body), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toMatchObject(
|
||||
"active" in body ? { active: body.active } : {},
|
||||
);
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -131,8 +132,10 @@ export const GET = apiHandler(
|
||||
scheduleRevisions: med.scheduleRevisions,
|
||||
};
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
|
||||
const liveSchedules = dueSchedules(med);
|
||||
const timeline = buildCadenceTimeline(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
@@ -141,7 +144,7 @@ export const GET = apiHandler(
|
||||
engineCtx,
|
||||
);
|
||||
const chips = complianceChips(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
@@ -150,7 +153,7 @@ export const GET = apiHandler(
|
||||
engineCtx,
|
||||
);
|
||||
const next = computeNextDose(
|
||||
med.schedules,
|
||||
liveSchedules,
|
||||
asOf,
|
||||
14,
|
||||
anchor,
|
||||
|
||||
@@ -426,3 +426,147 @@ describe("GET /api/medications/[id]/dose-history — early take (#1028 class)",
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1028 follow-up — a medication added in the afternoon, dosed 09:00 / 14:00
|
||||
* / 21:00. The 14:00 dose of the creation day was recorded afterwards on its
|
||||
* own slot, the 21:00 dose came in through the reminder, and the 09:00 slot
|
||||
* of that day only ever held the pending placeholder the today projector
|
||||
* mints for every slot of the day, which the auto-miss pass later stamped.
|
||||
*
|
||||
* The 14:00 dose must read as its own slot, taken on time, not as an
|
||||
* off-schedule take "due" at 21:00; the 09:00 slot, which predates the
|
||||
* medication and holds no recorded dose, must not appear at all.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/dose-history — slots before creation (#1028)", () => {
|
||||
const cases = [
|
||||
{ tz: "Asia/Kolkata", edited: false },
|
||||
{ tz: "America/New_York", edited: false },
|
||||
// The schedule was saved again shortly after the medication was added,
|
||||
// so the creation day lives in an archived schedule era.
|
||||
{ tz: "Asia/Kolkata", edited: true },
|
||||
];
|
||||
for (const { tz, edited } of cases) {
|
||||
it(`attributes a recorded dose on a pre-creation slot and hides the unrecorded one (${tz}${edited ? ", edited schedule" : ""})`, async () => {
|
||||
const day1 = new Date("2026-09-23T12:00:00Z");
|
||||
const day2 = new Date("2026-09-24T12:00:00Z");
|
||||
const createdAt = localHmAsUtc(day1, tz, 16, 5);
|
||||
const now = localHmAsUtc(day2, tz, 22, 0);
|
||||
const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m);
|
||||
const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, timezone: tz },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: new Date("2026-09-23T00:00:00Z"),
|
||||
endsOn: new Date("2026-09-28T00:00:00Z"),
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
scheduleRevisions: edited
|
||||
? [
|
||||
{
|
||||
id: "rev-1",
|
||||
validFrom: createdAt,
|
||||
validUntil: localHmAsUtc(day1, tz, 16, 30),
|
||||
supersededByRevisionId: null,
|
||||
payload: [
|
||||
{
|
||||
timesOfDay: ["09:00", "14:00", "21:00"],
|
||||
windowStart: "09:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: "1 tablet",
|
||||
reminderGraceMinutes: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "14:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const row = (
|
||||
id: string,
|
||||
scheduledFor: Date,
|
||||
takenAt: Date | null,
|
||||
extra: Partial<{ autoMissed: boolean; source: string }> = {},
|
||||
) => ({
|
||||
id,
|
||||
scheduledFor,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
autoMissed: extra.autoMissed ?? false,
|
||||
attributionSource: "AUTO",
|
||||
doseTaken: null,
|
||||
source: extra.source ?? "REMINDER",
|
||||
});
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
row("d2-2100", d2(21), d2(21, 30)),
|
||||
row("d2-1400", d2(14), d2(14)),
|
||||
row("d2-0900", d2(9), d2(10)),
|
||||
row("d1-2100", d1(21), d1(21)),
|
||||
row("d1-1400", d1(14), d1(14), { source: "WEB" }),
|
||||
// The projector's placeholder for the pre-creation 09:00 slot,
|
||||
// stamped by the auto-miss pass a day later.
|
||||
row("d1-0900-placeholder", d1(9), null, { autoMissed: true }),
|
||||
] as never);
|
||||
|
||||
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
|
||||
const res = await GET(
|
||||
getReq(
|
||||
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(now.toISOString())}`,
|
||||
),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
const rows = json.data.rows as Array<{
|
||||
kind: string;
|
||||
at: string;
|
||||
timeOfDay: string | null;
|
||||
status: string;
|
||||
nearestSlot?: unknown;
|
||||
intake: { id: string | null } | null;
|
||||
}>;
|
||||
|
||||
expect(
|
||||
rows.map((r) => [r.at, r.kind, r.status, r.intake?.id ?? null]),
|
||||
).toEqual([
|
||||
[d1(14).toISOString(), "slot", "taken_on_time", "d1-1400"],
|
||||
[d1(21).toISOString(), "slot", "taken_on_time", "d1-2100"],
|
||||
[d2(9).toISOString(), "slot", "taken_on_time", "d2-0900"],
|
||||
[d2(14).toISOString(), "slot", "taken_on_time", "d2-1400"],
|
||||
[d2(21).toISOString(), "slot", "taken_on_time", "d2-2100"],
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type WorkerScheduleRow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -134,26 +135,23 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the window. Default to the trailing 90 days; clamp the floor to
|
||||
// the medication's creation so pre-existence days never mint phantom
|
||||
// slots, and cap the span so a pathological `from=1970` can't expand a
|
||||
// year-plus of bands. `to` defaults to `now`.
|
||||
// Resolve the window. Default to the trailing 90 days and cap the span
|
||||
// so a pathological `from=1970` can't expand a year-plus of bands. `to`
|
||||
// defaults to `now`.
|
||||
const to = parsed.data.to ?? now;
|
||||
const requestedFrom =
|
||||
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
|
||||
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
|
||||
// The window the caller asked for, capped only by the span limit. This is
|
||||
// the floor for recorded doses: a dose logged with a time before the
|
||||
// medication was added (created at 16:20, "taken at 14:00") is a real
|
||||
// record and belongs in the window, exactly as the unwindowed full
|
||||
// history shows it (#1028).
|
||||
const intakeFrom = new Date(
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
// The floor for minted slots additionally stops at the medication's
|
||||
// creation, so pre-existence days never mint phantom missed slots.
|
||||
// The window the caller asked for, capped only by the span limit. Both
|
||||
// recorded doses and minted slots use it: a dose logged for a slot
|
||||
// before the medication was added (created at 16:20, the 14:00 dose
|
||||
// recorded afterwards) is a real record and must meet its slot, or it
|
||||
// reads as an off-schedule take due at the next slot (#1028). Slots
|
||||
// before the creation that no recorded dose claims stay out of the
|
||||
// ledger: `reconstructDoseHistory` receives the creation instant as the
|
||||
// floor of expected slots, so pre-existence days never read as missed.
|
||||
const from = new Date(
|
||||
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
@@ -220,7 +218,10 @@ export const GET = apiHandler(
|
||||
// A legacy daily schedule carrying only `windowStart` surfaces it as the
|
||||
// single time-of-day so the minter mints its daily band (mirrors the
|
||||
// compliance route + the ledger tally).
|
||||
const canonicalSchedules = medication.schedules.map((s) => {
|
||||
// v1.39.1 (#1033) — with intake tracking off the live era expects
|
||||
// nothing: the ledger shows the doses that were recorded and the slots
|
||||
// of archived eras, never a missed slot minted from the record.
|
||||
const canonicalSchedules = dueSchedules(medication).map((s) => {
|
||||
const canonical = buildCanonicalSchedule(s as WorkerScheduleRow);
|
||||
if (
|
||||
canonical.timesOfDay.length === 0 &&
|
||||
@@ -264,7 +265,7 @@ export const GET = apiHandler(
|
||||
// partition, so filter on the stored anchor (a take snapped to an
|
||||
// out-of-window slot is excluded, matching the compliance read).
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
|
||||
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= horizon)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
scheduledFor: e.scheduledFor,
|
||||
@@ -276,7 +277,12 @@ export const GET = apiHandler(
|
||||
source: e.source,
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
historyIntakes,
|
||||
now,
|
||||
medication.createdAt,
|
||||
);
|
||||
|
||||
const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({
|
||||
kind: row.kind,
|
||||
@@ -314,11 +320,10 @@ export const GET = apiHandler(
|
||||
});
|
||||
|
||||
// The window the recorded doses were read over (#1028): from the
|
||||
// requested start (slots additionally start at the medication's
|
||||
// creation) to the read horizon, which reaches past `to` only for a dose
|
||||
// recorded ahead of its slot.
|
||||
// requested start to the read horizon, which reaches past `to` only for
|
||||
// a dose recorded ahead of its slot.
|
||||
return apiSuccess({
|
||||
from: intakeFrom.toISOString(),
|
||||
from: from.toISOString(),
|
||||
to: horizon.toISOString(),
|
||||
family,
|
||||
hasExpectedSlots: bands.length > 0,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
shapeDoseChangeNote,
|
||||
} from "@/lib/crypto/note-cipher";
|
||||
import { NextRequest } from "next/server";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -120,12 +121,16 @@ export const GET = apiHandler(
|
||||
prefsRow?.notificationPrefs ?? null,
|
||||
medication.reorderLeadDays,
|
||||
);
|
||||
const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({
|
||||
...sched,
|
||||
// Decimal → number so the slot-aware runway math reads the wire shape.
|
||||
unitsPerDose:
|
||||
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
|
||||
}));
|
||||
// v1.39.1 (#1033) — intake tracking off: no dose consumes stock, so no
|
||||
// runway and no low-stock flag derive from the stored schedule.
|
||||
const schedules: RunwaySchedule[] = dueSchedules(medication).map(
|
||||
(sched) => ({
|
||||
...sched,
|
||||
// Decimal → number so the slot-aware runway math reads the wire shape.
|
||||
unitsPerDose:
|
||||
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
|
||||
}),
|
||||
);
|
||||
/** runway ≤ effective trigger AND the alert is enabled. */
|
||||
const isLowStock = (dosesRemaining: number): boolean => {
|
||||
if (runwayFloor === null) return false;
|
||||
|
||||
@@ -41,6 +41,11 @@ import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-
|
||||
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
dueSchedules,
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -106,7 +111,7 @@ export const GET = apiHandler(
|
||||
// cadences re-anchor on the latest non-skipped intake, so fetch it
|
||||
// once (no-op for calendar cadences but cheap + keeps the value
|
||||
// correct for rolling injections).
|
||||
const lastIntake = medication.schedules.some(
|
||||
const lastIntake = dueSchedules(medication).some(
|
||||
(s) => s.rollingIntervalDays !== null,
|
||||
)
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -157,6 +162,9 @@ export const GET = apiHandler(
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
// v1.39.1 (#1033) — intake tracking off: nothing is due from the
|
||||
// stored rows, and no runway derives from them.
|
||||
const liveSchedules = dueSchedules(medication);
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
@@ -165,7 +173,7 @@ export const GET = apiHandler(
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
schedules: medication.schedules,
|
||||
schedules: liveSchedules,
|
||||
now,
|
||||
userTz: user.timezone || "Europe/Berlin",
|
||||
lastIntakeAt: lastIntake?.takenAt ?? null,
|
||||
@@ -220,14 +228,16 @@ export const GET = apiHandler(
|
||||
? null
|
||||
: estimateUnitsRunwayDays(
|
||||
stockUnitsRemaining,
|
||||
schedulesDto,
|
||||
isRecordOnly(medication) ? [] : schedulesDto,
|
||||
Number(medication.unitsPerDose),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
schedules: schedulesDto,
|
||||
// v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when
|
||||
// intake tracking is off (see `scheduleWireFields`).
|
||||
...scheduleWireFields(medication.trackIntake, schedulesDto),
|
||||
category,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
@@ -265,6 +275,8 @@ export const PUT = apiHandler(
|
||||
active: true,
|
||||
createdAt: true,
|
||||
asNeeded: true,
|
||||
// v1.39.1 (#1033) — the tracking transition below needs the before.
|
||||
trackIntake: true,
|
||||
// v1.37.0 — the pre-image the audit row carries (C4).
|
||||
name: true,
|
||||
dose: true,
|
||||
@@ -287,6 +299,55 @@ export const PUT = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ──
|
||||
//
|
||||
// A medication with intake tracking off is served with `schedules: []`
|
||||
// and its stored schedule in `recordedSchedules`, so a client that
|
||||
// predates the field arms no reminder from it. The price is that such a
|
||||
// client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
|
||||
// 1.0.4) opens a medication without schedule rows in its editor as
|
||||
// "daily at 08:00"; a rename or dose edit sends no `schedules` (its
|
||||
// editor diffs against that same prefill), but any touch of a schedule
|
||||
// field sends a full replacement built from the 08:00 default plus
|
||||
// `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
|
||||
// overwrite the schedule the person kept on record with one they never
|
||||
// chose (or flip it to as-needed and delete the rows).
|
||||
//
|
||||
// Rule: on a medication whose intake tracking is off, a write that does
|
||||
// not name `trackIntake` leaves the schedule shape alone. `schedules`,
|
||||
// `asNeeded` and `oneShot` are dropped from it; every other field (name,
|
||||
// dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
|
||||
// as sent. A client that knows about the switch names it on every
|
||||
// schedule save (the web editor and wizard do), and those writes apply.
|
||||
const keepRecordedSchedule =
|
||||
existing.trackIntake === false &&
|
||||
parsed.data.trackIntake === undefined &&
|
||||
(parsed.data.schedules !== undefined ||
|
||||
parsed.data.asNeeded !== undefined ||
|
||||
parsed.data.oneShot !== undefined);
|
||||
const input = keepRecordedSchedule
|
||||
? {
|
||||
...parsed.data,
|
||||
schedules: undefined,
|
||||
asNeeded: undefined,
|
||||
oneShot: undefined,
|
||||
}
|
||||
: parsed.data;
|
||||
if (keepRecordedSchedule) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.update.record_schedule_kept",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
dropped_schedules: parsed.data.schedules !== undefined,
|
||||
dropped_as_needed: parsed.data.asNeeded !== undefined,
|
||||
dropped_one_shot: parsed.data.oneShot !== undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
@@ -309,8 +370,15 @@ export const PUT = apiHandler(
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
} = input;
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
|
||||
// as a record; the live era from here on expects nothing.
|
||||
const wasTracked = existing.trackIntake !== false;
|
||||
const willTrack = trackIntake ?? wasTracked;
|
||||
const trackingChanged = wasTracked !== willTrack;
|
||||
|
||||
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
|
||||
//
|
||||
@@ -516,123 +584,16 @@ export const PUT = apiHandler(
|
||||
// the manager's grant ends.
|
||||
let replacedCadence: string | null = null;
|
||||
let replacedScheduleCount: number | null = null;
|
||||
let tombstonedSlotCount: number | null = null;
|
||||
let tombstonedFrom: Date | null = null;
|
||||
let tombstonedTo: Date | null = null;
|
||||
let tombstonedSlotCount = null as number | null;
|
||||
// Assigned inside `tombstoneOpenSlots`; the casts keep TypeScript from
|
||||
// narrowing them to `null` across the closure.
|
||||
let tombstonedFrom = null as Date | null;
|
||||
let tombstonedTo = null as Date | null;
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
)
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: previousEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: previousEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
// Tombstone today's and future open pending rows of this medication.
|
||||
// Runs on a schedule replace (the old anchors are stale) and when intake
|
||||
// tracking is switched off (nothing is due any more).
|
||||
const tombstoneOpenSlots = async (): Promise<void> => {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -703,9 +664,195 @@ export const PUT = apiHandler(
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
replacedScheduleCount = previousRows.length;
|
||||
replacedCadence =
|
||||
previousRows.length > 0
|
||||
? previousRows
|
||||
.map((row) =>
|
||||
[
|
||||
row.timesOfDay,
|
||||
row.rrule ?? "",
|
||||
row.rollingIntervalDays !== null
|
||||
? `every ${row.rollingIntervalDays}d`
|
||||
: "",
|
||||
row.daysOfWeek ?? "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
)
|
||||
.join(" | ")
|
||||
: null;
|
||||
|
||||
// v1.39.1 (#1033) — a stretch with intake tracking off expected
|
||||
// nothing, so it archives with an empty payload whatever the rows
|
||||
// said; and a tracking transition is itself an era boundary even when
|
||||
// the rows stay the same.
|
||||
const archivedEntries = wasTracked ? previousEntries : [];
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
(trackingChanged ||
|
||||
!wasTracked ||
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
))
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
// Chain from the ACTIVE boundary: a superseded row is an
|
||||
// audit record whose `validUntil` may sit past its correction.
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: archivedEntries.length },
|
||||
});
|
||||
} else if (
|
||||
previousRows.length === 0 &&
|
||||
existing.asNeeded &&
|
||||
asNeeded === false &&
|
||||
normalisedSchedules.length > 0
|
||||
) {
|
||||
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
|
||||
// schedule rows while as-needed, so the wholesale-replace archive
|
||||
// above never fires — and without a revision the live era would
|
||||
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
|
||||
// retro-painting the schedule-less as-needed stretch with the NEW
|
||||
// schedule's expected slots: every PRN day would read as missed.
|
||||
// Archive an EMPTY era covering the as-needed stretch instead; an
|
||||
// empty payload expands to zero schedules, so era-aware compliance
|
||||
// expects nothing there — exactly the as-needed contract.
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: [] as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: 0 },
|
||||
});
|
||||
}
|
||||
await tombstoneOpenSlots();
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
} else if (trackingChanged) {
|
||||
// v1.39.1 (#1033) — intake tracking switched without a schedule
|
||||
// replace. Close the current era at this instant: switching OFF
|
||||
// archives the schedule that was in force (the tracked stretch keeps
|
||||
// its expected doses); switching back ON archives the untracked
|
||||
// stretch with an empty payload, so compliance never counts it as
|
||||
// missed and the live schedule, the projector and the reminder worker
|
||||
// all resume from now.
|
||||
const liveRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
if (liveRows.length > 0) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id, supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
const archivedEntries = wasTracked
|
||||
? liveRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: archivedEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
schedule_revision_rows: archivedEntries.length,
|
||||
track_intake: willTrack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
|
||||
// the open placeholders of today and later go the way a schedule
|
||||
// replace sends them (a replace in the same request already did it).
|
||||
if (trackingChanged && !willTrack && !schedules) {
|
||||
await tombstoneOpenSlots();
|
||||
}
|
||||
|
||||
const baseUpdateData = {
|
||||
@@ -743,6 +890,8 @@ export const PUT = apiHandler(
|
||||
// v1.16.11 — as-needed flag, field-by-field (the invariants above
|
||||
// already guaranteed the medication ends schedule-consistent).
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
@@ -849,6 +998,7 @@ export const PUT = apiHandler(
|
||||
dose: existing.dose,
|
||||
active: existing.active,
|
||||
asNeeded: existing.asNeeded,
|
||||
trackIntake: existing.trackIntake,
|
||||
endsOn: existing.endsOn,
|
||||
},
|
||||
after: {
|
||||
@@ -856,6 +1006,7 @@ export const PUT = apiHandler(
|
||||
dose: medication.dose,
|
||||
active: medication.active,
|
||||
asNeeded: medication.asNeeded,
|
||||
trackIntake: medication.trackIntake,
|
||||
endsOn: medication.endsOn,
|
||||
},
|
||||
}),
|
||||
@@ -886,9 +1037,12 @@ export const PUT = apiHandler(
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
});
|
||||
|
||||
@@ -494,6 +494,42 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
|
||||
expect(data.unitsPerDose).toBe(2);
|
||||
});
|
||||
|
||||
// #1034 — a whole number plus a fraction reaches the Prisma write
|
||||
// unchanged, on the medication and on a per-slot override; a value with
|
||||
// a fifth decimal place is refused before any write.
|
||||
it("creates a medication with a mixed units-per-dose value", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
unitsPerDose: 1.5,
|
||||
asNeeded: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(lastCreateData().unitsPerDose).toBe(1.5);
|
||||
});
|
||||
|
||||
it("422s a units-per-dose value with more than four decimal places", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
unitsPerDose: 1.23456,
|
||||
asNeeded: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: Array<string | number> }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.includes("unitsPerDose")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still 422s a scheduled create without any schedule (legacy contract)", async () => {
|
||||
const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -250,6 +250,8 @@ describe("GET /api/medications/compliance", () => {
|
||||
expect(call.where).toEqual({
|
||||
userId: "user-1",
|
||||
asNeeded: false,
|
||||
// v1.39.1 (#1033) — intake tracking off is left out like PRN.
|
||||
trackIntake: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
/**
|
||||
* Batched card-compliance read: one round trip for every medication the
|
||||
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
|
||||
// v1.16.11 — as-needed (PRN) medications carry no compliance entry:
|
||||
// the cards/table render a last-taken presentation for them instead
|
||||
// of rates, and an empty expected set must not read as 0% or 100%.
|
||||
where: { userId: user.id, asNeeded: false },
|
||||
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
|
||||
@@ -54,6 +54,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// The query + body schemas moved to `@/lib/validations/medication` when this
|
||||
// endpoint joined the published contract: the OpenAPI registry generates from
|
||||
@@ -125,6 +126,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today list.
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
// v1.39.1 (#1033) — the today list is what clients offer doses
|
||||
// from; a medication with intake tracking off is not on it.
|
||||
...TRACKED_INTAKE_EVENT_WHERE,
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
include: { medication: { select: { id: true, snoozedUntil: true } } },
|
||||
|
||||
@@ -25,6 +25,7 @@ import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
|
||||
// A client that mints a fresh `externalId` for the same source concept on
|
||||
@@ -72,6 +73,7 @@ async function respondWithExistingMirror(
|
||||
medication: Record<string, unknown> & {
|
||||
id: string;
|
||||
unitsPerDose: unknown;
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
|
||||
},
|
||||
): Promise<Response> {
|
||||
@@ -96,9 +98,13 @@ async function respondWithExistingMirror(
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
),
|
||||
),
|
||||
category,
|
||||
});
|
||||
@@ -165,6 +171,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.28 — idempotent mirror create. A mirrored medication (Apple
|
||||
@@ -304,6 +311,10 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
// carries an empty `scheduleInputs`, so the nested create below
|
||||
// persists zero schedule rows.
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field. Created off,
|
||||
// the schedule is stored as a record from the start; the column
|
||||
// default (true) covers every caller that does not send it.
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
schedules: {
|
||||
create: scheduleInputs.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
@@ -426,9 +437,13 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
schedules: serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
},
|
||||
|
||||
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -449,6 +450,46 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks a response carrying upsert_failed no-store, so a retry with the same Idempotency-Key writes again", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
|
||||
new Error("connection reset"),
|
||||
);
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
spy.mockRestore();
|
||||
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
|
||||
it("documents upsert_failed as a transient write failure to retry, and the validation skips as terminal", () => {
|
||||
const description = String(
|
||||
(nutrientEntryResultSchema.meta() as { description?: string })
|
||||
.description,
|
||||
);
|
||||
expect(description).toMatch(/`upsert_failed` is a write failure/);
|
||||
expect(description).toMatch(/sent again on a later sync/);
|
||||
expect(description).not.toMatch(/log and drop a skipped entry/i);
|
||||
expect(description).toMatch(/unit_mismatch/);
|
||||
});
|
||||
|
||||
it("a race that drops one createMany row still counts consistently and is observable", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
* per-row updates (existing pairs), the measurements-batch shape. A write
|
||||
* failure marks its WHOLE group `skipped upsert_failed` rather than
|
||||
* isolating the one entry that would have caused it (unavoidable once the
|
||||
* write is batched) — the request itself never fails.
|
||||
* write is batched) — the request itself never fails. That reason is
|
||||
* transient, unlike the validation skips: the client sends those entries
|
||||
* again, and the response is marked `no-store` so an idempotent retry is not
|
||||
* answered from the cache.
|
||||
*
|
||||
* Module gate FIRST: the opt-in `nutrients` module (default off)
|
||||
* refuses ingest with the 403 `module.disabled` envelope, so a phone
|
||||
@@ -360,11 +363,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
// `upsert_failed` is transient: nothing was stored for those entries and the
|
||||
// client sends them again. `no-store` keeps the idempotency layer from
|
||||
// caching this answer, so a retry under the same key writes instead of
|
||||
// replaying the failure. The measurement batch does the same for `failed`.
|
||||
if (skippedByReason.upsert_failed) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -1181,3 +1181,45 @@ describe("Telegram webhook — GET verification is rate-limited first", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1033 — a medication kept as a record is never offered, recorded or snoozed
|
||||
* from Telegram, the same as on the web.
|
||||
*/
|
||||
describe("POST /api/telegram/webhook — record-only medications", () => {
|
||||
function textUpdate(text: string) {
|
||||
return {
|
||||
update_id: 300,
|
||||
message: { message_id: 30, text, chat: { id: 7777 }, from: { id: 7777 } },
|
||||
};
|
||||
}
|
||||
|
||||
function whereOf(call: unknown): Record<string, unknown> {
|
||||
return (call as [{ where: Record<string, unknown> }])[0].where;
|
||||
}
|
||||
|
||||
it("/add lists tracked medications only", async () => {
|
||||
await POST(tgRequest(textUpdate("/add")));
|
||||
const where = whereOf(vi.mocked(prisma.medication.findMany).mock.calls[0]);
|
||||
expect(where).toMatchObject({ active: true, trackIntake: true });
|
||||
});
|
||||
|
||||
it("the 'taken' keyword resolves tracked medications only", async () => {
|
||||
await POST(tgRequest(textUpdate("taken Ramipril")));
|
||||
const where = whereOf(vi.mocked(prisma.medication.findFirst).mock.calls[0]);
|
||||
expect(where).toMatchObject({ active: true, trackIntake: true });
|
||||
});
|
||||
|
||||
it("a reminder button on a record-only medication records nothing", async () => {
|
||||
await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
// The gate lookup: a record-only medication resolves to nothing here,
|
||||
// so no dose is written (later lookups only bind an admitted dose).
|
||||
const calls = vi.mocked(prisma.medication.findFirst).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(whereOf(calls[0])).toMatchObject({
|
||||
id: "med-1",
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,6 +61,12 @@ const SESSION_OK = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
|
||||
// The operator's default zone, which the server falls back to when a
|
||||
// stored one is unusable. Distinct from the built-in default so a test can
|
||||
// tell the resolver ran.
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
defaultUserTimezone: "America/Chicago",
|
||||
} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -438,3 +445,81 @@ describe("PATCH /api/user/profile", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `/api/auth/me` has returned the zone the server actually cuts days in —
|
||||
* the stored one, or the instance default when the stored value is unusable
|
||||
* — while this alias returned the raw column. A client reading this endpoint
|
||||
* could bucket a day differently from the server. Both now go through the
|
||||
* same resolution.
|
||||
*/
|
||||
describe("/api/user/profile — timezone is the resolved zone", () => {
|
||||
function profileRow(timezone: string | null) {
|
||||
return {
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: null,
|
||||
timezone,
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getZone(stored: string | null): Promise<string> {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
profileRow(stored) as never,
|
||||
);
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { timezone: string } }).data.timezone;
|
||||
}
|
||||
|
||||
it("returns a usable stored zone as stored", async () => {
|
||||
expect(await getZone("Asia/Tokyo")).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("returns the instance default in place of a bare offset", async () => {
|
||||
expect(await getZone("+05:30")).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("returns the instance default in place of a name no runtime knows", async () => {
|
||||
expect(await getZone("Mars/Olympus_Mons")).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("returns the instance default when the row is missing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await callGet(makeGetReq());
|
||||
const body = (await res.json()) as { data: { timezone: string } };
|
||||
expect(body.data.timezone).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("answers a PATCH that leaves an unusable zone untouched with the resolved one", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
...profileRow("+05:30"),
|
||||
id: "user-1",
|
||||
role: "USER",
|
||||
displayName: "Alex T.",
|
||||
} as never);
|
||||
const res = await PATCH(
|
||||
new NextRequest("http://localhost/api/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ displayName: "Alex T." }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { timezone: string } };
|
||||
expect(body.data.timezone).toBe("America/Chicago");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -77,7 +78,10 @@ export const GET = apiHandler(async () => {
|
||||
gender: dbUser?.gender ?? null,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
locale: dbUser?.locale ?? null,
|
||||
timezone: dbUser?.timezone ?? "Europe/Berlin",
|
||||
// The zone the server cuts this account's days in, exactly as
|
||||
// `/api/auth/me` reports it: the stored zone, or the instance default
|
||||
// when the stored value is unusable. Never the raw column.
|
||||
timezone: await resolveStoredTimezone(dbUser?.timezone),
|
||||
timeFormat: dbUser?.timeFormat ?? "AUTO",
|
||||
dateFormat: dbUser?.dateFormat ?? "AUTO",
|
||||
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
|
||||
@@ -118,7 +122,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
gender: result.user.gender,
|
||||
heightCm: result.user.heightCm,
|
||||
locale: result.user.locale,
|
||||
timezone: result.user.timezone,
|
||||
timezone: await resolveStoredTimezone(result.user.timezone),
|
||||
timeFormat: result.user.timeFormat,
|
||||
dateFormat: result.user.dateFormat,
|
||||
moodReminderEnabled: result.user.moodReminderEnabled,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -11,6 +12,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { ProceduresSection } from "@/components/encounters/procedures-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -29,6 +31,11 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* v1.39.1 — a third tab, the procedure and surgery history. It is the visits
|
||||
* list read one way (the visits of kind PROCEDURE, searchable by body site),
|
||||
* so it sits beside the visits rather than behind a nav entry of its own, and
|
||||
* its read hangs off the same `["encounters"]` root the pull already evicts.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
@@ -38,7 +45,10 @@ export default function CheckupsPageClient() {
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
// A link to one visit (`?visit=<id>`, from a document's link chip) lands on
|
||||
// the visits tab, where the section opens it.
|
||||
const linkedVisit = useSearchParams()?.get("visit");
|
||||
const [view, setView] = useState(linkedVisit ? "visits" : "vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
@@ -65,6 +75,9 @@ export default function CheckupsPageClient() {
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="procedures" data-testid="checkups-tab-procedures">
|
||||
{t("checkups.tabProcedures")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
@@ -74,6 +87,9 @@ export default function CheckupsPageClient() {
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
<TabsContent value="procedures">
|
||||
<ProceduresSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,6 +105,12 @@ interface Medication {
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
/**
|
||||
* v1.39.1 (#1033) — false keeps the medication as a record: served with
|
||||
* `schedules: []` (the stored rows ride `recordedSchedules`), never due,
|
||||
* and left out of the take-all and log-dose flows.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
@@ -647,25 +653,27 @@ export default function MedicationsPageClient() {
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
onOpenChange={setLogIntakeOpen}
|
||||
medications={activeMeds.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
lastTakenAt: m.lastTakenAt,
|
||||
todayEventCount: m.todayEventCount ?? 0,
|
||||
nextDueAt: m.nextDueAt,
|
||||
nextDueOverdue: m.nextDueOverdue,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
timesOfDay: s.timesOfDay,
|
||||
doseWindows: s.doseWindows,
|
||||
})),
|
||||
}))}
|
||||
medications={activeMeds
|
||||
.filter((m) => m.trackIntake !== false)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
lastTakenAt: m.lastTakenAt,
|
||||
todayEventCount: m.todayEventCount ?? 0,
|
||||
nextDueAt: m.nextDueAt,
|
||||
nextDueOverdue: m.nextDueOverdue,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
timesOfDay: s.timesOfDay,
|
||||
doseWindows: s.doseWindows,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -42,5 +42,11 @@ export default function VaccinationsPage() {
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return <VaccinationsView />;
|
||||
// The view reads `?dose=` through `useSearchParams`, which needs a
|
||||
// boundary on a statically rendered page.
|
||||
return (
|
||||
<Suspense fallback={<PageAuthGate />}>
|
||||
<VaccinationsView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -801,6 +801,8 @@ describe("a visit row's edit sheet", () => {
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
bodySite: null,
|
||||
laterality: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* What the backups console shows while a restore runs and after it ends.
|
||||
*
|
||||
* The restore runs as a background job; this is the only place an operator
|
||||
* learns how it is going. Pinned here:
|
||||
* 1. a running restore names its phase with the counts and draws a bar
|
||||
* only where the share is known, and says the account still shows its
|
||||
* current data;
|
||||
* 2. a failed restore says what went wrong and that nothing changed, except
|
||||
* for the one failure after the commit, where saying so would be false;
|
||||
* 3. a succeeded restore reports through the outcome module, so an empty
|
||||
* file restored to an empty account is not drawn as a tick;
|
||||
* 4. the panels shown are every active job and the newest finished one not
|
||||
* dismissed;
|
||||
* 5. the copy resolves in more than one locale.
|
||||
*
|
||||
* Mutation check: always print the "nothing was changed" line and case 2's
|
||||
* after-commit test goes red; render the bar in every phase and case 1's
|
||||
* clearing test goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
RestoreJobStatus,
|
||||
visibleRestoreJobs,
|
||||
} from "@/components/admin/backup-restore-status";
|
||||
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/backups",
|
||||
}));
|
||||
|
||||
function job(overrides: Partial<BackupRestoreJobView>): BackupRestoreJobView {
|
||||
return {
|
||||
id: "job-1",
|
||||
userId: "user-1",
|
||||
username: "alex",
|
||||
backupId: "backup-1",
|
||||
restoreInstanceSettings: false,
|
||||
status: "running",
|
||||
phase: "measurements",
|
||||
progress: {
|
||||
measurementsChecked: 1_250_000,
|
||||
measurementsTotal: 1_250_000,
|
||||
measurementsWritten: 400_000,
|
||||
sectionsDone: 0,
|
||||
sectionsTotal: 17,
|
||||
},
|
||||
result: null,
|
||||
failure: null,
|
||||
attempts: 1,
|
||||
createdAt: "2026-09-25T08:00:00.000Z",
|
||||
startedAt: "2026-09-25T08:00:01.000Z",
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(view: BackupRestoreJobView, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<RestoreJobStatus job={view} onDismiss={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RestoreJobStatus while it runs", () => {
|
||||
it("names the phase with its counts and draws the share done", () => {
|
||||
const html = render(job({}));
|
||||
expect(html).toContain("Restoring alex");
|
||||
expect(html).toContain("Writing readings: 400,000 of 1,250,000");
|
||||
expect(html).toContain('data-slot="restore-job-progress"');
|
||||
expect(html).toContain("keeps showing its current data");
|
||||
});
|
||||
|
||||
it("draws no bar where the share is not known", () => {
|
||||
const html = render(job({ phase: "clearing" }));
|
||||
expect(html).toContain("Clearing the account");
|
||||
expect(html).not.toContain('data-slot="restore-job-progress"');
|
||||
});
|
||||
|
||||
it("says a queued restore is waiting", () => {
|
||||
const html = render(job({ status: "queued", phase: null, progress: null }));
|
||||
expect(html).toContain("waiting to start");
|
||||
});
|
||||
|
||||
it("says when it was started again after a restart", () => {
|
||||
expect(render(job({ attempts: 2 }))).toContain("Started again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RestoreJobStatus once it failed", () => {
|
||||
it("says why, and that nothing changed", () => {
|
||||
const html = render(
|
||||
job({
|
||||
status: "failed",
|
||||
phase: null,
|
||||
failure: { code: "interrupted", message: "server text" },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("did not complete");
|
||||
expect(html).toContain("server restarted");
|
||||
expect(html).toContain("Nothing was changed");
|
||||
expect(html).toContain('data-failure-code="interrupted"');
|
||||
});
|
||||
|
||||
it("does not claim nothing changed when the data was restored", () => {
|
||||
const html = render(
|
||||
job({
|
||||
status: "failed",
|
||||
phase: null,
|
||||
failure: { code: "failed_after_commit", message: "server text" },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("The data was restored");
|
||||
expect(html).not.toContain("Nothing was changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RestoreJobStatus once it succeeded", () => {
|
||||
const result = {
|
||||
summary: { measurements: 1_250_000, medications: 3 },
|
||||
skipped: { links: 0, catalogueKeys: [] },
|
||||
cleared: { measurements: 10 },
|
||||
};
|
||||
|
||||
it("reports the count through the outcome module", () => {
|
||||
const html = render(
|
||||
job({
|
||||
status: "succeeded",
|
||||
phase: null,
|
||||
result,
|
||||
completedAt: "2026-09-25T08:02:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Restore of alex finished");
|
||||
expect(html).toContain("1,250,003 records restored");
|
||||
expect(html).toContain('data-outcome="success"');
|
||||
});
|
||||
|
||||
it("does not draw a tick for a file that restored nothing", () => {
|
||||
const html = render(
|
||||
job({
|
||||
status: "succeeded",
|
||||
phase: null,
|
||||
result: { ...result, summary: {} },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-outcome="empty"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleRestoreJobs", () => {
|
||||
it("shows every active job and the newest finished one not dismissed", () => {
|
||||
const jobs = [
|
||||
job({ id: "running", status: "running" }),
|
||||
job({ id: "newest-done", status: "succeeded" }),
|
||||
job({ id: "older-done", status: "failed" }),
|
||||
];
|
||||
expect(visibleRestoreJobs(jobs, new Set()).map((j) => j.id)).toEqual([
|
||||
"running",
|
||||
"newest-done",
|
||||
]);
|
||||
expect(
|
||||
visibleRestoreJobs(jobs, new Set(["newest-done"])).map((j) => j.id),
|
||||
).toEqual(["running", "older-done"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RestoreJobStatus in another language", () => {
|
||||
it("speaks German", () => {
|
||||
const html = render(job({}), "de");
|
||||
expect(html).toContain("alex wird wiederhergestellt");
|
||||
expect(html).toContain("Messwerte werden geschrieben");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The server refuses a file whose manifest claims a section the file does not
|
||||
* carry, before it deletes anything. That refusal reaches a person through the
|
||||
* `meta.errorCode` on the error envelope, and a bare "restore failed" toast
|
||||
* `code` on the restore job's failure, and a bare "restore failed" toast
|
||||
* would waste it: the operator on the other end is frequently holding the only
|
||||
* copy of an account, and the two facts they can act on — which section is
|
||||
* missing, and that nothing was changed — have to stay on screen.
|
||||
@@ -14,7 +14,7 @@
|
||||
* 3. no sections means no notice — a panel that appears after every restore
|
||||
* trains the operator to ignore it,
|
||||
* 4. the copy resolves in more than one locale,
|
||||
* 5. `missingSectionsOf` keys on the errorCode and not on the message text,
|
||||
* 5. `missingSectionsOf` keys on the failure code and not on the message text,
|
||||
* so rewording the server's prose cannot silently remove the panel.
|
||||
*
|
||||
* Mutation check: render a count instead of the list and case 1 goes red; drop
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
RestoreRefusalNotice,
|
||||
missingSectionsOf,
|
||||
} from "@/components/admin/backups-section";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { MissingBackupSection } from "@/lib/export/restore-skips";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -79,33 +78,24 @@ describe("RestoreRefusalNotice", () => {
|
||||
});
|
||||
|
||||
describe("missingSectionsOf", () => {
|
||||
it("reads the sections off the refusal envelope", () => {
|
||||
const err = new ApiError("Backup is missing documents", 422, {
|
||||
errorCode: "backup.section.missing",
|
||||
sections: ["documents"],
|
||||
});
|
||||
|
||||
expect(missingSectionsOf(err)).toEqual(["documents"]);
|
||||
it("reads the sections off the failed job", () => {
|
||||
expect(
|
||||
missingSectionsOf({
|
||||
code: "backup.section.missing",
|
||||
sections: ["documents"],
|
||||
}),
|
||||
).toEqual(["documents"]);
|
||||
});
|
||||
|
||||
it("ignores an error whose message merely looks like one", () => {
|
||||
it("ignores a failure whose message merely looks like one", () => {
|
||||
// The message is prose the server may reword. Only the code counts.
|
||||
const err = new ApiError("Backup is missing documents", 422, {
|
||||
errorCode: "something_else",
|
||||
sections: ["documents"],
|
||||
});
|
||||
|
||||
expect(missingSectionsOf(err)).toEqual([]);
|
||||
expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
missingSectionsOf({ code: "something_else", sections: ["documents"] }),
|
||||
).toEqual([]);
|
||||
expect(missingSectionsOf(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives a refusal with no section list", () => {
|
||||
const err = new ApiError("Refused", 422, {
|
||||
errorCode: "backup.section.missing",
|
||||
});
|
||||
|
||||
expect(missingSectionsOf(err)).toEqual([]);
|
||||
expect(missingSectionsOf({ code: "backup.section.missing" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -130,10 +130,10 @@ describe("BackupsSection — empty state", () => {
|
||||
const className = helpElement?.match(/\bclass="([^"]*)"/)?.[1] ?? "";
|
||||
|
||||
expect(english).toContain(
|
||||
"JSON file matching the current backup schema. Max 10 MB.",
|
||||
"A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
);
|
||||
expect(german).toContain(
|
||||
"JSON-Datei passend zum aktuellen Backup-Schema. Max. 10 MB.",
|
||||
"HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
);
|
||||
expect(helpElement).toBeDefined();
|
||||
expect(className.split(/\s+/)).toContain("text-muted-foreground");
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<RestoreJobStatus>` — one restore job as the backups console shows it.
|
||||
*
|
||||
* A restore runs on the server as a background job (see
|
||||
* `src/lib/jobs/backup-restore.ts`). The console polls
|
||||
* `GET /api/admin/backups/restores` while a job is queued or running and
|
||||
* renders each one here: what it is doing and how far it has got, then how it
|
||||
* ended. Because the list comes from the server, a reload during a restore
|
||||
* picks it up again where the page left it.
|
||||
*
|
||||
* Three rules, each for a way the synchronous restore misled an operator:
|
||||
*
|
||||
* - A running restore says the account still shows its current data. It
|
||||
* does: nothing the restore writes is visible until it commits.
|
||||
* - A failed restore says whether anything changed, and every failure but
|
||||
* one changed nothing. It also says the backup is kept, because the next
|
||||
* question is "do I have to upload it again", and the answer is no.
|
||||
* - Only the job's own row decides the outcome. A poll that fails on the
|
||||
* network is not a failed restore and is never shown as one.
|
||||
*/
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
BackupRestoreFailureCode,
|
||||
BackupRestoreJobView,
|
||||
} from "@/lib/jobs/backup-restore";
|
||||
|
||||
/** Whether a job still holds its account's one restore slot. */
|
||||
export function isActiveRestore(job: BackupRestoreJobView): boolean {
|
||||
return job.status === "queued" || job.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* The jobs worth a panel: every active one, and the newest finished one the
|
||||
* operator has not dismissed.
|
||||
*/
|
||||
export function visibleRestoreJobs(
|
||||
jobs: readonly BackupRestoreJobView[],
|
||||
dismissed: ReadonlySet<string>,
|
||||
): BackupRestoreJobView[] {
|
||||
const active = jobs.filter(isActiveRestore);
|
||||
const finished = jobs.find(
|
||||
(job) => !isActiveRestore(job) && !dismissed.has(job.id),
|
||||
);
|
||||
return finished ? [...active, finished] : active;
|
||||
}
|
||||
|
||||
/** Total records a succeeded restore wrote, from its summary counts. */
|
||||
function restoredCount(job: BackupRestoreJobView): number {
|
||||
let total = 0;
|
||||
for (const value of Object.values(job.result?.summary ?? {})) {
|
||||
if (typeof value === "number") total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure as a sentence in the operator's language.
|
||||
*
|
||||
* Literal `t()` calls, so `i18n-call-site-coverage.test.ts` sees every key.
|
||||
* The `never` at the foot makes the compiler say the chain is exhaustive: a
|
||||
* failure code added on the server without a sentence here stops the build.
|
||||
*/
|
||||
function failureText(
|
||||
code: BackupRestoreFailureCode,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
switch (code) {
|
||||
case "backup_not_found":
|
||||
return t("admin.section.backups.restoreFailureNotFound");
|
||||
case "backup_changed":
|
||||
return t("admin.section.backups.restoreFailureChanged");
|
||||
case "backup.payload.undecryptable":
|
||||
return t("admin.section.backups.restoreFailureUndecryptable");
|
||||
case "schema_invalid":
|
||||
return t("admin.section.backups.restoreFailureSchema");
|
||||
case "incompatible_schema_version":
|
||||
return t("admin.section.backups.restoreFailureVersion");
|
||||
case "owner_mismatch":
|
||||
return t("admin.section.backups.restoreFailureOwnerMismatch");
|
||||
case "owner_not_found":
|
||||
return t("admin.section.backups.restoreFailureOwnerMissing");
|
||||
case "backup.section.missing":
|
||||
return t("admin.section.backups.restoreFailureSectionMissing");
|
||||
case "document_ciphertext_missing":
|
||||
return t("admin.section.backups.restoreFailureDocument");
|
||||
case "time_budget":
|
||||
return t("admin.section.backups.restoreFailureTimeBudget");
|
||||
case "transaction_failed":
|
||||
return t("admin.section.backups.restoreFailureTransaction");
|
||||
case "interrupted":
|
||||
return t("admin.section.backups.restoreFailureInterrupted");
|
||||
case "not_started":
|
||||
return t("admin.section.backups.restoreFailureNotStarted");
|
||||
case "enqueue_failed":
|
||||
return t("admin.section.backups.restoreFailureEnqueue");
|
||||
case "failed_after_commit":
|
||||
return t("admin.section.backups.restoreFailureAfterCommit");
|
||||
case "unexpected":
|
||||
return t("admin.section.backups.restoreFailureUnexpected");
|
||||
default: {
|
||||
const unhandled: never = code;
|
||||
return unhandled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a running job is doing, and the share of it done when that is known. */
|
||||
function runningLine(
|
||||
job: BackupRestoreJobView,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
fmt: ReturnType<typeof useFormatters>,
|
||||
): { text: string; percent: number | null } {
|
||||
const progress = job.progress;
|
||||
switch (job.phase) {
|
||||
case "clearing":
|
||||
return {
|
||||
text: t("admin.section.backups.restoreJobPhaseClearing"),
|
||||
percent: null,
|
||||
};
|
||||
case "measurements": {
|
||||
const total = progress?.measurementsTotal ?? 0;
|
||||
const done = progress?.measurementsWritten ?? 0;
|
||||
return {
|
||||
text: t("admin.section.backups.restoreJobPhaseMeasurements", {
|
||||
done: fmt.integer(done),
|
||||
total: fmt.integer(total),
|
||||
}),
|
||||
percent: total > 0 ? Math.min(100, (done / total) * 100) : null,
|
||||
};
|
||||
}
|
||||
case "sections": {
|
||||
const total = progress?.sectionsTotal ?? 0;
|
||||
const done = progress?.sectionsDone ?? 0;
|
||||
return {
|
||||
text: t("admin.section.backups.restoreJobPhaseSections", {
|
||||
done: fmt.integer(done),
|
||||
total: fmt.integer(total),
|
||||
}),
|
||||
percent: total > 0 ? Math.min(100, (done / total) * 100) : null,
|
||||
};
|
||||
}
|
||||
case "rebuilding":
|
||||
return {
|
||||
text: t("admin.section.backups.restoreJobPhaseRebuilding"),
|
||||
percent: null,
|
||||
};
|
||||
case "validating":
|
||||
default:
|
||||
return {
|
||||
text: t("admin.section.backups.restoreJobPhaseValidating", {
|
||||
checked: fmt.integer(progress?.measurementsChecked ?? 0),
|
||||
}),
|
||||
percent: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function RestoreJobStatus({
|
||||
job,
|
||||
onDismiss,
|
||||
}: {
|
||||
job: BackupRestoreJobView;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const username = job.username ?? job.userId;
|
||||
|
||||
if (isActiveRestore(job)) {
|
||||
const line =
|
||||
job.status === "running"
|
||||
? runningLine(job, t, fmt)
|
||||
: {
|
||||
text: t("admin.section.backups.restoreJobQueuedNote"),
|
||||
percent: null,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
className="border-border bg-muted/30 space-y-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Loader2
|
||||
className="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">
|
||||
{job.status === "queued"
|
||||
? t("admin.section.backups.restoreJobQueuedTitle", {
|
||||
username,
|
||||
})
|
||||
: t("admin.section.backups.restoreJobRunningTitle", {
|
||||
username,
|
||||
})}
|
||||
</p>
|
||||
<p>{line.text}</p>
|
||||
{line.percent !== null ? (
|
||||
<Progress
|
||||
value={line.percent}
|
||||
aria-label={line.text}
|
||||
data-slot="restore-job-progress"
|
||||
/>
|
||||
) : null}
|
||||
{job.attempts > 1 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreJobResumed")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.restoreJobRunningNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (job.status === "succeeded") {
|
||||
const count = restoredCount(job);
|
||||
return (
|
||||
<div
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
className="border-success/40 bg-success/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreJobSucceededTitle", {
|
||||
username,
|
||||
})}
|
||||
</p>
|
||||
{/* An empty file restores to an empty account: honest, and not a
|
||||
tick. The outcome module decides which it looks like. */}
|
||||
<WrittenOutcomeLine
|
||||
outcome={count > 0 ? "success" : "empty"}
|
||||
testId="restore-job-outcome"
|
||||
message={t(
|
||||
"admin.section.backups.restoreJobSucceededDescription",
|
||||
{
|
||||
count: fmt.integer(count),
|
||||
when: job.completedAt ? fmt.dateTime(job.completedAt) : "",
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const code = job.failure?.code ?? "unexpected";
|
||||
const changedNothing = code !== "failed_after_commit";
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="restore-job-status"
|
||||
data-status={job.status}
|
||||
data-failure-code={code}
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreJobFailedTitle", { username })}
|
||||
</p>
|
||||
<p className="text-xs">{failureText(code, t)}</p>
|
||||
{changedNothing ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.backups.restoreJobFailedKept")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,13 @@ import type {
|
||||
RestoreSkipSummary,
|
||||
SkippedCatalogue,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import {
|
||||
isActiveRestore,
|
||||
RestoreJobStatus,
|
||||
visibleRestoreJobs,
|
||||
} from "./backup-restore-status";
|
||||
import {
|
||||
ApiError,
|
||||
apiFetch,
|
||||
@@ -448,16 +454,59 @@ function missingSectionLabel(
|
||||
* carry, and that nothing was changed — have to survive long enough to be read.
|
||||
*/
|
||||
/**
|
||||
* The refused sections carried on an `ApiError`, or none.
|
||||
* The refused sections a failed restore job names, or none.
|
||||
*
|
||||
* Keyed on `meta.errorCode` and not on the message text: the message is prose
|
||||
* Keyed on the failure's `code` and not on its message: the message is prose
|
||||
* the server may reword, and matching on it would turn a copy edit into a
|
||||
* silent loss of the panel.
|
||||
*/
|
||||
export function missingSectionsOf(err: unknown): MissingBackupSection[] {
|
||||
if (!(err instanceof ApiError)) return [];
|
||||
if (err.meta?.errorCode !== "backup.section.missing") return [];
|
||||
const sections = err.meta?.sections;
|
||||
|
||||
const DISMISSED_RESTORES_KEY = "healthlog.admin.backups.dismissedRestores";
|
||||
|
||||
/** Dismissed restore panels, from this browser. Empty when storage is off. */
|
||||
function readDismissedRestores(): string[] {
|
||||
try {
|
||||
if (typeof window === "undefined") return [];
|
||||
const raw = window.localStorage.getItem(DISMISSED_RESTORES_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeDismissedRestores(ids: string[]): void {
|
||||
try {
|
||||
// The list only ever shows a day of jobs; fifty ids is weeks of them.
|
||||
window.localStorage.setItem(
|
||||
DISMISSED_RESTORES_KEY,
|
||||
JSON.stringify(ids.slice(-50)),
|
||||
);
|
||||
} catch {
|
||||
// A private window: the panel comes back on reload, nothing else breaks.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload body for a backup file: the file itself when it is already
|
||||
* gzip, otherwise the file compressed in the browser. The server accepts
|
||||
* both; compressing keeps a large export well inside the request size limit.
|
||||
*/
|
||||
async function uploadBody(file: File): Promise<Blob> {
|
||||
const head = new Uint8Array(await file.slice(0, 2).arrayBuffer());
|
||||
if (head[0] === 0x1f && head[1] === 0x8b) return file;
|
||||
return new Response(
|
||||
file.stream().pipeThrough(new CompressionStream("gzip")),
|
||||
).blob();
|
||||
}
|
||||
|
||||
export function missingSectionsOf(
|
||||
failure: { code: string; sections?: readonly unknown[] } | null | undefined,
|
||||
): MissingBackupSection[] {
|
||||
if (failure?.code !== "backup.section.missing") return [];
|
||||
const sections = failure.sections;
|
||||
if (!Array.isArray(sections)) return [];
|
||||
return sections.filter(
|
||||
(section): section is MissingBackupSection => typeof section === "string",
|
||||
@@ -697,10 +746,11 @@ export function BackupsSection() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
// apiFetch with a raw init: the multipart body must NOT be JSON
|
||||
// re-encoded, so the verb helper (which JSON-stringifies) is out.
|
||||
// Sent as the raw body, compressed on the way unless it already is: the
|
||||
// server reads it as a stream, and a large export (hundreds of MB of
|
||||
// JSON) shrinks about tenfold. No request timeout, because a file of
|
||||
// that size takes longer to check and store than the default allows.
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
valid: true;
|
||||
@@ -734,7 +784,12 @@ export function BackupsSection() {
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", { method: "POST", body: fd });
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const total =
|
||||
@@ -800,9 +855,77 @@ export function BackupsSection() {
|
||||
MissingBackupSection[]
|
||||
>([]);
|
||||
|
||||
// Restore: typed-confirmation dialog. The mutation is keyed by row id
|
||||
// and used inline by `<RestoreRowDialog>` below — keeping the
|
||||
// mutation here lets the parent invalidate the list query on success.
|
||||
// A job this page watched while it ran, and has now seen finish. Only those
|
||||
// raise a toast and refresh the page's data: a job that had already finished
|
||||
// when the page opened is shown in its panel and nothing more. Read in the
|
||||
// poll's own callback, where each answer arrives, rather than in an effect.
|
||||
const watched = useRef<Map<string, string>>(new Map());
|
||||
function noticeFinishedRestores(jobs: readonly BackupRestoreJobView[]) {
|
||||
for (const job of jobs) {
|
||||
const before = watched.current.get(job.id);
|
||||
watched.current.set(job.id, job.status);
|
||||
if (before !== "queued" && before !== "running") continue;
|
||||
if (job.status === "succeeded") {
|
||||
setRefusedSections([]);
|
||||
const report = job.result?.skipped;
|
||||
if (report && report.links > 0) {
|
||||
setSkipped(report as RestoreSkipSummary);
|
||||
toast.warning(
|
||||
t("admin.section.backups.restoreSkippedToast", {
|
||||
links: String(report.links),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setSkipped(null);
|
||||
toast.success(t("admin.section.backups.restoreSuccess"));
|
||||
}
|
||||
// Restore touches every personal-data table; nuke the broader cache
|
||||
// so dashboards / lists rebuild against the new state.
|
||||
void queryClient.invalidateQueries();
|
||||
} else if (job.status === "failed") {
|
||||
setRefusedSections(missingSectionsOf(job.failure));
|
||||
toast.error(t("admin.section.backups.restoreFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The restore jobs, read from the server so a reload during a restore finds
|
||||
// it again. Polled every two seconds while any is queued or running, and
|
||||
// not at all otherwise. A poll that fails keeps the last answer on screen:
|
||||
// a network blip is not a failed restore.
|
||||
const restores = useQuery({
|
||||
queryKey: queryKeys.adminBackupRestores(),
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<{ jobs: BackupRestoreJobView[] }>(
|
||||
"/api/admin/backups/restores",
|
||||
);
|
||||
noticeFinishedRestores(data.jobs);
|
||||
return data;
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.jobs.some(isActiveRestore) ? 2000 : false,
|
||||
});
|
||||
const restoreJobs = restores.data?.jobs ?? [];
|
||||
const activeOwners = new Set(
|
||||
restoreJobs.filter(isActiveRestore).map((job) => job.userId),
|
||||
);
|
||||
|
||||
// Finished restores the operator dismissed. Remembered in this browser only,
|
||||
// for convenience; the list itself forgets a job after a day.
|
||||
const [dismissedRestores, setDismissedRestores] = useState<Set<string>>(
|
||||
() => new Set(readDismissedRestores()),
|
||||
);
|
||||
function dismissRestore(id: string) {
|
||||
setDismissedRestores((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
writeDismissedRestores([...next]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Restore: typed-confirmation dialog. The request only queues the restore;
|
||||
// the panel above the list follows it from there.
|
||||
const restore = useMutation({
|
||||
mutationFn: async ({
|
||||
row,
|
||||
@@ -811,46 +934,35 @@ export function BackupsSection() {
|
||||
row: BackupRow;
|
||||
restoreInstanceSettings: boolean;
|
||||
}) => {
|
||||
// Idempotency-Key prevents a double-click from re-running the
|
||||
// destructive transaction. Include the row id so two different
|
||||
// backups can both be restored independently in the same minute.
|
||||
// Idempotency-Key prevents a double-click from queuing a second
|
||||
// restore. Include the row id so two different backups can both be
|
||||
// restored independently in the same minute.
|
||||
const idempotencyKey = `restore-${row.id}-${randomId()}`;
|
||||
return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>(
|
||||
return apiPost<{ jobId: string; status: "queued" }>(
|
||||
`/api/admin/backups/${row.id}/restore`,
|
||||
{ confirm: "RESTORE", restoreInstanceSettings },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// A restore that dropped links is not a plain success and must not read
|
||||
// as one. The count and the exact keys go on screen; the same report is
|
||||
// in the audit row for anyone asking later.
|
||||
setRefusedSections([]);
|
||||
const report = data.skipped;
|
||||
if (report && report.links > 0) {
|
||||
setSkipped(report);
|
||||
toast.warning(
|
||||
t("admin.section.backups.restoreSkippedToast", {
|
||||
links: String(report.links),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setSkipped(null);
|
||||
toast.success(t("admin.section.backups.restoreSuccess"));
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
// Restore touches every personal-data table; nuke the broader
|
||||
// cache so dashboards / lists rebuild against the new state.
|
||||
queryClient.invalidateQueries();
|
||||
setSkipped(null);
|
||||
watched.current.set(data.jobId, "queued");
|
||||
// Neutral, not a success: nothing has been restored yet.
|
||||
toast.info(t("admin.section.backups.restoreStarted"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminBackupRestores(),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
// The server refuses an incomplete file before it deletes anything, and
|
||||
// names the sections in `meta`. That is the one restore failure the
|
||||
// operator can act on, so it gets the panel rather than only a toast.
|
||||
const sections = missingSectionsOf(err);
|
||||
if (sections.length > 0) {
|
||||
setRefusedSections(sections);
|
||||
toast.error(t("admin.section.backups.restoreMissingTitle"));
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "backup.restore.active"
|
||||
) {
|
||||
toast.error(t("admin.section.backups.restoreActive"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminBackupRestores(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRefusedSections([]);
|
||||
@@ -959,7 +1071,7 @@ export function BackupsSection() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
accept="application/json,.json,application/gzip,.gz"
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
aria-label={t("admin.section.backups.uploadButton")}
|
||||
@@ -981,6 +1093,14 @@ export function BackupsSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
key={job.id}
|
||||
job={job}
|
||||
onDismiss={() => dismissRestore(job.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<RestoreRefusalNotice
|
||||
sections={refusedSections}
|
||||
onDismiss={() => setRefusedSections([])}
|
||||
@@ -1112,8 +1232,9 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
(restore.isPending &&
|
||||
restore.variables?.row.id === row.id) ||
|
||||
activeOwners.has(row.userId)
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
@@ -1175,8 +1296,9 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
(restore.isPending &&
|
||||
restore.variables?.row.id === row.id) ||
|
||||
activeOwners.has(row.userId)
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
|
||||
@@ -46,6 +46,7 @@ import { EmergencySection, PatientIdentitySection } from "./identity-sections";
|
||||
import {
|
||||
IllnessSection,
|
||||
ImmunizationsSection,
|
||||
SurgicalHistorySection,
|
||||
LabResultsSection,
|
||||
VisitsSection,
|
||||
} from "./history-sections";
|
||||
@@ -256,6 +257,12 @@ export function ClinicianView({
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<SurgicalHistorySection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<ImmunizationsSection
|
||||
t={t}
|
||||
report={report}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The `labs` and `history` groups on the clinician view: lab results, illness
|
||||
* episodes, visits and the immunization record.
|
||||
* episodes, visits, the surgical history and the immunization record.
|
||||
*
|
||||
* All four were selectable on a share link and none of them reached the page,
|
||||
* while the PDF download from the same link printed every one. What each
|
||||
@@ -12,8 +12,11 @@
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
|
||||
import type { EncounterKind, Laterality } from "@/generated/prisma/client";
|
||||
import {
|
||||
encounterKindLabelKey,
|
||||
lateralityLabelKey,
|
||||
} from "@/lib/encounters/kind-label";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
@@ -221,6 +224,53 @@ export function VisitsSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The surgical history. Reference data, not window-bounded, like the
|
||||
* immunization record below: every procedure that happened, oldest first. What
|
||||
* was done is the row's label; the date, the site with its side and the
|
||||
* outcome compose onto the value side, the shape the immunization rows use.
|
||||
*/
|
||||
export function SurgicalHistorySection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
}) {
|
||||
const procedures = report.surgicalHistory ?? [];
|
||||
|
||||
const site = (row: (typeof procedures)[number]) => {
|
||||
if (!row.bodySite) return null;
|
||||
if (!row.laterality) return row.bodySite;
|
||||
return t("encounters.bodySiteWithSide", {
|
||||
site: row.bodySite,
|
||||
side: t(lateralityLabelKey(row.laterality as Laterality)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["SURGICAL_HISTORY"]}
|
||||
title={t("doctorReport.surgicalHistoryTitle")}
|
||||
empty={procedures.length === 0}
|
||||
>
|
||||
{procedures.map((row, index) => (
|
||||
<StatRow
|
||||
key={`${row.occurredAt}-${index}`}
|
||||
label={row.procedure ?? "—"}
|
||||
value={compose([fmtDate(row.occurredAt), site(row), row.outcome])}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The immunization record. Reference data, not window-bounded: an Impfpass is
|
||||
* a lifetime document and the whole of it rides when the leaf and the module
|
||||
|
||||
@@ -28,6 +28,15 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,9 +28,27 @@ function dayBase(date: string): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The opening tag of one day's cell, by its date in the aria-label. */
|
||||
function cellTag(html: string, date: string): string {
|
||||
const match = html.match(
|
||||
new RegExp(`<(?:button|div)[^>]*aria-label="${date}[^"]*"[^>]*>`),
|
||||
);
|
||||
if (!match) throw new Error(`no cell for ${date}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe("<CycleCalendar>", () => {
|
||||
const today = "2026-06-15";
|
||||
|
||||
@@ -130,4 +148,85 @@ describe("<CycleCalendar>", () => {
|
||||
);
|
||||
expect(html).toContain('data-fertile="true"');
|
||||
});
|
||||
|
||||
describe("per-day entries beyond period and symptoms (#1032)", () => {
|
||||
it("marks an intercourse day with its own marker and names it", () => {
|
||||
const days = [{ ...dayBase("2026-06-05"), sexualActivity: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-05");
|
||||
expect(cell).toContain('data-intercourse="true"');
|
||||
expect(cell).toContain("Intercourse");
|
||||
// Only that day.
|
||||
expect(cellTag(html, "2026-06-06")).not.toContain("data-intercourse");
|
||||
expect(html).toContain('data-slot="cycle-calendar-intercourse"');
|
||||
});
|
||||
|
||||
it("the legend names intercourse and the other-entries marker", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-legend="cycle.calendar.legendIntercourse"');
|
||||
expect(html).toContain('data-legend="cycle.calendar.legendOtherEntries"');
|
||||
expect(html).toContain("Intercourse");
|
||||
expect(html).toContain("Other entries");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a pregnancy test",
|
||||
{ pregnancyTest: "POSITIVE" },
|
||||
"Pregnancy test (Positive)",
|
||||
],
|
||||
[
|
||||
"a progesterone test",
|
||||
{ progesteroneTest: "NEGATIVE" },
|
||||
"Progesterone test (Negative)",
|
||||
],
|
||||
["contraception", { contraceptive: "ORAL" }, "Contraceptive (Pill)"],
|
||||
[
|
||||
"spotting",
|
||||
{ intermenstrualBleeding: true },
|
||||
"Spotting between periods",
|
||||
],
|
||||
["a note", { hasNote: true }, "Note"],
|
||||
["a temperature", { basalBodyTempC: 36.6 }, "Basal body temperature"],
|
||||
[
|
||||
"an ovulation test",
|
||||
{ ovulationTest: "POSITIVE_LH_SURGE" },
|
||||
"Ovulation test (LH surge)",
|
||||
],
|
||||
[
|
||||
"a mucus reading",
|
||||
{ cervicalMucus: "CREAMY" },
|
||||
"Cervical mucus (Creamy)",
|
||||
],
|
||||
["a cervix sign", { cervixPosition: "HIGH" }, "Cervix"],
|
||||
] as const)(
|
||||
"a day with only %s logged no longer looks empty",
|
||||
(_label, over, aria) => {
|
||||
const days = [{ ...dayBase("2026-06-08"), ...over } as CalendarDay];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-08");
|
||||
expect(cell).toContain('data-other-entries="true"');
|
||||
expect(cell).toContain(aria);
|
||||
},
|
||||
);
|
||||
|
||||
it("an empty day carries neither marker", () => {
|
||||
const html = render(
|
||||
<CycleCalendar
|
||||
days={[dayBase("2026-06-08")]}
|
||||
today={today}
|
||||
onSelectDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-08");
|
||||
expect(cell).not.toContain("data-other-entries");
|
||||
expect(cell).not.toContain("data-intercourse");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +281,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CalendarDay } from "./types";
|
||||
import {
|
||||
FERTILE_HUE,
|
||||
FLOW_HUE,
|
||||
INTERCOURSE_HUE,
|
||||
OVULATION_HUE,
|
||||
flowOpacity,
|
||||
} from "./phase-tokens";
|
||||
@@ -25,7 +26,11 @@ import {
|
||||
* single dated dot); the fertile window (already goal-gated server-side — the
|
||||
* API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the
|
||||
* predicted-ovulation day gets an amber marker; per-day symptom presence
|
||||
* shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet).
|
||||
* shows a small dot; intercourse shows a diamond; anything else logged that
|
||||
* day (a test, contraception, spotting, a temperature, a mucus or cervix
|
||||
* reading, a note) shows a hollow ring, so a day with only one of those
|
||||
* logged no longer looks like a day with nothing logged. Selecting a day
|
||||
* calls `onSelectDay` (the log-day sheet).
|
||||
*
|
||||
* a11y: each day is a real `<button>` with an aria-label restating the date
|
||||
* + its markers; the colour markers are paired with the aria text so the
|
||||
@@ -60,6 +65,64 @@ function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
/** `LABEL (VALUE)`, or the bare label when there is no value to name. */
|
||||
function named(t: Translate, labelKey: string, valueKey?: string): string {
|
||||
return valueKey ? `${t(labelKey)} (${t(valueKey)})` : t(labelKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a day holds beyond flow, symptoms, intercourse and the
|
||||
* predictions, each spelled out for the cell's aria-label. Empty means the
|
||||
* other-entries ring stays off. Every field here is one the day log can hold
|
||||
* and the grid has no marker of its own for.
|
||||
*/
|
||||
export function otherEntryLabels(day: CalendarDay, t: Translate): string[] {
|
||||
const out: string[] = [];
|
||||
if (day.intermenstrualBleeding)
|
||||
out.push(t("cycle.sheet.intermenstrualBleeding"));
|
||||
if (day.basalBodyTempC != null) out.push(t("cycle.sheet.temperature"));
|
||||
if (day.ovulationTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.ovulationTest",
|
||||
`cycle.ovulationTest.${day.ovulationTest}`,
|
||||
),
|
||||
);
|
||||
if (day.cervicalMucus)
|
||||
out.push(named(t, "cycle.sheet.mucus", `cycle.mucus.${day.cervicalMucus}`));
|
||||
if (day.cervixPosition || day.cervixFirmness || day.cervixOpening)
|
||||
out.push(t("cycle.sheet.cervix"));
|
||||
if (day.pregnancyTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.pregnancyTest",
|
||||
`cycle.testResult.${day.pregnancyTest}`,
|
||||
),
|
||||
);
|
||||
if (day.progesteroneTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.progesteroneTest",
|
||||
`cycle.testResult.${day.progesteroneTest}`,
|
||||
),
|
||||
);
|
||||
if (day.contraceptive)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.contraceptive",
|
||||
`cycle.contraceptive.${day.contraceptive}`,
|
||||
),
|
||||
);
|
||||
if (day.hasNote) out.push(t("cycle.sheet.note"));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CycleCalendarProps {
|
||||
days: CalendarDay[];
|
||||
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
|
||||
@@ -222,6 +285,11 @@ export function CycleCalendar({
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
const hasIntercourse = !!info?.sexualActivity;
|
||||
if (hasIntercourse)
|
||||
markers.push(t("cycle.calendar.legendIntercourse"));
|
||||
const others = info ? otherEntryLabels(info, t) : [];
|
||||
markers.push(...others);
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
const flowLevel =
|
||||
@@ -296,7 +364,7 @@ export function CycleCalendar({
|
||||
oval lives behind the number, so it needs no row dot. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
|
||||
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
|
||||
>
|
||||
{isPredictedOvulationDot ? (
|
||||
<span
|
||||
@@ -307,6 +375,19 @@ export function CycleCalendar({
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
{hasIntercourse ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-intercourse"
|
||||
className="mx-px size-2 rotate-45"
|
||||
style={{ backgroundColor: INTERCOURSE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-other"
|
||||
className="border-muted-foreground size-1.5 rounded-full border"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -330,6 +411,8 @@ export function CycleCalendar({
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined,
|
||||
"data-intercourse": hasIntercourse ? "true" : undefined,
|
||||
"data-other-entries": others.length > 0 ? "true" : undefined,
|
||||
};
|
||||
|
||||
return onSelectDay ? (
|
||||
@@ -363,11 +446,20 @@ function CalendarLegend() {
|
||||
// Each swatch mirrors its grid affordance: period = filled, predicted =
|
||||
// dashed, fertile = a soft band fill, predicted-ovulation = a filled dot,
|
||||
// confirmed-ovulation = a ringed light oval (distinct from the dot), symptom
|
||||
// = the small grey marker dot the grid draws (QA M4).
|
||||
// = the small grey marker dot the grid draws (QA M4), intercourse = the
|
||||
// diamond, other entries = the hollow ring.
|
||||
const items: {
|
||||
hue: string;
|
||||
labelKey: string;
|
||||
variant: "fill" | "dashed" | "band" | "oval" | "dot";
|
||||
variant:
|
||||
| "fill"
|
||||
| "dashed"
|
||||
| "band"
|
||||
| "oval"
|
||||
| "dot"
|
||||
| "smallDot"
|
||||
| "diamond"
|
||||
| "ring";
|
||||
}[] = [
|
||||
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
|
||||
{
|
||||
@@ -393,7 +485,17 @@ function CalendarLegend() {
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
labelKey: "cycle.calendar.legendSymptom",
|
||||
variant: "dot",
|
||||
variant: "smallDot",
|
||||
},
|
||||
{
|
||||
hue: INTERCOURSE_HUE,
|
||||
labelKey: "cycle.calendar.legendIntercourse",
|
||||
variant: "diamond",
|
||||
},
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
labelKey: "cycle.calendar.legendOtherEntries",
|
||||
variant: "ring",
|
||||
},
|
||||
];
|
||||
return (
|
||||
@@ -407,14 +509,16 @@ function CalendarLegend() {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
it.variant === "oval"
|
||||
? "h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset"
|
||||
: "rounded-full",
|
||||
it.variant === "dot" && it.hue === "var(--muted-foreground)"
|
||||
? "h-1 w-1"
|
||||
: it.variant === "oval"
|
||||
? ""
|
||||
: "h-2.5 w-2.5",
|
||||
it.variant === "oval" &&
|
||||
"h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset",
|
||||
(it.variant === "fill" ||
|
||||
it.variant === "dashed" ||
|
||||
it.variant === "band" ||
|
||||
it.variant === "dot") &&
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
it.variant === "smallDot" && "h-1 w-1 rounded-full",
|
||||
it.variant === "diamond" && "mx-0.5 size-2 rotate-45",
|
||||
it.variant === "ring" && "size-2 rounded-full border",
|
||||
it.variant === "dashed" && "opacity-60",
|
||||
it.variant === "band" && "opacity-30",
|
||||
)}
|
||||
@@ -425,7 +529,9 @@ function CalendarLegend() {
|
||||
opacity: 0.3,
|
||||
"--tw-ring-color": it.hue,
|
||||
} as React.CSSProperties)
|
||||
: { backgroundColor: it.hue }
|
||||
: it.variant === "ring"
|
||||
? { borderColor: it.hue }
|
||||
: { backgroundColor: it.hue }
|
||||
}
|
||||
/>
|
||||
{t(it.labelKey)}
|
||||
|
||||
@@ -22,6 +22,12 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
/**
|
||||
* Intercourse marker tint (luteal family). Never colour-only: the marker is a
|
||||
* diamond, a shape no other calendar marker uses, and the cell's aria-label
|
||||
* names it.
|
||||
*/
|
||||
export const INTERCOURSE_HUE = "var(--cycle-phase-luteal)";
|
||||
|
||||
/**
|
||||
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface CalendarDay {
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
cervicalMucus: CervicalMucus | null;
|
||||
/** Logged cervix signs, each or null. */
|
||||
cervixPosition: string | null;
|
||||
cervixFirmness: string | null;
|
||||
cervixOpening: string | null;
|
||||
/** Spotting outside the period logged on this day. */
|
||||
intermenstrualBleeding: boolean;
|
||||
/** Intercourse logged on this day (resolved server-side, envelope included). */
|
||||
sexualActivity: boolean;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
/** Whether the day carries a note; the text is only on the day-log read. */
|
||||
hasNote: boolean;
|
||||
}
|
||||
|
||||
export interface CyclePrediction {
|
||||
|
||||
@@ -80,6 +80,8 @@ interface Schedule extends ScheduleWindowInput {
|
||||
export interface MedicationOption extends DefaultMedicationOption {
|
||||
dose: string;
|
||||
schedules: Schedule[];
|
||||
/** v1.39.1 (#1033) — false: kept as a record, not offered for a dose. */
|
||||
trackIntake?: boolean;
|
||||
}
|
||||
|
||||
interface MedicationIntakeQuickAddProps {
|
||||
@@ -138,7 +140,9 @@ export function MedicationIntakeQuickAdd({
|
||||
const medications = useMemo(
|
||||
() =>
|
||||
Array.isArray(medicationsRaw)
|
||||
? medicationsRaw.filter((m) => m.active)
|
||||
? // v1.39.1 (#1033) — a medication with intake tracking off is
|
||||
// kept as a record; the dose capture does not offer it.
|
||||
medicationsRaw.filter((m) => m.active && m.trackIntake !== false)
|
||||
: [],
|
||||
[medicationsRaw],
|
||||
);
|
||||
|
||||
@@ -165,4 +165,78 @@ describe("<DocumentRecordLinks>", () => {
|
||||
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
|
||||
expect(html).not.toContain('data-slot="document-visit-links-add"');
|
||||
});
|
||||
|
||||
describe("chips open the record they point at (#1024)", () => {
|
||||
it("a manager's dose chip carries the dose date and opens the dose", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({
|
||||
vaccinationLinks: [
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: "tetanus",
|
||||
vaccineName: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
canManage
|
||||
seedFresh
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const open = html.match(
|
||||
/<a[^>]*data-slot="document-vaccination-links-chip-open"[^>]*>/,
|
||||
);
|
||||
expect(open?.[0]).toContain('href="/vaccinations?dose=dose-1"');
|
||||
// The date tells two doses of one vaccine apart.
|
||||
expect(open?.[0]).toBeDefined();
|
||||
expect(html).toMatch(/>Tetanus<\/span><span[^>]*>04\/02\/1991</);
|
||||
// The label is not the unlink control; the X is.
|
||||
expect(html).toContain(
|
||||
'data-slot="document-vaccination-links-chip-remove"',
|
||||
);
|
||||
});
|
||||
|
||||
it("a reader's dose chip opens that dose, not the whole list", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({
|
||||
vaccinationLinks: [
|
||||
{
|
||||
vaccinationId: "dose-1",
|
||||
occurredAt: "1991-04-02T00:00:00.000Z",
|
||||
catalogSlug: null,
|
||||
vaccineName: "DTP",
|
||||
},
|
||||
],
|
||||
})}
|
||||
canManage={false}
|
||||
seedFresh
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="/vaccinations?dose=dose-1"');
|
||||
});
|
||||
|
||||
it("a reader's visit chip opens the visit", () => {
|
||||
const html = render(
|
||||
<DocumentRecordLinks
|
||||
doc={doc({
|
||||
encounterLinks: [
|
||||
{
|
||||
encounterId: "enc-1",
|
||||
kind: "CHECKUP",
|
||||
occurredAt: "2026-03-01T09:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as never)}
|
||||
canManage={false}
|
||||
seedFresh
|
||||
onChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('href="/checkups?visit=enc-1"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ export function recordOptions(
|
||||
label: string;
|
||||
meta?: string | null;
|
||||
occurredAt: string;
|
||||
href?: string | null;
|
||||
}>,
|
||||
anchor: string | null,
|
||||
labels: { suggested: string; date: (iso: string) => string },
|
||||
@@ -59,6 +60,7 @@ export function recordOptions(
|
||||
label: record.label,
|
||||
meta: record.meta ?? null,
|
||||
dateLabel: labels.date(record.occurredAt),
|
||||
href: record.href ?? null,
|
||||
};
|
||||
if (isNearAnchor(record.occurredAt, anchor)) {
|
||||
near.push({
|
||||
@@ -73,6 +75,16 @@ export function recordOptions(
|
||||
return [...near, ...rest];
|
||||
}
|
||||
|
||||
/** Where a visit opens: its sheet on the checkups page. */
|
||||
function visitHref(encounterId: string): string {
|
||||
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
|
||||
}
|
||||
|
||||
/** Where a dose opens: its sheet on the vaccinations page. */
|
||||
function doseHref(vaccinationId: string): string {
|
||||
return `/vaccinations?dose=${encodeURIComponent(vaccinationId)}`;
|
||||
}
|
||||
|
||||
/** A dose named the way the vaccination page names it. */
|
||||
function doseName(
|
||||
t: Translate,
|
||||
@@ -146,6 +158,7 @@ export function DocumentRecordLinks({
|
||||
label: encounterKindText(t, visit.kind as EncounterKind),
|
||||
meta: visit.practitioner?.name ?? null,
|
||||
occurredAt: visit.occurredAt,
|
||||
href: visitHref(visit.id),
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
@@ -155,6 +168,7 @@ export function DocumentRecordLinks({
|
||||
id: dose.id,
|
||||
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
|
||||
occurredAt: dose.occurredAt,
|
||||
href: doseHref(dose.id),
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
@@ -194,7 +208,7 @@ export function DocumentRecordLinks({
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
href={visitHref(link.encounterId)}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
@@ -244,7 +258,7 @@ export function DocumentRecordLinks({
|
||||
{(doc.vaccinationLinks ?? []).map((link) => (
|
||||
<Link
|
||||
key={link.vaccinationId}
|
||||
href="/vaccinations"
|
||||
href={doseHref(link.vaccinationId)}
|
||||
data-slot="document-vaccination-link"
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
|
||||
@@ -132,6 +132,8 @@ describe("editing never unfiles what the visit already collected", () => {
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
bodySite: null,
|
||||
laterality: null,
|
||||
reminderNextDueAt: null,
|
||||
links: {
|
||||
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
|
||||
@@ -216,3 +218,96 @@ describe("the link pickers follow the modules that own their targets", () => {
|
||||
expect(html).toContain('data-slot="encounter-link-pickers"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.1 — the body site. Shown for a procedure, where it means something,
|
||||
* and kept visible on any visit that already carries one, so switching the
|
||||
* kind away never hides text the person typed. Never required.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - show the field only for PROCEDURE → "keeps a typed site visible after
|
||||
* the kind moves away" goes red;
|
||||
* - send `bodySite` untrimmed → the whitespace case goes red.
|
||||
*/
|
||||
describe("the body site", () => {
|
||||
function renderDraft(over: Parameters<typeof emptyDraft>[0]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<EncounterForm
|
||||
draft={emptyDraft({ occurredAt: "2026-08-08T10:00", ...over })}
|
||||
onChange={() => {}}
|
||||
isEdit
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers the site and the side for a procedure", () => {
|
||||
const html = renderDraft({ kind: "PROCEDURE" });
|
||||
expect(html).toContain('id="encounter-body-site"');
|
||||
expect(html).toContain('id="encounter-laterality"');
|
||||
expect(html).toContain("Body site");
|
||||
expect(html).toContain("Not stated");
|
||||
});
|
||||
|
||||
it("keeps the fields off an ordinary visit", () => {
|
||||
const html = renderDraft({ kind: "ROUTINE" });
|
||||
expect(html).not.toContain('id="encounter-body-site"');
|
||||
});
|
||||
|
||||
it("keeps a typed site visible after the kind moves away", () => {
|
||||
const html = renderDraft({ kind: "HOSPITAL", bodySite: "Knee" });
|
||||
expect(html).toContain('id="encounter-body-site"');
|
||||
});
|
||||
|
||||
it("offers Procedure or surgery among the kinds", () => {
|
||||
const html = renderDraft({ kind: "OTHER" });
|
||||
expect(html).toContain('value="PROCEDURE"');
|
||||
expect(html).toContain("Procedure or surgery");
|
||||
});
|
||||
|
||||
it("sends the site trimmed and the side, and null for an empty site", () => {
|
||||
const withSite = draftToBody(
|
||||
emptyDraft({
|
||||
occurredAt: "2026-08-08T10:00",
|
||||
kind: "PROCEDURE",
|
||||
bodySite: " Knee ",
|
||||
laterality: "LEFT",
|
||||
}),
|
||||
{ isEdit: false },
|
||||
);
|
||||
expect(withSite?.bodySite).toBe("Knee");
|
||||
expect(withSite?.laterality).toBe("LEFT");
|
||||
|
||||
const empty = draftToBody(
|
||||
emptyDraft({ occurredAt: "2026-08-08T10:00", bodySite: " " }),
|
||||
{ isEdit: false },
|
||||
);
|
||||
expect(empty?.bodySite).toBeNull();
|
||||
expect(empty?.laterality).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the site and the side from the row being edited", () => {
|
||||
const draft = draftFromEncounter({
|
||||
id: "e1",
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "PROCEDURE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
bodySite: "Knee",
|
||||
laterality: "RIGHT",
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
});
|
||||
expect(draft.bodySite).toBe("Knee");
|
||||
expect(draft.laterality).toBe("RIGHT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The procedure history's designed states, pinned by single-pass static
|
||||
* renders (the repository's component-test convention; typing into the filter
|
||||
* is driven in the browser check, not here).
|
||||
*
|
||||
* - an account with no procedure gets an empty state that says how to make
|
||||
* one — including switching a visit already on file — and one action;
|
||||
* - a history lists what the server sent, in the server's order, each row
|
||||
* naming its body site with the side;
|
||||
* - the body sites are offered as filter choices, with "All" pressed while
|
||||
* nothing is filtered;
|
||||
* - a failed read is an error with a retry, never the empty state.
|
||||
*
|
||||
* Mutation checks (each run, each seen red):
|
||||
* - render `procedures.length === 0` as the account-empty state instead of
|
||||
* keying it on `total` → "keeps the filter when nothing matches" goes red;
|
||||
* - drop the side from `bodySiteText` → "names the site with its side" goes
|
||||
* red.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWrite: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO, ProcedureListDTO } from "@/lib/encounters/dto";
|
||||
import { ProceduresSection } from "../procedures-section";
|
||||
|
||||
function procedure(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: "2011-03-14T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "PROCEDURE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
bodySite: null,
|
||||
laterality: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2011-03-14T09:00:00.000Z",
|
||||
updatedAt: "2011-03-14T09:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(data: ProcedureListDTO | "error") {
|
||||
const queryClient = new QueryClient({
|
||||
// `retryOnMount: false` so a seeded error stays an error through the
|
||||
// render instead of being reported as a fresh fetch.
|
||||
defaultOptions: { queries: { retry: false, retryOnMount: false } },
|
||||
});
|
||||
const key = queryKeys.encounterProcedures("", null);
|
||||
if (data === "error") {
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.build(queryClient, { queryKey: key })
|
||||
.setState({
|
||||
status: "error",
|
||||
error: new Error("boom"),
|
||||
fetchStatus: "idle",
|
||||
});
|
||||
} else {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProceduresSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ProceduresSection>", () => {
|
||||
it("explains how to get a first procedure, including switching a visit", () => {
|
||||
const html = render({ procedures: [], bodySites: [], total: 0 });
|
||||
expect(html).toContain("No procedure recorded yet");
|
||||
expect(html).toContain("switch an existing visit to it");
|
||||
expect(html).toContain('data-slot="procedures-add"');
|
||||
expect(html).not.toContain('data-slot="procedures-filter"');
|
||||
expect(html).not.toContain("Procedures could not be loaded.");
|
||||
});
|
||||
|
||||
it("lists the history in the server's order and names the site with its side", () => {
|
||||
const html = render({
|
||||
procedures: [
|
||||
procedure({
|
||||
id: "newer",
|
||||
occurredAt: "2019-07-02T09:00:00.000Z",
|
||||
reason: "Cholecystectomy",
|
||||
bodySite: "Gallbladder",
|
||||
}),
|
||||
procedure({
|
||||
id: "older",
|
||||
reason: "Meniscus repair",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
}),
|
||||
],
|
||||
bodySites: [
|
||||
{ bodySite: "Gallbladder", laterality: null, count: 1 },
|
||||
{ bodySite: "Knee", laterality: "LEFT", count: 1 },
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
expect(html.indexOf('data-encounter-id="newer"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="older"'),
|
||||
);
|
||||
expect(html).toContain("Meniscus repair");
|
||||
expect(html).toContain("Knee · Left");
|
||||
expect(html).toContain("2 of 2");
|
||||
});
|
||||
|
||||
it("heads a procedure with what was done, and keeps the practice on its own line", () => {
|
||||
const html = render({
|
||||
procedures: [
|
||||
procedure({
|
||||
id: "p1",
|
||||
reason: "Meniscus repair",
|
||||
practitioner: {
|
||||
id: "pr1",
|
||||
name: "Sample clinic",
|
||||
specialty: null,
|
||||
practice: null,
|
||||
location: null,
|
||||
phone: null,
|
||||
note: null,
|
||||
createdAt: "2011-01-01T00:00:00.000Z",
|
||||
updatedAt: "2011-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
],
|
||||
bodySites: [],
|
||||
total: 1,
|
||||
});
|
||||
expect(html).toMatch(/font-medium">Meniscus repair</);
|
||||
expect(html).toContain("Sample clinic");
|
||||
expect(html.match(/Meniscus repair/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("offers every body site as a filter, with All pressed while nothing is filtered", () => {
|
||||
const html = render({
|
||||
procedures: [
|
||||
procedure({ id: "p1", bodySite: "Knee", laterality: "LEFT" }),
|
||||
],
|
||||
bodySites: [{ bodySite: "Knee", laterality: "LEFT", count: 1 }],
|
||||
total: 1,
|
||||
});
|
||||
expect(html).toContain('data-slot="procedures-filter"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the filter when nothing matches, and offers to clear it", () => {
|
||||
const html = render({
|
||||
procedures: [],
|
||||
bodySites: [{ bodySite: "Knee", laterality: null, count: 1 }],
|
||||
total: 1,
|
||||
});
|
||||
expect(html).toContain("Nothing matches this filter");
|
||||
expect(html).toContain('data-slot="procedures-filter"');
|
||||
expect(html).not.toContain("No procedure recorded yet");
|
||||
});
|
||||
|
||||
it("paints an error with a retry, never the empty state", () => {
|
||||
const html = render("error");
|
||||
expect(html).toContain("Procedures could not be loaded.");
|
||||
expect(html).not.toContain("No procedure recorded yet");
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,8 @@ function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
bodySite: null,
|
||||
laterality: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -36,8 +37,10 @@ import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
import {
|
||||
ENCOUNTER_KINDS,
|
||||
ENCOUNTER_STATUSES,
|
||||
LATERALITIES,
|
||||
encounterKindText,
|
||||
encounterStatusText,
|
||||
lateralityText,
|
||||
} from "./encounter-labels";
|
||||
import { PractitionerCombobox } from "./practitioner-combobox";
|
||||
import { EncounterLinkPickers } from "./encounter-link-pickers";
|
||||
@@ -50,6 +53,9 @@ export interface EncounterDraft {
|
||||
practitioner: Practitioner | null;
|
||||
reason: string;
|
||||
outcome: string;
|
||||
/** Where on the body; free text, empty when not stated. */
|
||||
bodySite: string;
|
||||
laterality: "LEFT" | "RIGHT" | "BOTH" | null;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
@@ -82,6 +88,8 @@ export function emptyDraft(
|
||||
practitioner: null,
|
||||
reason: "",
|
||||
outcome: "",
|
||||
bodySite: "",
|
||||
laterality: null,
|
||||
documentIds: [],
|
||||
labResultIds: [],
|
||||
episodeIds: [],
|
||||
@@ -97,6 +105,8 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
|
||||
practitioner: row.practitioner,
|
||||
reason: row.reason ?? "",
|
||||
outcome: row.outcome ?? "",
|
||||
bodySite: row.bodySite ?? "",
|
||||
laterality: row.laterality,
|
||||
documentIds: row.links?.documents.map((link) => link.id) ?? [],
|
||||
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
|
||||
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
|
||||
@@ -131,6 +141,8 @@ export function draftToBody(
|
||||
practitionerId: draft.practitioner?.id ?? null,
|
||||
reason: draft.reason.trim() || null,
|
||||
outcome: draft.outcome.trim() || null,
|
||||
bodySite: draft.bodySite.trim() || null,
|
||||
laterality: draft.laterality,
|
||||
...(options.reminderId ? { reminderId: options.reminderId } : {}),
|
||||
documentIds: draft.documentIds,
|
||||
labResultIds: draft.labResultIds,
|
||||
@@ -168,6 +180,15 @@ export function EncounterForm({
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
|
||||
|
||||
// The body site belongs to a procedure, so that is where it is offered. A
|
||||
// visit that already carries one keeps the fields in view whatever its kind:
|
||||
// switching the kind away must not hide text the person typed, and the
|
||||
// server keeps it either way.
|
||||
const showBodySite =
|
||||
draft.kind === "PROCEDURE" ||
|
||||
draft.bodySite.trim().length > 0 ||
|
||||
draft.laterality !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="encounter-form">
|
||||
<FieldGroup
|
||||
@@ -234,6 +255,47 @@ export function EncounterForm({
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
|
||||
{showBodySite ? (
|
||||
<div className="grid gap-4 sm:grid-cols-[2fr_1fr]">
|
||||
<FieldGroup
|
||||
htmlFor="encounter-body-site"
|
||||
label={t("encounters.form.bodySite")}
|
||||
hint={t("encounters.form.bodySiteHint")}
|
||||
>
|
||||
<Input
|
||||
id="encounter-body-site"
|
||||
value={draft.bodySite}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
onChange={(event) => patch({ bodySite: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup
|
||||
htmlFor="encounter-laterality"
|
||||
label={t("encounters.form.laterality")}
|
||||
>
|
||||
<NativeSelect
|
||||
id="encounter-laterality"
|
||||
value={draft.laterality ?? ""}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
laterality:
|
||||
(event.target.value as EncounterDraft["laterality"]) ||
|
||||
null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{t("encounters.laterality.none")}</option>
|
||||
{LATERALITIES.map((side) => (
|
||||
<option key={side} value={side}>
|
||||
{lateralityText(t, side)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-reason"
|
||||
label={t("encounters.form.reason")}
|
||||
|
||||
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user