mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46191337fc | ||
|
|
fed37829e2 | ||
|
|
1ae21b5971 | ||
|
|
b52262e27f | ||
|
|
4f2c90b66c | ||
|
|
5f8af25815 | ||
|
|
19d534432e | ||
|
|
bec4cb3dfb | ||
|
|
154b609768 | ||
|
|
b06d7dbd92 | ||
|
|
c5a01767aa | ||
|
|
ce73729916 | ||
|
|
2dc553057d | ||
|
|
f4c0eb0ab9 | ||
|
|
87929eb3b8 | ||
|
|
97d0c26ceb | ||
|
|
b76468f455 | ||
|
|
590911ca72 | ||
|
|
fefda545ae | ||
|
|
1efac85a68 | ||
|
|
6f2f43bb49 | ||
|
|
c1811bc85b | ||
|
|
251a42bb0a | ||
|
|
d49479bc64 | ||
|
|
475db3073c | ||
|
|
3ae5bfe1c0 | ||
|
|
60e17a2617 | ||
|
|
50287d2743 | ||
|
|
4d53241574 | ||
|
|
032ea5b1a0 | ||
|
|
965d41f1ed | ||
|
|
50b483d8d1 | ||
|
|
c985f634aa | ||
|
|
30930b46d0 | ||
|
|
bb9bf0ba68 | ||
|
|
edccc17a4d | ||
|
|
75f4a39066 | ||
|
|
79a1395104 | ||
|
|
6f62f66105 | ||
|
|
aa80cb45c1 | ||
|
|
b6e34d1264 | ||
|
|
e39e2b60c2 | ||
|
|
a283211586 | ||
|
|
49da15feaa | ||
|
|
7f67c24ecc | ||
|
|
63f791120a | ||
|
|
d66c4f6cdc | ||
|
|
b6efc6cb5a | ||
|
|
1c2e6c5c80 | ||
|
|
c8035fdd78 | ||
|
|
a74dc289a8 | ||
|
|
768320f3d3 | ||
|
|
14638a6660 | ||
|
|
a6c239d952 | ||
|
|
775efad923 | ||
|
|
f95fb95295 | ||
|
|
974a90f28f | ||
|
|
cc8c69aa07 | ||
|
|
352ff617da | ||
|
|
d5dde3eb1c | ||
|
|
4d2fc06fe7 | ||
|
|
f4fe08c73e | ||
|
|
79725a85e9 | ||
|
|
16b10c7855 | ||
|
|
e56b54cf24 | ||
|
|
d596f8c66d | ||
|
|
460ed37a2a | ||
|
|
86231ed23e | ||
|
|
f4e65c827a | ||
|
|
1fbd3b628b | ||
|
|
ad8325f18d | ||
|
|
bcb7716ffa | ||
|
|
b400e8bb4a | ||
|
|
a8898bbee7 | ||
|
|
3f570b6f53 | ||
|
|
5e0994aab9 | ||
|
|
aca5b2d30b | ||
|
|
48f4cba3aa | ||
|
|
af386066e5 | ||
|
|
eddb67975d | ||
|
|
b8a0aead7f | ||
|
|
2e5ec7f49f | ||
|
|
5d3d512e82 | ||
|
|
92af3c213c | ||
|
|
dc24e2250b | ||
|
|
b8859ae424 | ||
|
|
350294670d | ||
|
|
0a20925d35 | ||
|
|
08fd7c208f | ||
|
|
2435c9cefe | ||
|
|
e6c2aa86b1 | ||
|
|
c486096845 | ||
|
|
552f11c21f | ||
|
|
c9a3132893 | ||
|
|
50e4138ed5 | ||
|
|
2e119f19c4 | ||
|
|
73c3eff294 | ||
|
|
238d63f3b9 | ||
|
|
cc670633ff | ||
|
|
68448b2c5f | ||
|
|
c93a947408 | ||
|
|
290ce3441a | ||
|
|
e00d013459 | ||
|
|
d252940381 | ||
|
|
23278318ef | ||
|
|
6c4f27794b | ||
|
|
b7fdfc3a2e | ||
|
|
370a7b3df8 | ||
|
|
12f7441f82 | ||
|
|
9a3c1d181c | ||
|
|
eb087f9fc2 | ||
|
|
52f8c5a57a | ||
|
|
8ec682f007 | ||
|
|
1a4cff11d2 | ||
|
|
09446846b1 | ||
|
|
d2491d5589 | ||
|
|
5ddf00420e | ||
|
|
548e94cc75 | ||
|
|
aafc1a3c1a | ||
|
|
3a2c0e757e | ||
|
|
54afd3e42c | ||
|
|
a104963daf | ||
|
|
a5016c6eed | ||
|
|
a48efb6976 | ||
|
|
d33be187e6 | ||
|
|
09fbac3a33 | ||
|
|
4941cd075b | ||
|
|
bcabd43ca7 | ||
|
|
9d64eb6aa2 | ||
|
|
90bad17cc8 | ||
|
|
651d37b494 | ||
|
|
a3561b4600 | ||
|
|
b1be67bfa7 | ||
|
|
a1ca0dc911 | ||
|
|
7bf8dbd8af | ||
|
|
689cacc039 | ||
|
|
5619783648 | ||
|
|
cd12b3886e | ||
|
|
57ec22073c | ||
|
|
832aad9dd8 | ||
|
|
d8cbe2596f | ||
|
|
1b183f64fe | ||
|
|
7cfe13db8c | ||
|
|
83fa7856c8 | ||
|
|
d2ff7cb769 | ||
|
|
4eeaec74aa | ||
|
|
7611c82ae7 | ||
|
|
97cb63a77f | ||
|
|
ac44b35867 | ||
|
|
14e9edfa7a | ||
|
|
6ebd5e8711 | ||
|
|
f67fcec1c9 | ||
|
|
4b009c3a45 | ||
|
|
542ca91be5 | ||
|
|
3b1314d1b0 | ||
|
|
6b3a5a4182 | ||
|
|
487eb6970c | ||
|
|
1cde353965 | ||
|
|
dd885043c4 | ||
|
|
9e555b7fd2 | ||
|
|
8ee123d06b | ||
|
|
2b3a67d43c | ||
|
|
95baf58be7 | ||
|
|
b49013e6c6 | ||
|
|
2c0b4d60dd | ||
|
|
b7dfd236ee | ||
|
|
d75034a9f4 | ||
|
|
3973af69d4 | ||
|
|
30aad51fac | ||
|
|
4e12aef5fd | ||
|
|
db6465c17d | ||
|
|
a45dde2039 | ||
|
|
15fb5dbbdb | ||
|
|
c23e677b6e | ||
|
|
11aa3dc060 | ||
|
|
cd127010fe | ||
|
|
0cec8b07bd | ||
|
|
cb86b24c0d | ||
|
|
c7400abb48 | ||
|
|
9fb5d7d7d0 |
No files matched your search
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# minutes and can produce nothing actionable, because nobody edits
|
||||
# it and the fix for anything found there is `pnpm prisma generate`.
|
||||
- name: Initialise CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
- public
|
||||
|
||||
- name: Analyse
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
publish_results: true
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: scorecard-results.sarif
|
||||
category: scorecard
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
skip-dirs: src/generated
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-filesystem.sarif
|
||||
category: trivy-filesystem
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
exit-code: "0"
|
||||
|
||||
- name: Upload results
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-published-image.sarif
|
||||
category: trivy-published-image
|
||||
|
||||
+157
@@ -1,5 +1,162 @@
|
||||
# Changelog
|
||||
|
||||
## [1.37.26] — 2026-08-23
|
||||
|
||||
The app is offered in six languages, and in seven places it was reading what you wrote as though there were two.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Side effects you recorded on a GLP-1 medication contributed nothing to the timeline unless you use English or German. Tapping a chip stores the translated word, so the same symptom is `nausea` on one account and `Nudności` on another, and the timeline compared that against a hand-typed list of the English and German labels. The same list sat behind the Coach and behind the doctor report, where a clinician read an empty side-effect table over a record that had entries.
|
||||
- A lab reference range printed as `jusqu'à 5,0` was read as no range at all, so the value was never flagged as normal or out of range. Ranges in all six languages parse now, and one the parser genuinely cannot read is recorded as unreadable instead of quietly looking like a result nobody set a range for.
|
||||
- Lab analytes named in French, Spanish, Italian or Polish got no LOINC code on the FHIR export, and a qualitative result of `négatif` or `ujemny` got no SNOMED code. A lab report in one of those languages also failed the check that decides whether a document is a lab report, so it was never offered for automatic staging.
|
||||
- Analyte names written with German umlauts never matched either, which is the older half of the same defect and affected the language most people here use. The key normaliser deleted any character outside a to z, so `Hämoglobin` became `hmoglobin` and never met the alias written as `hamoglobin`. It only ever worked for people who left the umlaut off. `Nüchternglukose` and `Harnsäure` were the same.
|
||||
- Over the assistant connection, `Fer`, `Żelazo`, `poids` and `Sommeil` resolved to nothing, and asking about `Puls` answered with pulse-wave velocity, because a fallback pass matched English display names by substring. Medication names written as `Metformina` or `Lewotyroksyna` missed the class that connects a dose to a symptom.
|
||||
|
||||
### Changed
|
||||
|
||||
- A route that has been removed now answers `410 Gone` with a machine-readable code, the version that removed it, and its replacement where there is one, rather than the bare `404` it used to. A `404` is indistinguishable from a broken deployment, which is why a client that met one kept retrying a route that was never coming back. The same list is published in the API document and on the capability endpoint, so a client can learn about a retirement without comparing versions.
|
||||
- Removing a route now has two legal answers rather than one, and a test enforces the choice: drop it from the published contract, or register it as retired. Doing neither, which is how a client finds out by rendering an error, no longer passes.
|
||||
|
||||
## [1.37.25] — 2026-08-22
|
||||
|
||||
Two things this release is about. The Coach could state a figure your record does not support, in exactly the situation where it was most likely to: when you asked about something you have never recorded. And a backup you cannot fully restore is not a backup, so the register of what a restore left behind went from twenty-seven tables to two.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Coach checks its own numbers against a record of what it looked up, and rewrites any figure that record cannot account for. That check did not run when every lookup came back empty. Ask about a metric you have never tracked, and an invented number was passed straight through, with no note that anything went unchecked, so the answer read exactly like a verified one. The check now runs on a turn where the lookups found nothing, and because removing only the digits would leave the sentence still claiming a trend that does not exist, such an answer is replaced with a plain statement that nothing is recorded.
|
||||
- The Coach could also narrate a weight history that no longer exists. Syncing a body-composition provider replaces your rows, but the monthly summaries folded from the old ones stayed behind, so the same block could show a year at 95 kg beside an all-time range that peaked at 80. A summary is an average of real readings and cannot fall outside their own extremes; when it does, the history band is dropped and the Coach is told it was withheld rather than shown a number nobody can account for.
|
||||
- The Insights page promised "based on your last 90 days" whether or not there was a briefing behind it, and the pulse page promised a personalised Karvonen target band while computing population percentiles by age and sex, or a flat 60 to 100 when your age is unknown. Both now say what they do, in every language. The Coach's "all time" window was 365 days, the same as the option beside it called "year so far"; both labels tell the truth now.
|
||||
- An account that had grouped its own mood factors could not be restored at all. Not partially: the restore hit a foreign key on a custom category the file never carried, rolled the whole transaction back, and answered with a constraint name and an empty account. The categories a person creates travel now, and they are written before the tags that point at them.
|
||||
- Signing in from the iOS app failed behind a reverse proxy that does not pass the host through. The redirect was built from the address the server process bound to, so it came back as `https://0.0.0.0:3000/...`, which iOS refuses outright. Redirects to this same deployment no longer name a host at all. Seven places had the same bug; all seven are fixed, and a test fails if the pattern comes back.
|
||||
- The Prisma CLI in the published image still resolved the vulnerable deepmerge-ts. The application tree had been pinned already, but that install is a separate one with its own resolution, so the image carried the advisory while every dependency check read clean. A test holds the two pins in step now.
|
||||
- The demo instance resets its published credentials on every migration run, so the account named on the site keeps working.
|
||||
- Turning the API off used to lock people out of their own tokens. The operator switch was checked on the read as well as the revoke, so a user could neither see nor clean up tokens that were still live. It stops tokens from working; it no longer stops you tidying them.
|
||||
- A preference update read its request body with no size limit at all, where its two neighbours cap at one kilobyte. Four more routes threw away the detail of why they refused a value, so a name that was empty and a name that was too long came back as the same sentence.
|
||||
- Someone managing a shared record could write a titration step onto a medication and then be refused that medication's history. The read is fenced at the same level as its siblings now.
|
||||
- Disconnecting a Withings account left the connection state reading "connected" until something else refreshed it, because the teardown neither wrote its audit row nor parked the ledger the way every sibling does. Two more teardowns wrote no audit trail at all when there was nothing connected.
|
||||
- Two sync routes had no rate limit where their siblings carry two each, and every sync route read its request body inside a swallowing catch, so a typo in the body reported a successful run. A body that is absent still means an incremental sync; a body that is present and wrong is refused now.
|
||||
- The integrations page could not tell "nothing delivered yet" from "the freshness query failed": both arrived as an empty list. The failure says so.
|
||||
- Two list endpoints widened their result on a typo instead of refusing it: an unknown metric type was dropped and the read came back unfiltered, and a nonsense page size reverted to the default.
|
||||
- The medication ingest endpoint counted its rate limit per client address, so several bridges behind one router starved each other. It counts per token now.
|
||||
|
||||
### Added
|
||||
|
||||
The restore now returns your conversation history with the Coach: every thread, every turn in it, the record of which documents a thread was grounded in, and the facts, plans and reminders it carries between threads. Until now a restored account had a Coach that had never spoken to it and had to be told its own history again.
|
||||
|
||||
It also returns the medication history: pause periods, the titration steps behind today's dose, the packs on the shelf, and the ledger of refills and consumption behind their counts. The remaining count comes back exactly as recorded rather than recalculated from the ledger, because that number is what the low-stock reminders were computed from. Per-drug reminder phase timings travel too, so a restored drug turns orange when it did before instead of falling back to the defaults. Archived schedule eras come back with their succession chain intact, and what a medication was meant to move comes back with the analyte it pointed at.
|
||||
|
||||
The rest, in short: ECG strips with their waveform, their classification and the reading they were filed against, where a restore into a populated account used to leave the old strips behind. Which conditions a document was filed under, and the facts extracted from it, including which of those were reviewed and committed to the record. The mood taxonomy an account shaped for itself: the groupings it created and the seeded factors it chose to hide. Personal bests, badges, and the per-day environment readings with the location periods that explain them.
|
||||
|
||||
Screener history and the consent record come back in the disaster-recovery export only. Completed WHO-5, PHQ and GAD administrations include the PHQ-9 self-harm item, which is not written into a portable file that can be opened anywhere. The portable export says in its own manifest that they are absent rather than leaving you to notice.
|
||||
|
||||
### Changed
|
||||
|
||||
The published API document listed 262 paths. The application answers on 410.
|
||||
|
||||
The drift check compares the registry against the committed document and fails when they disagree. It has never compared the routes on disk against the registry, so a route that was never registered produced no drift and no failure: the document stayed internally consistent while going quietly incomplete. Forty-three of the missing paths are ones the iOS app calls every day.
|
||||
|
||||
Every route is now either published or named in a list with a reason for its absence. Seventy-three are deliberately outside, and the reasons say which kind: the admin console cannot be reached by an API client at all, OAuth callbacks are driven by the provider, webhooks authenticate by a shared secret, and three discovery documents have their format fixed by somebody else's specification.
|
||||
|
||||
The document also says how to authenticate, which it never did. Both schemes were defined and neither was referenced, so anyone generating a client from the contract got one that sends no credentials and treats the whole surface as open.
|
||||
|
||||
Two tests keep it that way. One walks the route tree and fails on a verb that is neither published nor excused. The other fails on an operation that names no scheme.
|
||||
|
||||
- The idempotency contract is in the published API document. Thirty-four write operations declare the `Idempotency-Key` header they honour and the `X-Idempotent-Replay` header they answer with, including the detail that a malformed key is ignored rather than refused.
|
||||
- Per-event notification preferences are recorded as deliberately outside the backup rather than as an unpaid debt. Each one addresses a single notification channel by a foreign key, the channel itself is excluded for carrying secrets, and the restore deletes every channel it finds, so a carried preference would point at a row that cannot exist.
|
||||
- Dependency updates: pg-boss, Playwright, Testcontainers, Prettier, and two workflow actions.
|
||||
|
||||
## [1.37.24] — 2026-08-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- The hero card on the start page no longer says last night's sleep is still missing when no sleep reaches the record at all. It used to check only whether last night had arrived, so anyone without a tracker read the note every morning about a delay that was never going to end. The note now needs a night within the last week: a flat battery or a few nights without the device keeps it, a week of silence retires it, and someone who has never recorded sleep never sees it.
|
||||
- The same rule settles the day itself. Records with no sleep source used to sit on "provisional" indefinitely, which the daily push line reads too, so the notification carried the same untrue aside.
|
||||
|
||||
## [1.37.23] — 2026-08-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Postponing a checkup is a button on the card now, beside "erledigt" or "jetzt messen", instead of a row inside the overflow menu. That is the shape the medication card has always had, where "genommen" and "übersprungen" sit next to each other, and it puts the action one tap away rather than three. The compact list view carries the same action as an icon button. Edit, measurements and delete stay in the menu.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The quick options in the postpone sheet ("+7 Tage" and its siblings) fill in the day they name even when a daylight-saving change falls inside the span. They used to add a fixed number of hours, which shifts the clock past midnight in the week a country moves its offset, so the option could write the day before or after the one on its label.
|
||||
|
||||
## [1.37.22] — 2026-08-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- The doctor-report export refuses a selection that includes nothing instead of rendering an empty document. The selection panel already disabled its own button in that state, so this only ever reached the server through a hand-built request, and it spent an export slot on a file with no content in it.
|
||||
- A lab value in the FHIR export no longer names UCUM as its unit system when it carries no UCUM code. That combination told a receiving system the unit was coded when it was not, which is worse than saying nothing: the unit still travels as a display string, and the coded form appears only when the analyte and unit actually resolve to one.
|
||||
|
||||
## [1.37.21] — 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- A dose that reaches the server through the batch route, the path a phone's offline queue drains through, now quiets an active snooze the same way a directly logged dose always has. Before, a take synced in later kept counting as snoozed and the reminder kept ringing.
|
||||
- Marking a dose through the single-medication intake route now also closes the still-pending dose-due reminder in the web app and refreshes its badge. The other intake paths have done this since v1.18.4; this one, which is exactly the route a phone's offline replay uses, left the web reminder standing.
|
||||
|
||||
## [1.37.20] — 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- A checkup reminder can now be postponed or skipped. Postponing moves the next due date to a day you pick; skipping closes the current cycle and restarts the interval from today, without pretending the checkup happened. Every completion and every skip lands in a new history, kept per reminder, including whether it happened on time, so the checkups page and the API can show how a reminder has actually been handled over the years.
|
||||
- The dashboard hero can show the reminder rail instead of the health score. The choice lives in the dashboard settings and does nothing until you change it.
|
||||
- The restore dialog shows what a backup contains before you confirm: per-type counts read from the stored file, with an honest note when the preview cannot be computed.
|
||||
- A mental-health screening shows its per-item answers on the detail view, so you can see which questions carried the score, not only the total.
|
||||
- Document search understands German and English medical synonyms: searching for "Blutdruck" finds a report that says "blood pressure", and the other way round.
|
||||
- A deleted custom-metric entry can be brought back with one tap for a short while instead of being gone the moment you tap.
|
||||
|
||||
### Changed
|
||||
|
||||
- Onboarding says plainly that Apple Health data arrives through the iPhone app, and the Apple Health card reports the backfill progress the server actually sees: accepted counts, the oldest reading, and how recently data last arrived.
|
||||
- The measurements and mood pages keep their filters in the address bar, so a filtered view survives a reload and can be shared as a link, the way documents already worked.
|
||||
- The sleep-stage tooltip labels one night's figures as main sleep, naps and total sleep instead of an averaged label that fit none of them.
|
||||
- The daily digest is available offline like the rest of the app shell.
|
||||
- Backups carry the checkup reminders and their new completion history end to end, and an appointment or vaccination that references a reminder keeps that link across a restore.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Apple Health ECG exports in the single-column layout import correctly. Apple writes two CSV shapes; the importer understood only the paired one and skipped every recording in the other, reporting each row as malformed. Both layouts parse now, and the declared unit is converted before validation.
|
||||
|
||||
## [1.37.19] — 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Medication stock understands per-slot doses everywhere now. The remaining-doses figure weighs each scheduled slot by its own dose, the API carries the resolved dose per slot plus a runway in days, and the low-stock notification runs on the same arithmetic, so the app, the API and the push can no longer disagree about how long a supply lasts.
|
||||
- A document whose summary job died no longer says "being generated" forever. After an hour the detail view says honestly that no summary is available and offers the manual action, and an hourly sweep repairs stuck documents in the background.
|
||||
- The daily coach reminder sweep counts how often it has surfaced an ignored reminder and quietly dismisses it after three mornings instead of nagging indefinitely.
|
||||
- Accepting an updated disclaimer is now tracked by version, so a future revision can actually ask again.
|
||||
- The restore report lists practitioners, appointments, vaccinations and their links, and states what was written, not only what was cleared. The portable export names any field that could not be decrypted instead of exporting it as if it were never set.
|
||||
- Changing your timezone re-folds the medication compliance statistics so the day boundaries match your new zone.
|
||||
- Long health histories are honest again in two places: the all-time summary includes readings older than five years instead of silently truncating, and the AI context falls back to live data when a pre-computed band is missing.
|
||||
- A cleaner copy voice across all six languages: shorter consent buttons, a source-neutral workouts empty state, consistent toast punctuation, "Navigations-Chips" instead of "Pills" in German, and a medication reminder chip that no longer clips mid-sentence.
|
||||
- Settings pages that had been permanently redirected are no longer built, the retired placeholder is gone, and the about page is reachable from the privacy page and the settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A measurement reminder claims its notification slot before sending, so a crash mid-send can no longer deliver the same reminder twice.
|
||||
- A simultaneous double-submit of the same mood entry reports "duplicate" with the winning entry instead of a confusing error.
|
||||
- The intake-event API description now documents every field the server actually returns, and the workout list description matches the real nullability, so generated clients decode without surprises.
|
||||
|
||||
## [1.37.18] — 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Coach reminders tied to a moment ("remind me when I next log blood pressure", "next time I open the app") now actually fire. The app has offered these since v1.22 and confirmed them, but nothing ever evaluated them; a matching measurement or the next app open now surfaces the reminder, and the daily sweep backs it up.
|
||||
- The Apple Health import tells you what it skipped and why. Every dropped sample class is now listed on the import card: unknown types, out-of-range values, deferred records, and cycle samples that were read while cycle tracking is switched off, which previously vanished without a trace. A cycle write that fails halfway also reports the failure instead of claiming zero samples.
|
||||
- Deleting a measurement now reaches the pre-computed aggregates. Before, a deleted reading stayed inside day, week, month and year statistics forever; the freshness probe can now see tombstones, and the WHOOP deletion webhook recomputes the affected days.
|
||||
- The sleep history repair job only deletes what it can restore. It used to clear a provider's entire sleep history and re-import just 30 days; the delete is now bounded to the same window.
|
||||
- The connector token settings said tokens are read-only while offering a write toggle on the same card. The copy now tells the truth in all six languages.
|
||||
- A lab report cannot smuggle instructions into the AI layer through a biomarker name anymore; document-derived names are cleaned before they reach any prompt, and the assistant tools fence checkup labels and lab analyte names the same way.
|
||||
- Google Health and Withings syncs now refresh the server-side caches like every other integration, so analytics pick up synced data on the next read instead of waiting out the cache lifetime.
|
||||
- The vaccination booster and suggestion endpoints are published in the API description, as the contract notes already claimed.
|
||||
- On phones, the Medications tab no longer appears in the bottom bar when the medications module is switched off; tapping it used to open an empty page.
|
||||
- The import guide links point at the documentation site instead of a page that does not exist.
|
||||
- A caregiver settings toggle that has had no effect since its feature was retired is gone.
|
||||
- Every create and update request from the app now carries an idempotency key, so a network retry cannot write a vaccination or an appointment twice.
|
||||
- The service worker's offline fallback version is anchored to the release version again; it had silently stayed behind for two releases.
|
||||
|
||||
## [1.37.17] — 2026-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
+18
@@ -157,9 +157,27 @@ COPY --from=builder /app/src/generated ./src/generated
|
||||
# the maintenance scripts already shipped in the standalone tree. The Prisma
|
||||
# config loads dotenv from /app, so expose the isolated copy there before
|
||||
# stripping npm/Corepack and their caches from the runtime surface.
|
||||
#
|
||||
# This install belongs to npm, not pnpm, so the `overrides` block in
|
||||
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
|
||||
# tree still arrives here at whatever version Prisma asks for. That is not
|
||||
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
|
||||
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
|
||||
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
|
||||
# whose dependency audit over the app tree was green. Two installs, two
|
||||
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
|
||||
# `npm pkg set` writes it into the package.json that `npm init -y` just
|
||||
# created, before anything resolves.
|
||||
#
|
||||
# Measured by rebuilding this exact install both ways: without the field
|
||||
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
|
||||
# still reports 7.8.0. Keep the range in step with the entry in
|
||||
# `pnpm-workspace.yaml` — they are one decision applied to two package
|
||||
# managers, and a fix that lands in only one of them is the defect above.
|
||||
RUN mkdir -p /opt/prisma-cli && \
|
||||
cd /opt/prisma-cli && \
|
||||
npm init -y && \
|
||||
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
|
||||
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \
|
||||
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
|
||||
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
|
||||
|
||||
@@ -99,6 +99,35 @@ Every read tool is annotated read-only / non-destructive / idempotent /
|
||||
closed-world — the read-only guarantee is structural (only read tools are
|
||||
registered for a read session), and the annotation merely advertises it.
|
||||
|
||||
### Naming a metric or a nutrient
|
||||
|
||||
The tools that take a free-text name — `compare_metric`,
|
||||
`get_metric_baseline`, `detect_changepoints`, `get_nutrients` — accept the
|
||||
metric's or nutrient's own display name in **any language HealthLog ships**
|
||||
(de / en / es / fr / it / pl), alongside the internal code and the English
|
||||
aliases. `poids`, `Blutzucker`, `Fer` and `Żelazo` reach the same series as
|
||||
`weight`, `blood_glucose` and `iron`. Case, accents and punctuation are
|
||||
folded, so `magnésium` and `MAGNESIUM` are one word.
|
||||
|
||||
The accepted names are read out of the app's own message bundles rather
|
||||
than listed in the resolver, so a language works as soon as its bundle
|
||||
ships. Two consequences worth relying on:
|
||||
|
||||
- **The vocabulary is still closed.** A word in no bundle is a miss, not a
|
||||
guess: `{ present: false, reason: "unknown_metric" }` or
|
||||
`"unknown_nutrient"`. That reason is distinct from `"no_data"`, which
|
||||
means the name WAS understood and the record honestly holds nothing.
|
||||
Widening the accepted spellings moves words from the first answer to the
|
||||
second; it never turns absence into data.
|
||||
- **An ambiguous word is refused, not resolved.** Where a language uses one
|
||||
phrase for two different measurements — French and Italian call both body
|
||||
fat and fat mass `masse grasse` / `massa grassa`, and French, Italian and
|
||||
Polish use one phrase for fat-free mass and lean body mass — the word
|
||||
resolves to neither. Name those in English, or by their metric key.
|
||||
|
||||
Responses are unchanged: labels and units come back in protocol-level
|
||||
English whatever language the request used.
|
||||
|
||||
### Clinical signals
|
||||
|
||||
The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
|
||||
|
||||
+31339
-5067
File diff suppressed because it is too large.
Load diff
@@ -131,6 +131,34 @@ without touching the file:
|
||||
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
|
||||
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
|
||||
|
||||
#### Bumping a digest pin by hand
|
||||
|
||||
A digest-pinned instance never follows a release on its own — an image
|
||||
promote does not touch the pin, so moving it is part of shipping. The
|
||||
mechanic, end to end:
|
||||
|
||||
1. Resolve the new release's digest from the registry (the manifest-list
|
||||
digest, which covers both architectures):
|
||||
|
||||
```sh
|
||||
docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:v1.37.20 \
|
||||
| grep -m1 Digest
|
||||
```
|
||||
|
||||
2. Set `HEALTHLOG_IMAGE_REF=@sha256:<that digest>` where the compose
|
||||
environment lives — the `.env` next to the compose file, or the
|
||||
deployment platform's env vars for the app service.
|
||||
|
||||
3. Deploy as usual.
|
||||
|
||||
4. Verify `/api/version` on the target — and verify it **three times a
|
||||
few seconds apart**, not once: during the container swap a single
|
||||
probe can be answered by the outgoing container (or a cache) and read
|
||||
as success while the old release keeps running. Three stable answers
|
||||
reporting the shipped `version` + `buildSha` are the signal; a deploy
|
||||
that finishes suspiciously fast (under ~30 s) is a warning that
|
||||
nothing was actually pulled.
|
||||
|
||||
> **A pin is sticky, and a stale pin is silent.** Once set, every later deploy
|
||||
> ships that reference until someone changes it. The deploy still reports
|
||||
> success, the tag still builds, the image still publishes, and the running
|
||||
|
||||
@@ -363,9 +363,49 @@ test.describe("account sharing", () => {
|
||||
// Let the offline persister flush the delegate's own dashboard to
|
||||
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
|
||||
// whole assertion is aimed at — without it the check would pass against a
|
||||
// disk that was empty anyway.
|
||||
// disk that was empty anyway. Poll the persister's own store for the
|
||||
// written snapshot instead of sleeping past the debounce (C3): the
|
||||
// condition IS the flush, so a slow CI box waits exactly as long as it
|
||||
// needs to and a fast one moves on immediately.
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
// Gate on rendered content first (the dashboard must actually mount and
|
||||
// populate the query cache before there is anything to persist).
|
||||
await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const req = indexedDB.open("healthlog-query-cache", 1);
|
||||
req.onerror = () => resolve(false);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
try {
|
||||
const get = db
|
||||
.transaction("kv", "readonly")
|
||||
.objectStore("kv")
|
||||
.get("react-query");
|
||||
get.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(get.result !== undefined);
|
||||
};
|
||||
get.onerror = () => {
|
||||
db.close();
|
||||
resolve(false);
|
||||
};
|
||||
} catch {
|
||||
db.close();
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
}),
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await openSwitcher(page);
|
||||
await page.locator('[data-slot="account-switcher-entry"]').click();
|
||||
|
||||
+10
-1
@@ -392,7 +392,9 @@ test.describe("document vault", () => {
|
||||
await sheet.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
// Undo toast → restore → the card returns.
|
||||
await expect(page.getByText("Document deleted.")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Document deleted", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(openButton(page, `${title}.pdf`)).not.toBeVisible();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
|
||||
@@ -559,6 +561,11 @@ test.describe("document vault", () => {
|
||||
// to the dialog after Chromium's PDF viewer grabs it, so Escape works
|
||||
// regardless of when the user presses it.
|
||||
await expect(dialog.locator("iframe")).toBeVisible();
|
||||
// True grace (C3): Chromium's PDF viewer steals focus from inside the
|
||||
// iframe and the sheet hands it back — both moves are invisible to the
|
||||
// outer document (activeElement is "the iframe" before AND after the
|
||||
// grab), so there is no condition to poll on. The window only needs to
|
||||
// outlive the focus dance.
|
||||
await page.waitForTimeout(250);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
@@ -573,6 +580,8 @@ test.describe("document vault", () => {
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
await button.dispatchEvent("touchstart");
|
||||
// Not a wait-for-condition sleep (C3): this IS the gesture — the finger
|
||||
// stays down past the long-press threshold before lifting.
|
||||
await page.waitForTimeout(700);
|
||||
await button.dispatchEvent("touchend");
|
||||
|
||||
|
||||
@@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
metricFreshness: [],
|
||||
// #778 — nothing accepted yet, so the card must show no
|
||||
// progress section for this pending-first-sync account.
|
||||
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
|
||||
@@ -153,6 +153,35 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// A night from earlier in the week, so this is a record that expects
|
||||
// sleep at all. The digest waits for last night only when sleep actually
|
||||
// reaches the account (a night inside the last seven days); without one
|
||||
// the purge above leaves a record with no sleep source, which reads
|
||||
// `final` by design and puts the provisional assertion below out of
|
||||
// reach. Written through the same ingest route as the fresh night, and
|
||||
// swept by the same `e2e-just-in-` purge on the next run.
|
||||
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
|
||||
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
|
||||
const priorBatch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
value: 420,
|
||||
unit: "min",
|
||||
startDate: priorNight.toISOString(),
|
||||
endDate: priorWoke.toISOString(),
|
||||
externalId: `e2e-just-in-prior-${priorNight.getTime()}`,
|
||||
sleepStage: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
priorBatch.ok(),
|
||||
`prior-night sleep batch must be accepted (got ${priorBatch.status()})`,
|
||||
).toBe(true);
|
||||
|
||||
// The account needs a reason to paint a hero that is not the arrival
|
||||
// itself. A connection needing a reconnect is that reason: the digest
|
||||
// reads `integration_statuses` fresh on every request rather than
|
||||
|
||||
@@ -116,7 +116,6 @@ function summary(partial: Partial<DataSummary>): DataSummary {
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
|
||||
@@ -326,11 +326,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
const accountId = await entry.getAttribute("data-account-id");
|
||||
expect(accountId).not.toBeNull();
|
||||
|
||||
let switchCommitted = false;
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
switchCommitted = true;
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
// Poll for the server round-trip having landed (the observable half of
|
||||
// the setup), THEN hold a short fixed window — this one is a true
|
||||
// absence-assertion grace (C3): "the peer never painted guessed state"
|
||||
// has no completion signal to poll on, only time in which the wrong
|
||||
// paint could have happened.
|
||||
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
|
||||
await initiator.waitForTimeout(500);
|
||||
|
||||
// The peer must never paint owner-ready state it guessed at …
|
||||
@@ -386,13 +394,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
// Hold the response, close the tab while it is in flight. The server has
|
||||
// committed; nothing will ever broadcast the commit.
|
||||
const held = deferred();
|
||||
// The commit is observable: flag once the server round-trip inside the
|
||||
// route handler finishes, and poll for it instead of a fixed sleep
|
||||
// (C3) — the tab must close AFTER the write landed, and this waits
|
||||
// exactly that long.
|
||||
let switchCommitted = false;
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
await route.fetch();
|
||||
switchCommitted = true;
|
||||
await held.promise;
|
||||
await route.abort();
|
||||
});
|
||||
await entry.click({ noWaitAfter: true });
|
||||
await initiator.waitForTimeout(300);
|
||||
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
|
||||
held.release();
|
||||
await initiator.close();
|
||||
|
||||
@@ -591,10 +605,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
expect(held.scope).toBe(accountId);
|
||||
|
||||
// Now move the context out from under it, and only then let it land.
|
||||
// "Out from under it" is observable: the exit posts
|
||||
// `/api/account/switch`, so poll for that request having been issued
|
||||
// instead of sleeping and hoping the click got that far (C3).
|
||||
let exitSwitchIssued = false;
|
||||
await page.route("**/api/account/switch", async (route) => {
|
||||
exitSwitchIssued = true;
|
||||
await route.continue();
|
||||
});
|
||||
const leaving = page
|
||||
.locator('[data-slot="shared-record-banner-exit"]')
|
||||
.click({ noWaitAfter: true });
|
||||
await page.waitForTimeout(500);
|
||||
await expect.poll(() => exitSwitchIssued, { timeout: 20_000 }).toBe(true);
|
||||
held.release?.();
|
||||
await leaving.catch(() => {});
|
||||
|
||||
|
||||
@@ -291,15 +291,32 @@ test.describe.serial("scoped sharing browser journeys", () => {
|
||||
|
||||
let corruptAuthPayload = true;
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const response = await route.fetch();
|
||||
if (!corruptAuthPayload) {
|
||||
await route.fulfill({ response });
|
||||
// The switch this test drives ends in a full reload, and the reload
|
||||
// cancels whatever `/api/auth/me` call is in flight. Playwright then
|
||||
// considers the route handled, and the `fulfill` below lands on it with
|
||||
// "Route is already handled!" — a failure of the teardown, not of the
|
||||
// thing under test, and one that turns the whole shard red under
|
||||
// `failOnFlakyTests`.
|
||||
//
|
||||
// Same guard as `v137-record-session-fence.spec.ts` and the a11y sibling
|
||||
// of this file. The fetch is guarded too: a request cancelled mid-flight
|
||||
// rejects there rather than at the fulfill.
|
||||
let response;
|
||||
try {
|
||||
response = await route.fetch();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: withDivergentActiveAccountAccess(await response.json()),
|
||||
});
|
||||
if (!corruptAuthPayload) {
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
await route
|
||||
.fulfill({
|
||||
response,
|
||||
json: withDivergentActiveAccountAccess(await response.json()),
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const ownerOnlyReads: string[] = [];
|
||||
|
||||
+259
-218
File diff suppressed because it is too large.
Load diff
+140
-99
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Today highlights",
|
||||
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
|
||||
"heroItemsNotificationNote": "Notification settings are managed separately.",
|
||||
"heroContentTitle": "Top card shows",
|
||||
"heroContentScore": "Health score and daily summary",
|
||||
"heroContentReminders": "Today highlights",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Coach check-ins",
|
||||
"dose_window": "Medication doses",
|
||||
@@ -560,7 +563,7 @@
|
||||
"viewAll": "View all",
|
||||
"empty": {
|
||||
"title": "No workouts yet",
|
||||
"cta": "Open Apple Health on your iPhone to sync workouts here."
|
||||
"cta": "Connect a data source in Settings to see workouts here."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
@@ -729,9 +732,12 @@
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} measurements deleted",
|
||||
"bulkDeleteSuccessOne": "Measurement deleted",
|
||||
"bulkDeleteError": "Error deleting the selected measurements",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
|
||||
"bulkDeleteConfirmTitleOne": "Delete this measurement?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.",
|
||||
"bulkDeleteConfirmBodyOne": "The selected measurement will be deleted. You can undo this for a short moment afterwards.",
|
||||
"typePhq9Score": "PHQ-9 score",
|
||||
"typeGad7Score": "GAD-7 score",
|
||||
"typeGripStrength": "Grip strength",
|
||||
@@ -879,9 +885,12 @@
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entries deleted",
|
||||
"bulkDeleteSuccessOne": "Entry deleted",
|
||||
"bulkDeleteError": "Error deleting the selected entries",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} entries?",
|
||||
"bulkDeleteConfirmTitleOne": "Delete this entry?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.",
|
||||
"bulkDeleteConfirmBodyOne": "The selected mood entry will be deleted. You can undo this for a short moment afterwards.",
|
||||
"customize": "Customize mood tags",
|
||||
"addTagInline": "Add tag",
|
||||
"manage": {
|
||||
@@ -1779,7 +1788,8 @@
|
||||
"enabledToast": "Reminders on",
|
||||
"disabledToast": "Reminders off",
|
||||
"toggleFailed": "Could not change the reminder setting.",
|
||||
"clientManagedChip": "Your iPhone manages reminders for this medication."
|
||||
"clientManagedChip": "Managed by iPhone",
|
||||
"clientManagedHint": "Your iPhone manages reminders for this medication."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -1932,7 +1942,7 @@
|
||||
"addQuantityHelper": "1–1000 units per pack or container.",
|
||||
"addExpiryLabel": "Expiry date (optional)",
|
||||
"addSubmit": "Register",
|
||||
"addSuccess": "Supply registered.",
|
||||
"addSuccess": "Supply registered",
|
||||
"addFailed": "The supply could not be registered.",
|
||||
"adjustButton": "Adjust",
|
||||
"adjustTitle": "Correct container",
|
||||
@@ -1940,11 +1950,11 @@
|
||||
"adjustQuantityLabel": "Remaining units",
|
||||
"adjustHelper": "Capacity: {total} units.",
|
||||
"adjustSubmit": "Save",
|
||||
"adjustSuccess": "Stock updated.",
|
||||
"adjustSuccess": "Stock updated",
|
||||
"adjustFailed": "The stock could not be updated.",
|
||||
"deleteTitle": "Delete container?",
|
||||
"deleteDescription": "Permanently removes this pack or container from the stock. Logged intakes stay unchanged.",
|
||||
"deleteSuccess": "Stock entry deleted.",
|
||||
"deleteSuccess": "Stock entry deleted",
|
||||
"deleteFailed": "The stock entry could not be deleted.",
|
||||
"containerTypeLabel": "Container type",
|
||||
"containerType": {
|
||||
@@ -2139,7 +2149,7 @@
|
||||
"scaleToggleLabel": "Chart scale"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} intakes imported.",
|
||||
"resultSuccess": "{imported} intakes imported",
|
||||
"resultPartial": "{imported} imported, {skipped} skipped.",
|
||||
"resultNothing": "Nothing was imported: all {skipped} entries were skipped.",
|
||||
"resultAlreadyRecorded": "{recorded} intakes are already recorded. Nothing new was added.",
|
||||
@@ -2302,7 +2312,6 @@
|
||||
}
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Compare to",
|
||||
"baseline": {
|
||||
"none": "None",
|
||||
"lastMonth": "Last month",
|
||||
@@ -2402,7 +2411,7 @@
|
||||
"dragHandle": "Drag to reorder",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"manageInSettings": "Manage pills & detail pages in Settings",
|
||||
"manageInSettings": "Manage chips & detail pages in Settings",
|
||||
"groupSleep": "Sleep",
|
||||
"groupMood": "Mood",
|
||||
"groupEvents": "Events",
|
||||
@@ -2423,23 +2432,6 @@
|
||||
"badge": "PR",
|
||||
"tooltip": "Personal record: {value} {unit} on {date}"
|
||||
},
|
||||
"recommendation": {
|
||||
"source": "Source",
|
||||
"viewSource": "Open guideline in a new tab",
|
||||
"rationaleWindow": "Window",
|
||||
"rationaleComparedTo": "Compared to",
|
||||
"rationaleDeviation": "Deviation",
|
||||
"rationaleExpand": "Show why",
|
||||
"rationaleCollapse": "Hide why",
|
||||
"feedbackHelpful": "Helpful",
|
||||
"feedbackNotHelpful": "Not helpful",
|
||||
"feedbackThanks": "Thanks for your feedback",
|
||||
"feedbackAlreadyRated": "Already rated",
|
||||
"feedbackError": "Could not save your feedback. Please try again.",
|
||||
"confidenceAria": "Confidence: {value} of 100",
|
||||
"confidenceLow": "Low confidence — based on limited data",
|
||||
"confidenceDraft": "Draft"
|
||||
},
|
||||
"loadError": "Failed to load insights",
|
||||
"heroPersonalBaseline": "Based on your last 90 days",
|
||||
"heroRegenerating": "Regenerating",
|
||||
@@ -2545,11 +2537,6 @@
|
||||
"areas": "{present} of {required} areas of health"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"tellMyDoctor": "What should I tell my doctor?",
|
||||
"medicationWorking": "Is my medication working?"
|
||||
},
|
||||
"dailyBriefing": {
|
||||
"title": "Daily Briefing",
|
||||
"keyFindingsTitle": "Key findings",
|
||||
@@ -2761,8 +2748,8 @@
|
||||
"last7days": "last 7 days",
|
||||
"last30days": "last 30 days",
|
||||
"last90days": "last 90 days",
|
||||
"lastYear": "year so far",
|
||||
"allTime": "all time"
|
||||
"lastYear": "last 12 months",
|
||||
"allTime": "all time (12 months of detail)"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
"settingsAriaLabel": "Coach settings",
|
||||
@@ -2776,6 +2763,7 @@
|
||||
"settingsVerbosityBrief": "Brief",
|
||||
"settingsVerbosityDefault": "Default",
|
||||
"settingsVerbosityDetailed": "Detailed",
|
||||
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
|
||||
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
|
||||
"settingsSourcesPointerLink": "Open the coach",
|
||||
"settingsExcludeLabel": "Exclude metrics",
|
||||
@@ -2787,7 +2775,7 @@
|
||||
"settingsDefaultWindowLabel": "Default analysis window",
|
||||
"settingsDefaultWindowHint": "The Coach reads this much history per chat unless you override it from the drawer header.",
|
||||
"settingsSave": "Save",
|
||||
"settingsSaved": "Saved.",
|
||||
"settingsSaved": "Saved",
|
||||
"settingsCancel": "Cancel",
|
||||
"feedbackHelpful": "Helpful",
|
||||
"feedbackUnhelpful": "Not quite",
|
||||
@@ -3059,7 +3047,7 @@
|
||||
"loadError": "Could not load this metric right now.",
|
||||
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.",
|
||||
"gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.",
|
||||
"pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.",
|
||||
"pulsDescription": "Resting pulse against an age- and sex-based reference band from CDC/NCHS population percentiles, plus a written assessment.",
|
||||
"stimmungDescription": "Daily mood scores over time with a written assessment.",
|
||||
"medikamenteDescription": "Per-medication adherence calendars and a written assessment.",
|
||||
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
|
||||
@@ -3160,7 +3148,8 @@
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"mainSleep": "Main sleep",
|
||||
"totalSleep": "Total sleep",
|
||||
"compositionTitle": "Stage composition",
|
||||
"compositionSubtitle": "Last {nights} nights",
|
||||
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
|
||||
@@ -4159,13 +4148,13 @@
|
||||
"overviewDescription": "Show or hide sections and set their order on the Insights overview."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Sort the navigation pills",
|
||||
"description": "Drag the pills into the order you want and show or hide them via the eye icon.",
|
||||
"title": "Sort the navigation chips",
|
||||
"description": "Drag the chips into the order you want and show or hide them via the eye icon.",
|
||||
"dragHandle": "Reorder",
|
||||
"saveSuccess": "Pill order saved.",
|
||||
"saveError": "Saving the pill order failed.",
|
||||
"saveSuccess": "Chip order saved",
|
||||
"saveError": "Saving the chip order failed.",
|
||||
"save": "Save",
|
||||
"empty": "No navigation pills available yet.",
|
||||
"empty": "No navigation chips available yet.",
|
||||
"detail": "Hidden detail pages are also removed from the top navigation."
|
||||
},
|
||||
"customize": "Customize Insights",
|
||||
@@ -4509,7 +4498,7 @@
|
||||
"title": "Withings",
|
||||
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.",
|
||||
"cta": "Connect Withings",
|
||||
"setupCta": "Set up in Settings",
|
||||
"setupCta": "Set up",
|
||||
"setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time."
|
||||
},
|
||||
"more": {
|
||||
@@ -4520,7 +4509,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup."
|
||||
},
|
||||
"import": "Import existing history"
|
||||
},
|
||||
@@ -4719,7 +4709,6 @@
|
||||
"last30days": "Last 30 days",
|
||||
"last90days": "Last 90 days",
|
||||
"allTime": "All time",
|
||||
"showEvidence": "Show evidence by default",
|
||||
"excludedMetrics": "Excluded metrics",
|
||||
"dataClusters": "Data clusters",
|
||||
"customClusters": "Use a custom cluster selection",
|
||||
@@ -4834,8 +4823,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout and order.",
|
||||
"subtitle": "Arrange the tiles on your dashboard."
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -4856,9 +4843,9 @@
|
||||
"resetDefaultsConfirm": "Reset order",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
"saveSuccess": "Source priority saved",
|
||||
"saveError": "Saving the source priority failed.",
|
||||
"resetSuccess": "Source priority reset to defaults.",
|
||||
"resetSuccess": "Source priority reset to defaults",
|
||||
"perMetricToggle": "Per-metric overrides ({count})",
|
||||
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
|
||||
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
|
||||
@@ -5002,7 +4989,14 @@
|
||||
"unpacking": "Unpacking the archive…",
|
||||
"parsing": "Reading the export…",
|
||||
"upserting": "Importing records…"
|
||||
}
|
||||
},
|
||||
"skipUnsupported": "{count} samples skipped — data types HealthLog doesn't import.",
|
||||
"skipRefused": "{count} samples couldn't be written (unreadable, out of range, or the save failed).",
|
||||
"skipBreakdown": "Show breakdown ({count} types)",
|
||||
"skipBreakdownMore": "+ {count} more types",
|
||||
"cycleSummary": "{samples} cycle samples merged into {days} day logs.",
|
||||
"cycleModuleOff": "{count} cycle samples skipped — cycle tracking is off for this account.",
|
||||
"cycleDaysFailed": "{count} cycle days couldn't be written: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "JSON file",
|
||||
@@ -5020,7 +5014,7 @@
|
||||
"failed": "The import failed. Please try again.",
|
||||
"readFailed": "Couldn't read that file.",
|
||||
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
|
||||
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries.",
|
||||
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries",
|
||||
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
|
||||
"resultEmpty": "That file carried no measurements and no mood entries."
|
||||
},
|
||||
@@ -5044,10 +5038,10 @@
|
||||
"previewSummary": "Preview: {inserted} would import, {skipped} skipped.",
|
||||
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
|
||||
"rowError": "Line {line}: {reason}",
|
||||
"resultSuccess": "Imported {inserted} new and updated {updated}.",
|
||||
"resultSuccess": "Imported {inserted} new and updated {updated}",
|
||||
"resultNothing": "Nothing was imported. All {skipped} rows were skipped.",
|
||||
"resultEmpty": "That file has a header but no data rows.",
|
||||
"previewSuccess": "Preview: {inserted} would import.",
|
||||
"previewSuccess": "Preview: {inserted} would import",
|
||||
"previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.",
|
||||
"skipGroupOne": "{count} row skipped: {reason}",
|
||||
"skipGroupFew": "{count} rows skipped: {reason}",
|
||||
@@ -5138,38 +5132,26 @@
|
||||
"subtitle": "Share read-only access to your data."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"description": "Overview and navigation-pill order.",
|
||||
"subtitle": "Arrange your overview sections and pill order."
|
||||
"subtitle": "Arrange your overview sections and chip order."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
"description": "List view, order, and injection sites for your medications.",
|
||||
"subtitle": "Manage your medication list view and order."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood tags",
|
||||
"description": "Groups, tags, and archived tags for the mood picker.",
|
||||
"subtitle": "Organize your mood tags, groups, and picker order."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Labs",
|
||||
"description": "View, sort order, and biomarkers for your labs.",
|
||||
"subtitle": "Manage the view, sort order, and biomarkers."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Illness journal",
|
||||
"description": "View and order of your illness episodes.",
|
||||
"subtitle": "Manage the view and order of episodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Checkups",
|
||||
"description": "View, order, and individual checkup reminders.",
|
||||
"subtitle": "Manage the view, order, and individual reminders."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"saved": "Saved.",
|
||||
"saved": "Saved",
|
||||
"error": "Couldn't save. Please try again.",
|
||||
"toggleable": {
|
||||
"title": "What you track",
|
||||
@@ -5217,7 +5199,7 @@
|
||||
"use": "Use",
|
||||
"select": "Select",
|
||||
"saveError": "Could not save your change.",
|
||||
"homeSaved": "Home location saved.",
|
||||
"homeSaved": "Home location saved",
|
||||
"travelAdded": "Location period added.",
|
||||
"travelRemoved": "Location period removed.",
|
||||
"backfillQueued": "Backfill queued.",
|
||||
@@ -5274,7 +5256,7 @@
|
||||
"current": "Your saved selection"
|
||||
},
|
||||
"save": "Save",
|
||||
"saved": "Saved.",
|
||||
"saved": "Saved",
|
||||
"error": "Couldn't save. Please try again.",
|
||||
"loadFailed": "Couldn't load your selection.",
|
||||
"eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.",
|
||||
@@ -5312,7 +5294,7 @@
|
||||
"auto": "Automatic (language)",
|
||||
"h24": "24-hour",
|
||||
"h12": "12-hour (AM/PM)",
|
||||
"saved": "Hour format saved.",
|
||||
"saved": "Hour format saved",
|
||||
"saveError": "Saving the hour format failed."
|
||||
},
|
||||
"dateFormat": {
|
||||
@@ -5322,7 +5304,7 @@
|
||||
"dmy": "DD.MM.YYYY",
|
||||
"mdy": "MM/DD/YYYY",
|
||||
"ymd": "YYYY-MM-DD",
|
||||
"saved": "Date format saved.",
|
||||
"saved": "Date format saved",
|
||||
"saveError": "Saving the date format failed."
|
||||
},
|
||||
"username": "Username",
|
||||
@@ -5616,7 +5598,7 @@
|
||||
"clearBody": "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
|
||||
"clearConfirm": "Clear note",
|
||||
"save": "Save",
|
||||
"savedToast": "About-me note saved.",
|
||||
"savedToast": "About-me note saved",
|
||||
"clearedToast": "About-me note cleared.",
|
||||
"saveError": "Could not save. Please try again.",
|
||||
"loadError": "The note could not be loaded.",
|
||||
@@ -5638,14 +5620,14 @@
|
||||
"title": "Read documents automatically with AI",
|
||||
"subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.",
|
||||
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.",
|
||||
"confirm": "I understand — turn on automatic AI reading",
|
||||
"confirm": "I understand — turn on",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Use the server's shared AI access",
|
||||
"subLabel": "Route AI requests through the connection the operator set up for this server, alongside your own providers as a fallback.",
|
||||
"honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.",
|
||||
"confirm": "I understand — use the shared connection",
|
||||
"confirm": "I understand — use connection",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"consent": {
|
||||
@@ -5667,7 +5649,7 @@
|
||||
"description": "Choose how distances and speeds are shown across charts and tiles.",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"saved": "Unit preference saved.",
|
||||
"saved": "Unit preference saved",
|
||||
"saveError": "Could not save your unit preference."
|
||||
}
|
||||
},
|
||||
@@ -5855,7 +5837,7 @@
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"warningServerError": "Sync failing",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Paused — reconnect manually",
|
||||
"parkedReconnect": "Paused — reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
@@ -5939,6 +5921,7 @@
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
@@ -5966,7 +5949,7 @@
|
||||
},
|
||||
"notificationStatus": {
|
||||
"title": "Delivery status",
|
||||
"description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.",
|
||||
"description": "State of each channel — “Auto-disabled” means retries stopped after permanent errors.",
|
||||
"emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.",
|
||||
"stateActive": "Active",
|
||||
"stateAutoDisabled": "Auto-disabled",
|
||||
@@ -6032,7 +6015,7 @@
|
||||
"expiryInvalid": "Expiry must be between 1 and {max} days.",
|
||||
"create": "Create link",
|
||||
"tokenCreated": "Share link created",
|
||||
"copied": "Copied to clipboard.",
|
||||
"copied": "Copied to clipboard",
|
||||
"activeTitle": "Active links",
|
||||
"noActive": "No active share links.",
|
||||
"created": "Created",
|
||||
@@ -6184,7 +6167,7 @@
|
||||
"revokeSession": "Sign out",
|
||||
"revokeError": "Could not complete the request. Please try again.",
|
||||
"signOutEverywhere": "Sign out everywhere else",
|
||||
"signOutEverywhereDone": "Signed out {count} other session(s).",
|
||||
"signOutEverywhereDone": "Signed out {count} other session(s)",
|
||||
"activityTitle": "Security activity",
|
||||
"activityDescription": "Recent sign-ins, security changes, and data exports on your account.",
|
||||
"activityEmpty": "No security activity yet.",
|
||||
@@ -6265,7 +6248,7 @@
|
||||
"revokeError": "Could not revoke the device. Please try again.",
|
||||
"revoke": "Revoke",
|
||||
"revokeAll": "Forget all devices",
|
||||
"revokeAllDone": "Forgot {count} trusted device(s).",
|
||||
"revokeAllDone": "Forgot {count} trusted device(s)",
|
||||
"unnamed": "Unknown device",
|
||||
"current": "This device",
|
||||
"expires": "Trusted until {date}",
|
||||
@@ -6329,8 +6312,8 @@
|
||||
"endpointLabel": "Connector URL",
|
||||
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
|
||||
"tokensTitle": "Connector tokens",
|
||||
"tokensDescription": "Mint a read-only token to connect an assistant.",
|
||||
"scopeNote": "Tokens grant read-only access to your own records by default. Write access is optional and enabled per token with the toggle below. The tokens can never reach admin functions.",
|
||||
"tokensDescription": "Mint a token to connect an assistant — read-only unless you allow logging below.",
|
||||
"scopeNote": "New tokens can only read your own records. Write access (health:write) is granted per token with the toggle below. A token can never reach admin functions.",
|
||||
"writeScopeToggleLabel": "Allow logging (health:write)",
|
||||
"writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.",
|
||||
"connectionsTitle": "Connected assistants",
|
||||
@@ -6386,6 +6369,13 @@
|
||||
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
|
||||
"importAction": "Open one-shot import",
|
||||
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
|
||||
"progress": {
|
||||
"title": "Sync progress",
|
||||
"receivedLabel": "Records received",
|
||||
"oldestLabel": "Oldest reading reached",
|
||||
"flowing": "Data arrived within the last hour — the iPhone app is still delivering.",
|
||||
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Delivery",
|
||||
"lastTriggerLabel": "Latest sync arrived",
|
||||
@@ -6590,13 +6580,13 @@
|
||||
"maxUsesLabel": "Maximum uses",
|
||||
"maxUsesHint": "How many signups this link admits (1–50).",
|
||||
"createConfirm": "Create invite",
|
||||
"createdToast": "Invite created.",
|
||||
"createdToast": "Invite created",
|
||||
"createError": "Could not create the invite.",
|
||||
"tokenTitle": "Invite link",
|
||||
"tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.",
|
||||
"hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.",
|
||||
"copyLink": "Copy link",
|
||||
"copiedToast": "Link copied.",
|
||||
"copiedToast": "Link copied",
|
||||
"copyError": "Copy failed.",
|
||||
"qrAlt": "QR code for the invite link",
|
||||
"loadError": "Could not load the invites.",
|
||||
@@ -6890,6 +6880,23 @@
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
|
||||
"restorePromptLabel": "Type RESTORE to continue",
|
||||
"previewTitle": "What this backup contains",
|
||||
"previewLoading": "Reading contents…",
|
||||
"previewUnavailable": "Contents could not be read — restoring is still possible.",
|
||||
"previewEmpty": "The file contains no records.",
|
||||
"previewOther": "Other records",
|
||||
"previewMeasurements": "Measurements",
|
||||
"previewMedications": "Medications and intakes",
|
||||
"previewMood": "Mood entries",
|
||||
"previewLabs": "Lab results",
|
||||
"previewDocuments": "Documents",
|
||||
"previewWorkouts": "Workouts",
|
||||
"previewCycles": "Cycle data",
|
||||
"previewVisits": "Doctor visits",
|
||||
"previewVaccinations": "Vaccinations",
|
||||
"previewReminders": "Preventive-care reminders",
|
||||
"previewCustomMetrics": "Custom metrics",
|
||||
"previewHealthScores": "Score history",
|
||||
"restoreConfirm": "Restore now",
|
||||
"restoreInProgress": "Restoring…",
|
||||
"restoreSuccess": "Restore complete",
|
||||
@@ -6905,7 +6912,17 @@
|
||||
"restoreSkippedVisitReference": "Visit reference",
|
||||
"restoreSkippedVaccinationReference": "Vaccination reference",
|
||||
"restoreSkippedCheckupClosure": "Checkup closure",
|
||||
"docsLink": "Documentation"
|
||||
"restoreSkippedReminderReference": "Reminder reference",
|
||||
"restoreSkippedCoachAttachment": "Coach document link",
|
||||
"restoreSkippedCoachReference": "Coach reference",
|
||||
"restoreSkippedDocumentConditionLink": "Document filing",
|
||||
"restoreSkippedExtractedFact": "Document fact",
|
||||
"restoreSkippedFactCommitment": "Fact commitment",
|
||||
"docsLink": "Documentation",
|
||||
"restoreSkippedPersonalRecordReference": "Personal best provenance",
|
||||
"restoreSkippedEcgReference": "ECG strip provenance",
|
||||
"restoreSkippedMedicationTarget": "Medication target analyte",
|
||||
"restoreSkippedScheduleRevisionLink": "Schedule era succession"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger Zone",
|
||||
@@ -7339,7 +7356,7 @@
|
||||
"saveError": "Save failed. Please try again.",
|
||||
"hourLabel": "Reminder time",
|
||||
"hourAria": "Mood reminder hour",
|
||||
"hourSavedToast": "Reminder time updated.",
|
||||
"hourSavedToast": "Reminder time updated",
|
||||
"detail": "Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today."
|
||||
},
|
||||
"coachNudge": {
|
||||
@@ -7380,8 +7397,8 @@
|
||||
"leadLabel": "Reorder lead time (days)",
|
||||
"leadAria": "Reorder lead time in days",
|
||||
"leadHelp": "The alert fires this many days plus one dose-interval earlier so a refill arrives before your last dose. A single medication can override this on its supply tab.",
|
||||
"leadSavedToast": "Reorder lead time saved.",
|
||||
"savedToast": "Low-stock threshold saved.",
|
||||
"leadSavedToast": "Reorder lead time saved",
|
||||
"savedToast": "Low-stock threshold saved",
|
||||
"enabledToast": "Low-stock alerts enabled.",
|
||||
"disabledToast": "Low-stock alerts disabled.",
|
||||
"saveError": "Could not save. Please try again.",
|
||||
@@ -8041,10 +8058,10 @@
|
||||
"protected": "Whether contraception was used during intercourse.",
|
||||
"protectedLabel": "What does protected mean?"
|
||||
},
|
||||
"saveSuccess": "Entry saved.",
|
||||
"deleteSuccess": "Entry deleted.",
|
||||
"periodStartSaved": "Period start saved.",
|
||||
"periodEndSaved": "End of period saved.",
|
||||
"saveSuccess": "Entry saved",
|
||||
"deleteSuccess": "Entry deleted",
|
||||
"periodStartSaved": "Period start saved",
|
||||
"periodEndSaved": "End of period saved",
|
||||
"startedPeriodOnDate": "My period started this day",
|
||||
"endedPeriodOnDate": "My period ended this day"
|
||||
},
|
||||
@@ -8084,6 +8101,23 @@
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete reminder?",
|
||||
"deleteConfirmDescription": "This checkup reminder will be permanently deleted.",
|
||||
"postpone": {
|
||||
"menuItem": "Postpone…",
|
||||
"title": "Postpone due date",
|
||||
"description": "Only moves the current due date — the regular rhythm stays unchanged.",
|
||||
"chip": "+{days} days",
|
||||
"dateLabel": "New date",
|
||||
"confirm": "Postpone"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Skip this due date",
|
||||
"explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.",
|
||||
"action": "Skip",
|
||||
"confirmTitle": "Skip this due date?"
|
||||
},
|
||||
"snoozedUntil": "Postponed until {date}",
|
||||
"skippedOn": "Skipped on {date}",
|
||||
"skipCount": "skipped {count}×",
|
||||
"disabledBadge": "Off",
|
||||
"nextDue": {
|
||||
"none": "Not scheduled",
|
||||
@@ -8933,7 +8967,8 @@
|
||||
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
||||
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
@@ -9034,7 +9069,9 @@
|
||||
"flaggedBadge": "Support shown",
|
||||
"chartTitle": "Score trend",
|
||||
"totalLabel": "Score",
|
||||
"bandLabel": "Category"
|
||||
"bandLabel": "Category",
|
||||
"answersToggle": "Answers",
|
||||
"answersUnavailable": "The stored answers for this check-in cannot be decrypted."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "You don't have to face this alone",
|
||||
@@ -9305,7 +9342,7 @@
|
||||
"freeText": {
|
||||
"label": "Free-text note (allergies & intolerances)",
|
||||
"hint": "A free-text supplement to the structured list above, for context a single entry cannot carry.",
|
||||
"savedToast": "Allergy note saved.",
|
||||
"savedToast": "Allergy note saved",
|
||||
"saveError": "Could not save. Please try again."
|
||||
}
|
||||
},
|
||||
@@ -9364,7 +9401,7 @@
|
||||
"conditionsLabel": "Chronic conditions",
|
||||
"focusLabel": "What the Coach watches",
|
||||
"loadError": "Couldn't load your conditions.",
|
||||
"savedToast": "Conditions saved.",
|
||||
"savedToast": "Conditions saved",
|
||||
"saveError": "Could not save. Please try again."
|
||||
},
|
||||
"emergency": {
|
||||
@@ -9468,7 +9505,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Already stored — highlighting the existing document.",
|
||||
"deleted": "Document deleted.",
|
||||
"deleted": "Document deleted",
|
||||
"restoreFailed": "Couldn't restore the document."
|
||||
},
|
||||
"timeline": {
|
||||
@@ -9641,9 +9678,9 @@
|
||||
"unverifiedNoteOther": "{count} values still need checking and stay pending.",
|
||||
"finish": "Finish review",
|
||||
"confirmFailed": "The review couldn't be saved. Try again.",
|
||||
"savedToastOne": "1 value saved.",
|
||||
"savedToastFew": "{count} values saved.",
|
||||
"savedToastOther": "{count} values saved.",
|
||||
"savedToastOne": "1 value saved",
|
||||
"savedToastFew": "{count} values saved",
|
||||
"savedToastOther": "{count} values saved",
|
||||
"nothingSavedToast": "Review saved. No values were added.",
|
||||
"extract": "Extract lab values",
|
||||
"extracting": "Extracting…",
|
||||
@@ -9738,6 +9775,8 @@
|
||||
"editedToast": "Value updated",
|
||||
"editError": "Could not update the value",
|
||||
"deletedToast": "Value deleted",
|
||||
"restoredToast": "Value restored",
|
||||
"restoreError": "Restore failed",
|
||||
"deleteError": "Could not delete the value"
|
||||
},
|
||||
"chart": {
|
||||
@@ -10184,7 +10223,7 @@
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Saved to {name}'s record."
|
||||
"savedTo": "Saved to {name}'s record"
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} added a reading",
|
||||
@@ -10207,6 +10246,8 @@
|
||||
"measurementReminderDelete": "{name} removed a measurement reminder",
|
||||
"measurementReminderComplete": "{name} completed a measurement reminder",
|
||||
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
|
||||
"measurementReminderSkip": "{name} skipped a measurement reminder",
|
||||
"measurementReminderSnooze": "{name} postponed a measurement reminder",
|
||||
"labResultUpdate": "{name} changed a lab result",
|
||||
"labResultDelete": "{name} removed a lab result",
|
||||
"labResultRestore": "{name} restored a lab result",
|
||||
|
||||
+135
-94
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Destacados de hoy",
|
||||
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
|
||||
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
|
||||
"heroContentTitle": "La tarjeta superior muestra",
|
||||
"heroContentScore": "Puntuación de salud y resumen del día",
|
||||
"heroContentReminders": "Destacados de hoy",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Revisión con Coach",
|
||||
"dose_window": "Dosis de medicamentos",
|
||||
@@ -560,7 +563,7 @@
|
||||
"viewAll": "Ver todos",
|
||||
"empty": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
|
||||
"cta": "Conecta una fuente de datos en los ajustes para ver aquí los entrenamientos."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
@@ -729,9 +732,12 @@
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} mediciones eliminadas",
|
||||
"bulkDeleteSuccessOne": "Medición eliminada",
|
||||
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
|
||||
"bulkDeleteConfirmTitleOne": "¿Eliminar esta medición?",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"bulkDeleteConfirmBodyOne": "La medición seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"typePhq9Score": "Puntuación PHQ-9",
|
||||
"typeGad7Score": "Puntuación GAD-7",
|
||||
"typeGripStrength": "Fuerza de agarre",
|
||||
@@ -879,9 +885,12 @@
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entradas eliminadas",
|
||||
"bulkDeleteSuccessOne": "Entrada eliminada",
|
||||
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
|
||||
"bulkDeleteConfirmTitleOne": "¿Eliminar esta entrada?",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"bulkDeleteConfirmBodyOne": "La entrada de estado de ánimo seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"customize": "Personalizar etiquetas de ánimo",
|
||||
"addTagInline": "Añadir etiqueta",
|
||||
"manage": {
|
||||
@@ -1779,7 +1788,8 @@
|
||||
"enabledToast": "Recordatorios activados",
|
||||
"disabledToast": "Recordatorios desactivados",
|
||||
"toggleFailed": "No se pudo cambiar el ajuste de recordatorios.",
|
||||
"clientManagedChip": "Tu iPhone gestiona los recordatorios de este medicamento."
|
||||
"clientManagedChip": "Gestionado por el iPhone",
|
||||
"clientManagedHint": "Tu iPhone gestiona los recordatorios de este medicamento."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
@@ -1932,7 +1942,7 @@
|
||||
"addQuantityHelper": "1–1000 unidades por envase o recipiente.",
|
||||
"addExpiryLabel": "Fecha de caducidad (opcional)",
|
||||
"addSubmit": "Registrar",
|
||||
"addSuccess": "Existencias registradas.",
|
||||
"addSuccess": "Existencias registradas",
|
||||
"addFailed": "No se pudieron registrar las existencias.",
|
||||
"adjustButton": "Ajustar",
|
||||
"adjustTitle": "Corregir envase",
|
||||
@@ -1940,11 +1950,11 @@
|
||||
"adjustQuantityLabel": "Unidades restantes",
|
||||
"adjustHelper": "Capacidad: {total} unidades.",
|
||||
"adjustSubmit": "Guardar",
|
||||
"adjustSuccess": "Existencias actualizadas.",
|
||||
"adjustSuccess": "Existencias actualizadas",
|
||||
"adjustFailed": "No se pudieron actualizar las existencias.",
|
||||
"deleteTitle": "¿Eliminar envase?",
|
||||
"deleteDescription": "Elimina permanentemente este envase o recipiente de las existencias. Las tomas registradas no cambian.",
|
||||
"deleteSuccess": "Entrada de existencias eliminada.",
|
||||
"deleteSuccess": "Entrada de existencias eliminada",
|
||||
"deleteFailed": "No se pudo eliminar la entrada de existencias.",
|
||||
"containerTypeLabel": "Tipo de envase",
|
||||
"containerType": {
|
||||
@@ -2139,7 +2149,7 @@
|
||||
"scaleToggleLabel": "Escala del gráfico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} tomas importadas.",
|
||||
"resultSuccess": "{imported} tomas importadas",
|
||||
"resultPartial": "{imported} importadas, {skipped} omitidas.",
|
||||
"resultNothing": "No se importó nada: se omitieron las {skipped} entradas.",
|
||||
"resultAlreadyRecorded": "{recorded} tomas ya están registradas. No se añadió nada nuevo.",
|
||||
@@ -2302,7 +2312,6 @@
|
||||
}
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Comparar con",
|
||||
"baseline": {
|
||||
"none": "Desactivado",
|
||||
"lastMonth": "Mes anterior",
|
||||
@@ -2423,23 +2432,6 @@
|
||||
"badge": "PR",
|
||||
"tooltip": "Récord personal: {value} {unit} el {date}"
|
||||
},
|
||||
"recommendation": {
|
||||
"source": "Fuente",
|
||||
"viewSource": "Abrir guía en una pestaña nueva",
|
||||
"rationaleWindow": "Ventana",
|
||||
"rationaleComparedTo": "Comparado con",
|
||||
"rationaleDeviation": "Desviación",
|
||||
"rationaleExpand": "Mostrar justificación",
|
||||
"rationaleCollapse": "Ocultar justificación",
|
||||
"feedbackHelpful": "Útil",
|
||||
"feedbackNotHelpful": "No útil",
|
||||
"feedbackThanks": "Gracias por tu opinión",
|
||||
"feedbackAlreadyRated": "Ya valorado",
|
||||
"feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.",
|
||||
"confidenceAria": "Confianza: {value} de 100",
|
||||
"confidenceLow": "Confianza baja — pocos datos",
|
||||
"confidenceDraft": "Borrador"
|
||||
},
|
||||
"loadError": "No se han podido cargar los análisis",
|
||||
"heroPersonalBaseline": "Basado en tus últimos 90 días",
|
||||
"heroRegenerating": "Generando",
|
||||
@@ -2545,11 +2537,6 @@
|
||||
"areas": "{present} de {required} ámbitos de salud"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Pregúntame",
|
||||
"tellMyDoctor": "¿Qué debería contarle a mi médico?",
|
||||
"medicationWorking": "¿Está funcionando mi medicamento?"
|
||||
},
|
||||
"dailyBriefing": {
|
||||
"title": "Resumen del día",
|
||||
"keyFindingsTitle": "Hallazgos clave",
|
||||
@@ -2761,8 +2748,8 @@
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días",
|
||||
"last90days": "Últimos 90 días",
|
||||
"lastYear": "lo que va de año",
|
||||
"allTime": "Todo el tiempo"
|
||||
"lastYear": "últimos 12 meses",
|
||||
"allTime": "todo el tiempo (12 meses en detalle)"
|
||||
},
|
||||
"windowLabel": "Periodo",
|
||||
"settingsAriaLabel": "Ajustes del coach",
|
||||
@@ -2776,6 +2763,7 @@
|
||||
"settingsVerbosityBrief": "Breve",
|
||||
"settingsVerbosityDefault": "Estándar",
|
||||
"settingsVerbosityDetailed": "Detallado",
|
||||
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
|
||||
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
|
||||
"settingsSourcesPointerLink": "Abrir el coach",
|
||||
"settingsExcludeLabel": "Excluir métricas",
|
||||
@@ -2787,7 +2775,7 @@
|
||||
"settingsDefaultWindowLabel": "Periodo de análisis por defecto",
|
||||
"settingsDefaultWindowHint": "El coach lee este periodo por chat, salvo que elijas otro en la cabecera del cajón.",
|
||||
"settingsSave": "Guardar",
|
||||
"settingsSaved": "Guardado.",
|
||||
"settingsSaved": "Guardado",
|
||||
"settingsCancel": "Cancelar",
|
||||
"feedbackHelpful": "Útil",
|
||||
"feedbackUnhelpful": "Poco útil",
|
||||
@@ -3059,7 +3047,7 @@
|
||||
"loadError": "No se pudo cargar esta métrica ahora mismo.",
|
||||
"blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.",
|
||||
"gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.",
|
||||
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
|
||||
"pulsDescription": "Pulso en reposo frente a una banda de referencia por edad y sexo basada en percentiles poblacionales de CDC/NCHS, con una valoración escrita.",
|
||||
"stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.",
|
||||
"medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.",
|
||||
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
|
||||
@@ -3160,7 +3148,8 @@
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
|
||||
"headlineTitle": "Media por noche",
|
||||
"mainSleep": "Sueño principal",
|
||||
"totalSleep": "Sueño total",
|
||||
"compositionTitle": "Distribución de fases",
|
||||
"compositionSubtitle": "Últimas {nights} noches",
|
||||
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
|
||||
@@ -4162,7 +4151,7 @@
|
||||
"title": "Ordenar las pestañas de navegación",
|
||||
"description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo.",
|
||||
"dragHandle": "Reordenar",
|
||||
"saveSuccess": "Orden de las pestañas guardado.",
|
||||
"saveSuccess": "Orden de las pestañas guardado",
|
||||
"saveError": "No se pudo guardar el orden de las pestañas.",
|
||||
"save": "Guardar",
|
||||
"empty": "Aún no hay pestañas de navegación disponibles.",
|
||||
@@ -4509,7 +4498,7 @@
|
||||
"title": "Withings",
|
||||
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.",
|
||||
"cta": "Conectar Withings",
|
||||
"setupCta": "Configurar en Ajustes",
|
||||
"setupCta": "Configurar",
|
||||
"setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez."
|
||||
},
|
||||
"more": {
|
||||
@@ -4520,7 +4509,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health."
|
||||
},
|
||||
"import": "Importar historial existente"
|
||||
},
|
||||
@@ -4719,7 +4709,6 @@
|
||||
"last30days": "Últimos 30 días",
|
||||
"last90days": "Últimos 90 días",
|
||||
"allTime": "Todo el tiempo",
|
||||
"showEvidence": "Mostrar evidencia de forma predeterminada",
|
||||
"excludedMetrics": "Métricas excluidas",
|
||||
"dataClusters": "Grupos de datos",
|
||||
"customClusters": "Usar una selección personalizada de grupos",
|
||||
@@ -4834,8 +4823,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"description": "Diseño y orden de los mosaicos.",
|
||||
"subtitle": "Organiza los mosaicos de tu panel."
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -4856,9 +4843,9 @@
|
||||
"resetDefaultsConfirm": "Restablecer orden",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"saveSuccess": "Prioridad de fuentes guardada.",
|
||||
"saveSuccess": "Prioridad de fuentes guardada",
|
||||
"saveError": "No se pudo guardar la prioridad de fuentes.",
|
||||
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.",
|
||||
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados",
|
||||
"perMetricToggle": "Ajustes por métrica ({count})",
|
||||
"perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.",
|
||||
"perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.",
|
||||
@@ -5002,7 +4989,14 @@
|
||||
"unpacking": "Descomprimiendo el archivo…",
|
||||
"parsing": "Leyendo la exportación…",
|
||||
"upserting": "Importando registros…"
|
||||
}
|
||||
},
|
||||
"skipUnsupported": "{count} muestras omitidas: tipos de datos que HealthLog no importa.",
|
||||
"skipRefused": "{count} muestras no se pudieron escribir (ilegibles, fuera de rango o falló el guardado).",
|
||||
"skipBreakdown": "Mostrar desglose ({count} tipos)",
|
||||
"skipBreakdownMore": "+ {count} tipos más",
|
||||
"cycleSummary": "{samples} muestras de ciclo combinadas en {days} registros diarios.",
|
||||
"cycleModuleOff": "{count} muestras de ciclo omitidas: el seguimiento del ciclo está desactivado en esta cuenta.",
|
||||
"cycleDaysFailed": "{count} días de ciclo no se pudieron escribir: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Archivo JSON",
|
||||
@@ -5020,7 +5014,7 @@
|
||||
"failed": "La importación falló. Inténtalo de nuevo.",
|
||||
"readFailed": "No se pudo leer ese archivo.",
|
||||
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
|
||||
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.",
|
||||
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas",
|
||||
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
|
||||
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
|
||||
},
|
||||
@@ -5044,10 +5038,10 @@
|
||||
"previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.",
|
||||
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
|
||||
"rowError": "Línea {line}: {reason}",
|
||||
"resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.",
|
||||
"resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas",
|
||||
"resultNothing": "No se importó nada. Se omitieron las {skipped} filas.",
|
||||
"resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.",
|
||||
"previewSuccess": "Vista previa: {inserted} se importarían.",
|
||||
"previewSuccess": "Vista previa: {inserted} se importarían",
|
||||
"previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.",
|
||||
"skipGroupOne": "{count} fila omitida: {reason}",
|
||||
"skipGroupFew": "{count} filas omitidas: {reason}",
|
||||
@@ -5138,38 +5132,26 @@
|
||||
"subtitle": "Comparte acceso de solo lectura a tus datos."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Análisis",
|
||||
"description": "Orden de la vista general y de las pestañas de navegación.",
|
||||
"subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras."
|
||||
"subtitle": "Organiza las secciones de tu resumen y el orden de las pestañas."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
"description": "Vista de lista, orden y zonas de inyección de tus medicamentos.",
|
||||
"subtitle": "Gestiona la vista y el orden de tu lista de medicamentos."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Etiquetas de ánimo",
|
||||
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
|
||||
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laboratorio",
|
||||
"description": "Vista, orden y biomarcadores de tu laboratorio.",
|
||||
"subtitle": "Gestiona la vista, el orden y los biomarcadores."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Diario de enfermedad",
|
||||
"description": "Vista y orden de tus episodios de enfermedad.",
|
||||
"subtitle": "Gestiona la vista y el orden de los episodios."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Revisiones",
|
||||
"description": "Vista, orden y recordatorios de revisión individuales.",
|
||||
"subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Módulos",
|
||||
"saved": "Guardado.",
|
||||
"saved": "Guardado",
|
||||
"error": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"toggleable": {
|
||||
"title": "Qué registras",
|
||||
@@ -5217,7 +5199,7 @@
|
||||
"use": "Usar",
|
||||
"select": "Seleccionar",
|
||||
"saveError": "No se pudo guardar el cambio.",
|
||||
"homeSaved": "Ubicación principal guardada.",
|
||||
"homeSaved": "Ubicación principal guardada",
|
||||
"travelAdded": "Periodo de ubicación añadido.",
|
||||
"travelRemoved": "Periodo de ubicación eliminado.",
|
||||
"backfillQueued": "Relleno programado.",
|
||||
@@ -5274,7 +5256,7 @@
|
||||
"current": "Tu selección guardada"
|
||||
},
|
||||
"save": "Guardar",
|
||||
"saved": "Guardado.",
|
||||
"saved": "Guardado",
|
||||
"error": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"loadFailed": "No se pudo cargar tu selección.",
|
||||
"eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.",
|
||||
@@ -5312,7 +5294,7 @@
|
||||
"auto": "Automático (idioma)",
|
||||
"h24": "24 horas",
|
||||
"h12": "12 horas (AM/PM)",
|
||||
"saved": "Formato horario guardado.",
|
||||
"saved": "Formato horario guardado",
|
||||
"saveError": "No se pudo guardar el formato horario."
|
||||
},
|
||||
"dateFormat": {
|
||||
@@ -5322,7 +5304,7 @@
|
||||
"dmy": "DD.MM.AAAA",
|
||||
"mdy": "MM/DD/AAAA",
|
||||
"ymd": "AAAA-MM-DD",
|
||||
"saved": "Formato de fecha guardado.",
|
||||
"saved": "Formato de fecha guardado",
|
||||
"saveError": "No se pudo guardar el formato de fecha."
|
||||
},
|
||||
"username": "Nombre de usuario",
|
||||
@@ -5616,7 +5598,7 @@
|
||||
"clearBody": "Solo se elimina el texto libre. Tus alergias e intolerancias permanecen tal cual.",
|
||||
"clearConfirm": "Vaciar nota",
|
||||
"save": "Guardar",
|
||||
"savedToast": "Nota «Sobre mí» guardada.",
|
||||
"savedToast": "Nota «Sobre mí» guardada",
|
||||
"clearedToast": "Nota «Sobre mí» eliminada.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"loadError": "No se pudo cargar la nota.",
|
||||
@@ -5638,14 +5620,14 @@
|
||||
"title": "Leer documentos automáticamente con IA",
|
||||
"subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
|
||||
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.",
|
||||
"confirm": "Lo entiendo: activar la lectura automática con IA",
|
||||
"confirm": "Entendido: activar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Usar el acceso de IA compartido del servidor",
|
||||
"subLabel": "Enruta las solicitudes de IA a través de la conexión que el operador configuró para este servidor, junto a tus propios proveedores como alternativa.",
|
||||
"honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.",
|
||||
"confirm": "Entendido: usar la conexión compartida",
|
||||
"confirm": "Entendido: usar la conexión",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"consent": {
|
||||
@@ -5667,7 +5649,7 @@
|
||||
"description": "Elige cómo se muestran las distancias y velocidades en los gráficos y mosaicos.",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"saved": "Preferencia de unidades guardada.",
|
||||
"saved": "Preferencia de unidades guardada",
|
||||
"saveError": "No se pudo guardar tu preferencia de unidades."
|
||||
}
|
||||
},
|
||||
@@ -5855,7 +5837,7 @@
|
||||
"errorReconnect": "Error — reconectar",
|
||||
"warningServerError": "Sincronización con errores",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Pausado — reconectar manualmente",
|
||||
"parkedReconnect": "Pausado — reconectar",
|
||||
"notConnected": "No conectado",
|
||||
"justNow": "ahora mismo",
|
||||
"minutesAgo": "hace {count} min",
|
||||
@@ -5939,6 +5921,7 @@
|
||||
"repository": "Código fuente",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
},
|
||||
@@ -5966,7 +5949,7 @@
|
||||
},
|
||||
"notificationStatus": {
|
||||
"title": "Fiabilidad de los canales",
|
||||
"description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.",
|
||||
"description": "Estado de cada canal — «Desactivado automáticamente» significa detenido tras errores permanentes.",
|
||||
"emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.",
|
||||
"stateActive": "Activo",
|
||||
"stateAutoDisabled": "Desactivado automáticamente",
|
||||
@@ -6032,7 +6015,7 @@
|
||||
"expiryInvalid": "La caducidad debe estar entre 1 y {max} días.",
|
||||
"create": "Crear enlace",
|
||||
"tokenCreated": "Enlace para compartir creado",
|
||||
"copied": "Copiado al portapapeles.",
|
||||
"copied": "Copiado al portapapeles",
|
||||
"activeTitle": "Enlaces activos",
|
||||
"noActive": "No hay enlaces para compartir activos.",
|
||||
"created": "Creado",
|
||||
@@ -6184,7 +6167,7 @@
|
||||
"revokeSession": "Cerrar sesión",
|
||||
"revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.",
|
||||
"signOutEverywhere": "Cerrar sesión en los demás dispositivos",
|
||||
"signOutEverywhereDone": "Se cerraron {count} sesión(es).",
|
||||
"signOutEverywhereDone": "Se cerraron {count} sesión(es)",
|
||||
"activityTitle": "Actividad de seguridad",
|
||||
"activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.",
|
||||
"activityEmpty": "Aún no hay actividad de seguridad.",
|
||||
@@ -6265,7 +6248,7 @@
|
||||
"revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.",
|
||||
"revoke": "Revocar",
|
||||
"revokeAll": "Olvidar todos los dispositivos",
|
||||
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
|
||||
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza",
|
||||
"unnamed": "Dispositivo desconocido",
|
||||
"current": "Este dispositivo",
|
||||
"expires": "De confianza hasta el {date}",
|
||||
@@ -6329,8 +6312,8 @@
|
||||
"endpointLabel": "URL del conector",
|
||||
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
|
||||
"tokensTitle": "Tokens del conector",
|
||||
"tokensDescription": "Genera un token de solo lectura para conectar un asistente.",
|
||||
"scopeNote": "De forma predeterminada, los tokens solo tienen acceso de lectura a tus propios registros. El acceso de escritura es opcional y se activa por token con el interruptor de abajo. Los tokens nunca pueden acceder a funciones de administración.",
|
||||
"tokensDescription": "Genera un token para conectar un asistente: solo lectura, salvo que permitas el registro abajo.",
|
||||
"scopeNote": "Los tokens nuevos solo pueden leer tus propios registros. El acceso de escritura (health:write) se concede por token con el interruptor de abajo. Un token nunca puede acceder a funciones de administración.",
|
||||
"writeScopeToggleLabel": "Permitir registro (health:write)",
|
||||
"writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.",
|
||||
"connectionsTitle": "Asistentes conectados",
|
||||
@@ -6386,6 +6369,13 @@
|
||||
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
|
||||
"importAction": "Abrir importación puntual",
|
||||
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progreso de sincronización",
|
||||
"receivedLabel": "Registros recibidos",
|
||||
"oldestLabel": "Lectura más antigua alcanzada",
|
||||
"flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.",
|
||||
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Entrega",
|
||||
"lastTriggerLabel": "La última sincronización llegó",
|
||||
@@ -6590,13 +6580,13 @@
|
||||
"maxUsesLabel": "Usos máximos",
|
||||
"maxUsesHint": "Cuántos registros admite este enlace (1–50).",
|
||||
"createConfirm": "Crear invitación",
|
||||
"createdToast": "Invitación creada.",
|
||||
"createdToast": "Invitación creada",
|
||||
"createError": "No se pudo crear la invitación.",
|
||||
"tokenTitle": "Enlace de invitación",
|
||||
"tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.",
|
||||
"hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.",
|
||||
"copyLink": "Copiar enlace",
|
||||
"copiedToast": "Enlace copiado.",
|
||||
"copiedToast": "Enlace copiado",
|
||||
"copyError": "No se pudo copiar.",
|
||||
"qrAlt": "Código QR del enlace de invitación",
|
||||
"loadError": "No se pudieron cargar las invitaciones.",
|
||||
@@ -6890,6 +6880,23 @@
|
||||
"restoreTitle": "Restaurar desde copia de seguridad",
|
||||
"restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.",
|
||||
"restorePromptLabel": "Escribe RESTORE para continuar",
|
||||
"previewTitle": "Contenido de la copia",
|
||||
"previewLoading": "Leyendo el contenido…",
|
||||
"previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.",
|
||||
"previewEmpty": "El archivo no contiene registros.",
|
||||
"previewOther": "Otros registros",
|
||||
"previewMeasurements": "Mediciones",
|
||||
"previewMedications": "Medicamentos y tomas",
|
||||
"previewMood": "Registros de ánimo",
|
||||
"previewLabs": "Resultados de laboratorio",
|
||||
"previewDocuments": "Documentos",
|
||||
"previewWorkouts": "Entrenamientos",
|
||||
"previewCycles": "Datos del ciclo",
|
||||
"previewVisits": "Visitas médicas",
|
||||
"previewVaccinations": "Vacunas",
|
||||
"previewReminders": "Recordatorios preventivos",
|
||||
"previewCustomMetrics": "Métricas propias",
|
||||
"previewHealthScores": "Historial de puntuación",
|
||||
"restoreConfirm": "Restaurar ahora",
|
||||
"restoreInProgress": "Restaurando...",
|
||||
"restoreSuccess": "Restauración completada",
|
||||
@@ -6905,7 +6912,17 @@
|
||||
"restoreSkippedVisitReference": "Referencia de visita",
|
||||
"restoreSkippedVaccinationReference": "Referencia de vacunación",
|
||||
"restoreSkippedCheckupClosure": "Cierre de revisión",
|
||||
"docsLink": "Documentación"
|
||||
"restoreSkippedReminderReference": "Referencia de recordatorio",
|
||||
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
|
||||
"restoreSkippedCoachReference": "Referencia del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registro del dato",
|
||||
"docsLink": "Documentación",
|
||||
"restoreSkippedPersonalRecordReference": "Procedencia de un récord personal",
|
||||
"restoreSkippedEcgReference": "Procedencia de un trazado de ECG",
|
||||
"restoreSkippedMedicationTarget": "Analito objetivo de un medicamento",
|
||||
"restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
@@ -7339,7 +7356,7 @@
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"hourLabel": "Hora del recordatorio",
|
||||
"hourAria": "Hora del recordatorio de ánimo",
|
||||
"hourSavedToast": "Hora del recordatorio actualizada.",
|
||||
"hourSavedToast": "Hora del recordatorio actualizada",
|
||||
"detail": "Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy."
|
||||
},
|
||||
"coachNudge": {
|
||||
@@ -7380,8 +7397,8 @@
|
||||
"leadLabel": "Plazo de reposición (días)",
|
||||
"leadAria": "Plazo de reposición en días",
|
||||
"leadHelp": "El aviso se envía estos días más un intervalo de dosis antes, para que la reposición llegue antes de tu última dosis. Un medicamento concreto puede anular este valor en su pestaña de existencias.",
|
||||
"leadSavedToast": "Plazo de reposición guardado.",
|
||||
"savedToast": "Umbral de existencias guardado.",
|
||||
"leadSavedToast": "Plazo de reposición guardado",
|
||||
"savedToast": "Umbral de existencias guardado",
|
||||
"enabledToast": "Avisos de existencias bajas activados.",
|
||||
"disabledToast": "Avisos de existencias bajas desactivados.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
@@ -8041,10 +8058,10 @@
|
||||
"protected": "Si se usó anticoncepción durante las relaciones.",
|
||||
"protectedLabel": "¿Qué significa protegido?"
|
||||
},
|
||||
"saveSuccess": "Entrada guardada.",
|
||||
"deleteSuccess": "Entrada eliminada.",
|
||||
"periodStartSaved": "Inicio del periodo guardado.",
|
||||
"periodEndSaved": "Fin del periodo guardado.",
|
||||
"saveSuccess": "Entrada guardada",
|
||||
"deleteSuccess": "Entrada eliminada",
|
||||
"periodStartSaved": "Inicio del periodo guardado",
|
||||
"periodEndSaved": "Fin del periodo guardado",
|
||||
"startedPeriodOnDate": "Mi periodo empezó este día",
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día"
|
||||
},
|
||||
@@ -8084,6 +8101,23 @@
|
||||
"delete": "Eliminar",
|
||||
"deleteConfirmTitle": "¿Eliminar recordatorio?",
|
||||
"deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.",
|
||||
"postpone": {
|
||||
"menuItem": "Aplazar…",
|
||||
"title": "Aplazar vencimiento",
|
||||
"description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.",
|
||||
"chip": "+{days} días",
|
||||
"dateLabel": "Nueva fecha",
|
||||
"confirm": "Aplazar"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Omitir este vencimiento",
|
||||
"explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.",
|
||||
"action": "Omitir",
|
||||
"confirmTitle": "¿Omitir este vencimiento?"
|
||||
},
|
||||
"snoozedUntil": "Aplazado hasta {date}",
|
||||
"skippedOn": "Omitido el {date}",
|
||||
"skipCount": "omitido {count} veces",
|
||||
"disabledBadge": "Apagado",
|
||||
"nextDue": {
|
||||
"none": "Sin programar",
|
||||
@@ -8933,7 +8967,8 @@
|
||||
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
|
||||
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
|
||||
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
@@ -9034,7 +9069,9 @@
|
||||
"flaggedBadge": "Apoyo mostrado",
|
||||
"chartTitle": "Evolución de la puntuación",
|
||||
"totalLabel": "Puntuación",
|
||||
"bandLabel": "Categoría"
|
||||
"bandLabel": "Categoría",
|
||||
"answersToggle": "Respuestas",
|
||||
"answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "No tienes que afrontar esto a solas",
|
||||
@@ -9305,7 +9342,7 @@
|
||||
"freeText": {
|
||||
"label": "Nota de texto libre (alergias e intolerancias)",
|
||||
"hint": "Un complemento de texto libre a la lista estructurada de arriba, para contexto que una entrada individual no puede reflejar.",
|
||||
"savedToast": "Nota de alergias guardada.",
|
||||
"savedToast": "Nota de alergias guardada",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
@@ -9364,7 +9401,7 @@
|
||||
"conditionsLabel": "Enfermedades crónicas",
|
||||
"focusLabel": "Qué vigila el Coach",
|
||||
"loadError": "No se pudieron cargar tus enfermedades.",
|
||||
"savedToast": "Enfermedades guardadas.",
|
||||
"savedToast": "Enfermedades guardadas",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo."
|
||||
},
|
||||
"emergency": {
|
||||
@@ -9468,7 +9505,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Ya estaba guardado — se resalta el documento existente.",
|
||||
"deleted": "Documento eliminado.",
|
||||
"deleted": "Documento eliminado",
|
||||
"restoreFailed": "No se pudo restaurar el documento."
|
||||
},
|
||||
"timeline": {
|
||||
@@ -9641,9 +9678,9 @@
|
||||
"unverifiedNoteOther": "{count} valores aún necesitan comprobación y quedan pendientes.",
|
||||
"finish": "Terminar revisión",
|
||||
"confirmFailed": "No se pudo guardar la revisión. Inténtalo de nuevo.",
|
||||
"savedToastOne": "1 valor guardado.",
|
||||
"savedToastFew": "{count} valores guardados.",
|
||||
"savedToastOther": "{count} valores guardados.",
|
||||
"savedToastOne": "1 valor guardado",
|
||||
"savedToastFew": "{count} valores guardados",
|
||||
"savedToastOther": "{count} valores guardados",
|
||||
"nothingSavedToast": "Revisión guardada. No se añadió ningún valor.",
|
||||
"extract": "Extraer valores de laboratorio",
|
||||
"extracting": "Extrayendo…",
|
||||
@@ -9738,6 +9775,8 @@
|
||||
"editedToast": "Valor actualizado",
|
||||
"editError": "No se pudo actualizar el valor",
|
||||
"deletedToast": "Valor eliminado",
|
||||
"restoredToast": "Valor restaurado",
|
||||
"restoreError": "No se pudo restaurar",
|
||||
"deleteError": "No se pudo eliminar el valor"
|
||||
},
|
||||
"chart": {
|
||||
@@ -10184,7 +10223,7 @@
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Guardado en el historial de {name}."
|
||||
"savedTo": "Guardado en el historial de {name}"
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} añadió una medición",
|
||||
@@ -10207,6 +10246,8 @@
|
||||
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
|
||||
"measurementReminderComplete": "{name} completó un recordatorio de medición",
|
||||
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
|
||||
"measurementReminderSkip": "{name} omitió un recordatorio de medición",
|
||||
"measurementReminderSnooze": "{name} aplazó un recordatorio de medición",
|
||||
"labResultUpdate": "{name} cambió un resultado de laboratorio",
|
||||
"labResultDelete": "{name} eliminó un resultado de laboratorio",
|
||||
"labResultRestore": "{name} restauró un resultado de laboratorio",
|
||||
|
||||
+135
-94
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Temps forts du jour",
|
||||
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
|
||||
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
|
||||
"heroContentTitle": "La carte du haut affiche",
|
||||
"heroContentScore": "Score de santé et résumé du jour",
|
||||
"heroContentReminders": "Temps forts du jour",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Bilan avec Coach",
|
||||
"dose_window": "Doses de médicaments",
|
||||
@@ -560,7 +563,7 @@
|
||||
"viewAll": "Tout voir",
|
||||
"empty": {
|
||||
"title": "Aucun entraînement",
|
||||
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
|
||||
"cta": "Connecte une source de données dans les réglages pour afficher les entraînements ici."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
@@ -729,9 +732,12 @@
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} mesures supprimées",
|
||||
"bulkDeleteSuccessOne": "Mesure supprimée",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
|
||||
"bulkDeleteConfirmTitleOne": "Supprimer cette mesure ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"bulkDeleteConfirmBodyOne": "La mesure sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"typePhq9Score": "Score PHQ-9",
|
||||
"typeGad7Score": "Score GAD-7",
|
||||
"typeGripStrength": "Force de préhension",
|
||||
@@ -879,9 +885,12 @@
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} entrées supprimées",
|
||||
"bulkDeleteSuccessOne": "Entrée supprimée",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
|
||||
"bulkDeleteConfirmTitleOne": "Supprimer cette entrée ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"bulkDeleteConfirmBodyOne": "L'entrée d'humeur sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"customize": "Personnaliser les étiquettes d’humeur",
|
||||
"addTagInline": "Ajouter une étiquette",
|
||||
"manage": {
|
||||
@@ -1779,7 +1788,8 @@
|
||||
"enabledToast": "Rappels activés",
|
||||
"disabledToast": "Rappels désactivés",
|
||||
"toggleFailed": "Impossible de modifier le réglage des rappels.",
|
||||
"clientManagedChip": "Votre iPhone gère les rappels de ce médicament."
|
||||
"clientManagedChip": "Géré par l’iPhone",
|
||||
"clientManagedHint": "Votre iPhone gère les rappels de ce médicament."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages",
|
||||
@@ -1932,7 +1942,7 @@
|
||||
"addQuantityHelper": "1–1000 unités par boîte ou contenant.",
|
||||
"addExpiryLabel": "Date de péremption (facultatif)",
|
||||
"addSubmit": "Enregistrer",
|
||||
"addSuccess": "Stock enregistré.",
|
||||
"addSuccess": "Stock enregistré",
|
||||
"addFailed": "Le stock n'a pas pu être enregistré.",
|
||||
"adjustButton": "Ajuster",
|
||||
"adjustTitle": "Corriger le contenant",
|
||||
@@ -1940,11 +1950,11 @@
|
||||
"adjustQuantityLabel": "Unités restantes",
|
||||
"adjustHelper": "Capacité : {total} unités.",
|
||||
"adjustSubmit": "Enregistrer",
|
||||
"adjustSuccess": "Stock mis à jour.",
|
||||
"adjustSuccess": "Stock mis à jour",
|
||||
"adjustFailed": "Le stock n'a pas pu être mis à jour.",
|
||||
"deleteTitle": "Supprimer le contenant ?",
|
||||
"deleteDescription": "Supprime définitivement cette boîte ou ce contenant du stock. Les prises enregistrées restent inchangées.",
|
||||
"deleteSuccess": "Entrée de stock supprimée.",
|
||||
"deleteSuccess": "Entrée de stock supprimée",
|
||||
"deleteFailed": "L'entrée de stock n'a pas pu être supprimée.",
|
||||
"containerTypeLabel": "Type de contenant",
|
||||
"containerType": {
|
||||
@@ -2139,7 +2149,7 @@
|
||||
"scaleToggleLabel": "Échelle du graphique"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} prises importées.",
|
||||
"resultSuccess": "{imported} prises importées",
|
||||
"resultPartial": "{imported} importées, {skipped} ignorées.",
|
||||
"resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.",
|
||||
"resultAlreadyRecorded": "{recorded} prises sont déjà enregistrées. Rien de nouveau n’a été ajouté.",
|
||||
@@ -2302,7 +2312,6 @@
|
||||
}
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Comparer avec",
|
||||
"baseline": {
|
||||
"none": "Désactivé",
|
||||
"lastMonth": "Mois précédent",
|
||||
@@ -2423,23 +2432,6 @@
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personnel : {value} {unit} le {date}"
|
||||
},
|
||||
"recommendation": {
|
||||
"source": "Source",
|
||||
"viewSource": "Ouvrir la recommandation dans un nouvel onglet",
|
||||
"rationaleWindow": "Fenêtre",
|
||||
"rationaleComparedTo": "Comparé à",
|
||||
"rationaleDeviation": "Écart",
|
||||
"rationaleExpand": "Afficher la justification",
|
||||
"rationaleCollapse": "Masquer la justification",
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackNotHelpful": "Peu utile",
|
||||
"feedbackThanks": "Merci pour ton retour",
|
||||
"feedbackAlreadyRated": "Déjà noté",
|
||||
"feedbackError": "Impossible d’enregistrer votre retour. Réessayez.",
|
||||
"confidenceAria": "Confiance : {value} sur 100",
|
||||
"confidenceLow": "Confiance faible — peu de points de données",
|
||||
"confidenceDraft": "Brouillon"
|
||||
},
|
||||
"loadError": "Impossible de charger les analyses",
|
||||
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
|
||||
"heroRegenerating": "Génération en cours",
|
||||
@@ -2545,11 +2537,6 @@
|
||||
"areas": "{present} domaines de santé sur {required}"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Demande-moi",
|
||||
"tellMyDoctor": "Que devrais-je dire à mon médecin ?",
|
||||
"medicationWorking": "Mon médicament fonctionne-t-il ?"
|
||||
},
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing du jour",
|
||||
"keyFindingsTitle": "Constats clés",
|
||||
@@ -2761,8 +2748,8 @@
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours",
|
||||
"last90days": "90 derniers jours",
|
||||
"lastYear": "depuis le début de l'année",
|
||||
"allTime": "Toute la période"
|
||||
"lastYear": "12 derniers mois",
|
||||
"allTime": "toute la période (12 mois en détail)"
|
||||
},
|
||||
"windowLabel": "Période",
|
||||
"settingsAriaLabel": "Paramètres du coach",
|
||||
@@ -2776,6 +2763,7 @@
|
||||
"settingsVerbosityBrief": "Bref",
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Détaillé",
|
||||
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
|
||||
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
|
||||
"settingsSourcesPointerLink": "Ouvrir le coach",
|
||||
"settingsExcludeLabel": "Exclure des métriques",
|
||||
@@ -2787,7 +2775,7 @@
|
||||
"settingsDefaultWindowLabel": "Période d’analyse par défaut",
|
||||
"settingsDefaultWindowHint": "Le coach lit cette période par chat, sauf si tu en choisis une autre dans l’en-tête du panneau.",
|
||||
"settingsSave": "Enregistrer",
|
||||
"settingsSaved": "Enregistré.",
|
||||
"settingsSaved": "Enregistré",
|
||||
"settingsCancel": "Annuler",
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackUnhelpful": "Peu utile",
|
||||
@@ -3059,7 +3047,7 @@
|
||||
"loadError": "Impossible de charger cette mesure pour le moment.",
|
||||
"blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.",
|
||||
"gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.",
|
||||
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
|
||||
"pulsDescription": "Pouls au repos comparé à une plage de référence selon l'âge et le sexe, issue des percentiles de population CDC/NCHS, avec une évaluation rédigée.",
|
||||
"stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.",
|
||||
"medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.",
|
||||
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
|
||||
@@ -3160,7 +3148,8 @@
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
|
||||
"headlineTitle": "Moyenne par nuit",
|
||||
"mainSleep": "Sommeil principal",
|
||||
"totalSleep": "Sommeil total",
|
||||
"compositionTitle": "Répartition des phases",
|
||||
"compositionSubtitle": "{nights} dernières nuits",
|
||||
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
|
||||
@@ -4162,7 +4151,7 @@
|
||||
"title": "Trier les onglets de navigation",
|
||||
"description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil.",
|
||||
"dragHandle": "Réorganiser",
|
||||
"saveSuccess": "Ordre des onglets enregistré.",
|
||||
"saveSuccess": "Ordre des onglets enregistré",
|
||||
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
|
||||
"save": "Enregistrer",
|
||||
"empty": "Aucun onglet de navigation disponible pour le moment.",
|
||||
@@ -4509,7 +4498,7 @@
|
||||
"title": "Withings",
|
||||
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.",
|
||||
"cta": "Connecter Withings",
|
||||
"setupCta": "Configurer dans les Réglages",
|
||||
"setupCta": "Configurer",
|
||||
"setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois."
|
||||
},
|
||||
"more": {
|
||||
@@ -4520,7 +4509,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health."
|
||||
},
|
||||
"import": "Importer un historique existant"
|
||||
},
|
||||
@@ -4719,7 +4709,6 @@
|
||||
"last30days": "30 derniers jours",
|
||||
"last90days": "90 derniers jours",
|
||||
"allTime": "Depuis toujours",
|
||||
"showEvidence": "Afficher les sources par défaut",
|
||||
"excludedMetrics": "Mesures exclues",
|
||||
"dataClusters": "Groupes de données",
|
||||
"customClusters": "Utiliser une sélection personnalisée de groupes",
|
||||
@@ -4834,8 +4823,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"description": "Disposition et ordre des tuiles.",
|
||||
"subtitle": "Organisez les tuiles de votre tableau de bord."
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -4856,9 +4843,9 @@
|
||||
"resetDefaultsConfirm": "Réinitialiser l’ordre",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"saveSuccess": "Priorité des sources enregistrée.",
|
||||
"saveSuccess": "Priorité des sources enregistrée",
|
||||
"saveError": "Échec de l’enregistrement de la priorité des sources.",
|
||||
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.",
|
||||
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut",
|
||||
"perMetricToggle": "Réglages par métrique ({count})",
|
||||
"perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.",
|
||||
"perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.",
|
||||
@@ -5002,7 +4989,14 @@
|
||||
"unpacking": "Décompression de l'archive…",
|
||||
"parsing": "Lecture de l'export…",
|
||||
"upserting": "Import des enregistrements…"
|
||||
}
|
||||
},
|
||||
"skipUnsupported": "{count} échantillons ignorés — des types de données que HealthLog n'importe pas.",
|
||||
"skipRefused": "{count} échantillons n'ont pas pu être écrits (illisibles, hors plage ou échec de l'enregistrement).",
|
||||
"skipBreakdown": "Afficher le détail ({count} types)",
|
||||
"skipBreakdownMore": "+ {count} autres types",
|
||||
"cycleSummary": "{samples} échantillons de cycle fusionnés en {days} journaux quotidiens.",
|
||||
"cycleModuleOff": "{count} échantillons de cycle ignorés — le suivi du cycle est désactivé pour ce compte.",
|
||||
"cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Fichier JSON",
|
||||
@@ -5020,7 +5014,7 @@
|
||||
"failed": "L'import a échoué. Veuillez réessayer.",
|
||||
"readFailed": "Impossible de lire ce fichier.",
|
||||
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
|
||||
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.",
|
||||
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées",
|
||||
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
|
||||
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
|
||||
},
|
||||
@@ -5044,10 +5038,10 @@
|
||||
"previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.",
|
||||
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
|
||||
"rowError": "Ligne {line} : {reason}",
|
||||
"resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.",
|
||||
"resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour",
|
||||
"resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.",
|
||||
"resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.",
|
||||
"previewSuccess": "Aperçu : {inserted} seraient importées.",
|
||||
"previewSuccess": "Aperçu : {inserted} seraient importées",
|
||||
"previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.",
|
||||
"skipGroupOne": "{count} ligne ignorée : {reason}",
|
||||
"skipGroupFew": "{count} lignes ignorées : {reason}",
|
||||
@@ -5138,38 +5132,26 @@
|
||||
"subtitle": "Partagez un accès en lecture seule à vos données."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analyses",
|
||||
"description": "Ordre de la vue d'ensemble et des onglets de navigation.",
|
||||
"subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles."
|
||||
"subtitle": "Organisez les sections d'aperçu et l'ordre des onglets."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
"description": "Vue liste, ordre et sites d’injection de vos médicaments.",
|
||||
"subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Étiquettes d’humeur",
|
||||
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
|
||||
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Analyses",
|
||||
"description": "Affichage, tri et biomarqueurs de vos analyses.",
|
||||
"subtitle": "Gérez l’affichage, le tri et les biomarqueurs."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Journal de maladie",
|
||||
"description": "Affichage et ordre de vos épisodes de maladie.",
|
||||
"subtitle": "Gérez l’affichage et l’ordre des épisodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Examens",
|
||||
"description": "Affichage, ordre et rappels d’examen individuels.",
|
||||
"subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Modules",
|
||||
"saved": "Enregistré.",
|
||||
"saved": "Enregistré",
|
||||
"error": "Échec de l'enregistrement. Veuillez réessayer.",
|
||||
"toggleable": {
|
||||
"title": "Ce que vous suivez",
|
||||
@@ -5217,7 +5199,7 @@
|
||||
"use": "Utiliser",
|
||||
"select": "Sélectionner",
|
||||
"saveError": "Impossible d'enregistrer la modification.",
|
||||
"homeSaved": "Lieu de référence enregistré.",
|
||||
"homeSaved": "Lieu de référence enregistré",
|
||||
"travelAdded": "Période de localisation ajoutée.",
|
||||
"travelRemoved": "Période de localisation supprimée.",
|
||||
"backfillQueued": "Récupération planifiée.",
|
||||
@@ -5274,7 +5256,7 @@
|
||||
"current": "Votre sélection enregistrée"
|
||||
},
|
||||
"save": "Enregistrer",
|
||||
"saved": "Enregistré.",
|
||||
"saved": "Enregistré",
|
||||
"error": "Enregistrement impossible. Veuillez réessayer.",
|
||||
"loadFailed": "Impossible de charger votre sélection.",
|
||||
"eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.",
|
||||
@@ -5312,7 +5294,7 @@
|
||||
"auto": "Automatique (langue)",
|
||||
"h24": "24 heures",
|
||||
"h12": "12 heures (AM/PM)",
|
||||
"saved": "Format horaire enregistré.",
|
||||
"saved": "Format horaire enregistré",
|
||||
"saveError": "Échec de l’enregistrement du format horaire."
|
||||
},
|
||||
"dateFormat": {
|
||||
@@ -5322,7 +5304,7 @@
|
||||
"dmy": "JJ.MM.AAAA",
|
||||
"mdy": "MM/JJ/AAAA",
|
||||
"ymd": "AAAA-MM-JJ",
|
||||
"saved": "Format de date enregistré.",
|
||||
"saved": "Format de date enregistré",
|
||||
"saveError": "Échec de l’enregistrement du format de date."
|
||||
},
|
||||
"username": "Nom d'utilisateur",
|
||||
@@ -5616,7 +5598,7 @@
|
||||
"clearBody": "Seul le texte libre est supprimé. Vos allergies et intolérances restent inchangées.",
|
||||
"clearConfirm": "Vider la note",
|
||||
"save": "Enregistrer",
|
||||
"savedToast": "Note « À propos de moi » enregistrée.",
|
||||
"savedToast": "Note « À propos de moi » enregistrée",
|
||||
"clearedToast": "Note « À propos de moi » supprimée.",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer.",
|
||||
"loadError": "Impossible de charger la note.",
|
||||
@@ -5638,14 +5620,14 @@
|
||||
"title": "Lire les documents automatiquement avec l'IA",
|
||||
"subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
|
||||
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.",
|
||||
"confirm": "Je comprends — activer la lecture automatique par l'IA",
|
||||
"confirm": "Compris — activer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Utiliser l'accès IA partagé du serveur",
|
||||
"subLabel": "Acheminez les requêtes IA via la connexion que l'opérateur a configurée pour ce serveur, en complément de vos propres fournisseurs comme solution de repli.",
|
||||
"honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.",
|
||||
"confirm": "J'ai compris — utiliser la connexion partagée",
|
||||
"confirm": "Compris — utiliser la connexion",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"consent": {
|
||||
@@ -5667,7 +5649,7 @@
|
||||
"description": "Choisissez l'affichage des distances et des vitesses dans les graphiques et les tuiles.",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"saved": "Préférence d'unités enregistrée.",
|
||||
"saved": "Préférence d'unités enregistrée",
|
||||
"saveError": "Impossible d'enregistrer votre préférence d'unités."
|
||||
}
|
||||
},
|
||||
@@ -5855,7 +5837,7 @@
|
||||
"errorReconnect": "Erreur — reconnecter",
|
||||
"warningServerError": "Échec de synchronisation",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "En pause — reconnecter manuellement",
|
||||
"parkedReconnect": "En pause — reconnecter",
|
||||
"notConnected": "Non connecté",
|
||||
"justNow": "à l’instant",
|
||||
"minutesAgo": "il y a {count} min",
|
||||
@@ -5939,6 +5921,7 @@
|
||||
"repository": "Code source",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
},
|
||||
@@ -5966,7 +5949,7 @@
|
||||
},
|
||||
"notificationStatus": {
|
||||
"title": "Fiabilité des canaux",
|
||||
"description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.",
|
||||
"description": "État de chaque canal — « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.",
|
||||
"emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.",
|
||||
"stateActive": "Actif",
|
||||
"stateAutoDisabled": "Désactivé automatiquement",
|
||||
@@ -6032,7 +6015,7 @@
|
||||
"expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.",
|
||||
"create": "Créer le lien",
|
||||
"tokenCreated": "Lien de partage créé",
|
||||
"copied": "Copié dans le presse-papiers.",
|
||||
"copied": "Copié dans le presse-papiers",
|
||||
"activeTitle": "Liens actifs",
|
||||
"noActive": "Aucun lien de partage actif.",
|
||||
"created": "Créé",
|
||||
@@ -6184,7 +6167,7 @@
|
||||
"revokeSession": "Déconnecter",
|
||||
"revokeError": "Impossible de traiter la demande. Veuillez réessayer.",
|
||||
"signOutEverywhere": "Se déconnecter partout ailleurs",
|
||||
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).",
|
||||
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s)",
|
||||
"activityTitle": "Activité de sécurité",
|
||||
"activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.",
|
||||
"activityEmpty": "Aucune activité de sécurité pour l'instant.",
|
||||
@@ -6265,7 +6248,7 @@
|
||||
"revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.",
|
||||
"revoke": "Révoquer",
|
||||
"revokeAll": "Oublier tous les appareils",
|
||||
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
|
||||
"revokeAllDone": "{count} appareil(s) de confiance oublié(s)",
|
||||
"unnamed": "Appareil inconnu",
|
||||
"current": "Cet appareil",
|
||||
"expires": "De confiance jusqu'au {date}",
|
||||
@@ -6329,8 +6312,8 @@
|
||||
"endpointLabel": "URL du connecteur",
|
||||
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
|
||||
"tokensTitle": "Jetons du connecteur",
|
||||
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant.",
|
||||
"scopeNote": "Par défaut, les jetons n'ont qu'un accès en lecture à vos propres données. L'accès en écriture est optionnel et s'active par jeton avec le commutateur ci-dessous. Les jetons ne peuvent jamais atteindre les fonctions d'administration.",
|
||||
"tokensDescription": "Générez un jeton pour connecter un assistant — lecture seule, sauf si vous autorisez l'écriture ci-dessous.",
|
||||
"scopeNote": "Les nouveaux jetons ne peuvent que lire vos propres données. L'accès en écriture (health:write) s'accorde par jeton avec le commutateur ci-dessous. Un jeton ne peut jamais atteindre les fonctions d'administration.",
|
||||
"writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)",
|
||||
"writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.",
|
||||
"connectionsTitle": "Assistants connectés",
|
||||
@@ -6386,6 +6369,13 @@
|
||||
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
|
||||
"importAction": "Ouvrir l’import ponctuel",
|
||||
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
|
||||
"progress": {
|
||||
"title": "Progression de la synchronisation",
|
||||
"receivedLabel": "Enregistrements reçus",
|
||||
"oldestLabel": "Mesure la plus ancienne atteinte",
|
||||
"flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.",
|
||||
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Livraison",
|
||||
"lastTriggerLabel": "La dernière synchronisation est arrivée",
|
||||
@@ -6590,13 +6580,13 @@
|
||||
"maxUsesLabel": "Utilisations maximales",
|
||||
"maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).",
|
||||
"createConfirm": "Créer l'invitation",
|
||||
"createdToast": "Invitation créée.",
|
||||
"createdToast": "Invitation créée",
|
||||
"createError": "Impossible de créer l'invitation.",
|
||||
"tokenTitle": "Lien d'invitation",
|
||||
"tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.",
|
||||
"hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.",
|
||||
"copyLink": "Copier le lien",
|
||||
"copiedToast": "Lien copié.",
|
||||
"copiedToast": "Lien copié",
|
||||
"copyError": "Échec de la copie.",
|
||||
"qrAlt": "Code QR du lien d'invitation",
|
||||
"loadError": "Impossible de charger les invitations.",
|
||||
@@ -6890,6 +6880,23 @@
|
||||
"restoreTitle": "Restaurer à partir d'une sauvegarde",
|
||||
"restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.",
|
||||
"restorePromptLabel": "Tapez RESTORE pour continuer",
|
||||
"previewTitle": "Contenu de la sauvegarde",
|
||||
"previewLoading": "Lecture du contenu…",
|
||||
"previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.",
|
||||
"previewEmpty": "Le fichier ne contient aucun enregistrement.",
|
||||
"previewOther": "Autres enregistrements",
|
||||
"previewMeasurements": "Mesures",
|
||||
"previewMedications": "Médicaments et prises",
|
||||
"previewMood": "Entrées d'humeur",
|
||||
"previewLabs": "Résultats de laboratoire",
|
||||
"previewDocuments": "Documents",
|
||||
"previewWorkouts": "Entraînements",
|
||||
"previewCycles": "Données de cycle",
|
||||
"previewVisits": "Visites médicales",
|
||||
"previewVaccinations": "Vaccinations",
|
||||
"previewReminders": "Rappels de prévention",
|
||||
"previewCustomMetrics": "Mesures personnalisées",
|
||||
"previewHealthScores": "Historique du score",
|
||||
"restoreConfirm": "Restaurer maintenant",
|
||||
"restoreInProgress": "Restauration en cours...",
|
||||
"restoreSuccess": "Restauration terminée",
|
||||
@@ -6905,7 +6912,17 @@
|
||||
"restoreSkippedVisitReference": "Référence de visite",
|
||||
"restoreSkippedVaccinationReference": "Référence de vaccination",
|
||||
"restoreSkippedCheckupClosure": "Clôture d'un examen",
|
||||
"docsLink": "Documentation"
|
||||
"restoreSkippedReminderReference": "Référence de rappel",
|
||||
"restoreSkippedCoachAttachment": "Lien de document du Coach",
|
||||
"restoreSkippedCoachReference": "Référence du Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classement du document",
|
||||
"restoreSkippedExtractedFact": "Donnée du document",
|
||||
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
|
||||
"docsLink": "Documentation",
|
||||
"restoreSkippedPersonalRecordReference": "Provenance d'un record personnel",
|
||||
"restoreSkippedEcgReference": "Provenance d'un tracé ECG",
|
||||
"restoreSkippedMedicationTarget": "Analyte cible d'un médicament",
|
||||
"restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
@@ -7339,7 +7356,7 @@
|
||||
"saveError": "Échec de l’enregistrement. Réessayez.",
|
||||
"hourLabel": "Heure du rappel",
|
||||
"hourAria": "Heure du rappel d’humeur",
|
||||
"hourSavedToast": "Heure du rappel mise à jour.",
|
||||
"hourSavedToast": "Heure du rappel mise à jour",
|
||||
"detail": "Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui."
|
||||
},
|
||||
"coachNudge": {
|
||||
@@ -7380,8 +7397,8 @@
|
||||
"leadLabel": "Délai de réapprovisionnement (jours)",
|
||||
"leadAria": "Délai de réapprovisionnement en jours",
|
||||
"leadHelp": "L’alerte est envoyée ce nombre de jours plus un intervalle de dose plus tôt, pour qu’un réapprovisionnement arrive avant ta dernière dose. Un médicament donné peut remplacer cette valeur dans son onglet stock.",
|
||||
"leadSavedToast": "Délai de réapprovisionnement enregistré.",
|
||||
"savedToast": "Seuil de stock enregistré.",
|
||||
"leadSavedToast": "Délai de réapprovisionnement enregistré",
|
||||
"savedToast": "Seuil de stock enregistré",
|
||||
"enabledToast": "Alertes de stock faible activées.",
|
||||
"disabledToast": "Alertes de stock faible désactivées.",
|
||||
"saveError": "Échec de l’enregistrement. Réessayez.",
|
||||
@@ -8041,10 +8058,10 @@
|
||||
"protected": "Si une contraception a été utilisée pendant le rapport.",
|
||||
"protectedLabel": "Que signifie protégé ?"
|
||||
},
|
||||
"saveSuccess": "Entrée enregistrée.",
|
||||
"deleteSuccess": "Entrée supprimée.",
|
||||
"periodStartSaved": "Début des règles enregistré.",
|
||||
"periodEndSaved": "Fin des règles enregistrée.",
|
||||
"saveSuccess": "Entrée enregistrée",
|
||||
"deleteSuccess": "Entrée supprimée",
|
||||
"periodStartSaved": "Début des règles enregistré",
|
||||
"periodEndSaved": "Fin des règles enregistrée",
|
||||
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
|
||||
},
|
||||
@@ -8084,6 +8101,23 @@
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirmTitle": "Supprimer le rappel ?",
|
||||
"deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.",
|
||||
"postpone": {
|
||||
"menuItem": "Reporter…",
|
||||
"title": "Reporter l'échéance",
|
||||
"description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.",
|
||||
"chip": "+{days} jours",
|
||||
"dateLabel": "Nouvelle date",
|
||||
"confirm": "Reporter"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Passer cette échéance",
|
||||
"explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.",
|
||||
"action": "Passer",
|
||||
"confirmTitle": "Passer cette échéance ?"
|
||||
},
|
||||
"snoozedUntil": "Reporté au {date}",
|
||||
"skippedOn": "Passé le {date}",
|
||||
"skipCount": "passé {count} fois",
|
||||
"disabledBadge": "Désactivé",
|
||||
"nextDue": {
|
||||
"none": "Non planifié",
|
||||
@@ -8933,7 +8967,8 @@
|
||||
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
|
||||
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
|
||||
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
@@ -9034,7 +9069,9 @@
|
||||
"flaggedBadge": "Aide affichée",
|
||||
"chartTitle": "Évolution du score",
|
||||
"totalLabel": "Score",
|
||||
"bandLabel": "Catégorie"
|
||||
"bandLabel": "Catégorie",
|
||||
"answersToggle": "Réponses",
|
||||
"answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Tu n'as pas à affronter cela seul(e)",
|
||||
@@ -9305,7 +9342,7 @@
|
||||
"freeText": {
|
||||
"label": "Note en texte libre (allergies et intolérances)",
|
||||
"hint": "Un complément en texte libre à la liste structurée ci-dessus, pour un contexte qu'une entrée seule ne peut pas refléter.",
|
||||
"savedToast": "Note d'allergies enregistrée.",
|
||||
"savedToast": "Note d'allergies enregistrée",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
@@ -9364,7 +9401,7 @@
|
||||
"conditionsLabel": "Affections chroniques",
|
||||
"focusLabel": "Ce que le Coach surveille",
|
||||
"loadError": "Impossible de charger vos antécédents.",
|
||||
"savedToast": "Affections enregistrées.",
|
||||
"savedToast": "Affections enregistrées",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
|
||||
},
|
||||
"emergency": {
|
||||
@@ -9468,7 +9505,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Déjà enregistré — le document existant est mis en évidence.",
|
||||
"deleted": "Document supprimé.",
|
||||
"deleted": "Document supprimé",
|
||||
"restoreFailed": "Impossible de restaurer le document."
|
||||
},
|
||||
"timeline": {
|
||||
@@ -9641,9 +9678,9 @@
|
||||
"unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"finish": "Terminer la vérification",
|
||||
"confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.",
|
||||
"savedToastOne": "1 valeur enregistrée.",
|
||||
"savedToastFew": "{count} valeurs enregistrées.",
|
||||
"savedToastOther": "{count} valeurs enregistrées.",
|
||||
"savedToastOne": "1 valeur enregistrée",
|
||||
"savedToastFew": "{count} valeurs enregistrées",
|
||||
"savedToastOther": "{count} valeurs enregistrées",
|
||||
"nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
|
||||
"extract": "Extraire les valeurs de laboratoire",
|
||||
"extracting": "Extraction…",
|
||||
@@ -9738,6 +9775,8 @@
|
||||
"editedToast": "Valeur mise à jour",
|
||||
"editError": "Impossible de mettre à jour la valeur",
|
||||
"deletedToast": "Valeur supprimée",
|
||||
"restoredToast": "Valeur restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"deleteError": "Impossible de supprimer la valeur"
|
||||
},
|
||||
"chart": {
|
||||
@@ -10184,7 +10223,7 @@
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Enregistré dans le dossier de {name}."
|
||||
"savedTo": "Enregistré dans le dossier de {name}"
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} a ajouté une mesure",
|
||||
@@ -10207,6 +10246,8 @@
|
||||
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
|
||||
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
|
||||
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
|
||||
"measurementReminderSkip": "{name} a passé un rappel de mesure",
|
||||
"measurementReminderSnooze": "{name} a reporté un rappel de mesure",
|
||||
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
|
||||
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
|
||||
"labResultRestore": "{name} a restauré un résultat de laboratoire",
|
||||
|
||||
+135
-94
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "In evidenza oggi",
|
||||
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
|
||||
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
|
||||
"heroContentTitle": "La scheda in alto mostra",
|
||||
"heroContentScore": "Punteggio di salute e riepilogo del giorno",
|
||||
"heroContentReminders": "In evidenza oggi",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Verifiche con Coach",
|
||||
"dose_window": "Dosi dei farmaci",
|
||||
@@ -560,7 +563,7 @@
|
||||
"viewAll": "Vedi tutti",
|
||||
"empty": {
|
||||
"title": "Nessun allenamento",
|
||||
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
|
||||
"cta": "Collega una fonte di dati nelle impostazioni per vedere qui gli allenamenti."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
@@ -729,9 +732,12 @@
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "{count} misurazioni eliminate",
|
||||
"bulkDeleteSuccessOne": "Misurazione eliminata",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
|
||||
"bulkDeleteConfirmTitleOne": "Eliminare questa misurazione?",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"bulkDeleteConfirmBodyOne": "La misurazione selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"typePhq9Score": "Punteggio PHQ-9",
|
||||
"typeGad7Score": "Punteggio GAD-7",
|
||||
"typeGripStrength": "Forza di presa",
|
||||
@@ -879,9 +885,12 @@
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "{count} voci eliminate",
|
||||
"bulkDeleteSuccessOne": "Voce eliminata",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
|
||||
"bulkDeleteConfirmTitleOne": "Eliminare questa voce?",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"bulkDeleteConfirmBodyOne": "La voce dell'umore selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"customize": "Personalizza i tag dell’umore",
|
||||
"addTagInline": "Aggiungi tag",
|
||||
"manage": {
|
||||
@@ -1779,7 +1788,8 @@
|
||||
"enabledToast": "Promemoria attivi",
|
||||
"disabledToast": "Promemoria disattivati",
|
||||
"toggleFailed": "Impossibile modificare l’impostazione dei promemoria.",
|
||||
"clientManagedChip": "Il tuo iPhone gestisce i promemoria di questo farmaco."
|
||||
"clientManagedChip": "Gestito dall’iPhone",
|
||||
"clientManagedHint": "Il tuo iPhone gestisce i promemoria di questo farmaco."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
@@ -1932,7 +1942,7 @@
|
||||
"addQuantityHelper": "1–1000 unità per confezione o contenitore.",
|
||||
"addExpiryLabel": "Data di scadenza (facoltativa)",
|
||||
"addSubmit": "Registra",
|
||||
"addSuccess": "Scorta registrata.",
|
||||
"addSuccess": "Scorta registrata",
|
||||
"addFailed": "Impossibile registrare la scorta.",
|
||||
"adjustButton": "Correggi",
|
||||
"adjustTitle": "Correggi il contenitore",
|
||||
@@ -1940,11 +1950,11 @@
|
||||
"adjustQuantityLabel": "Unità rimanenti",
|
||||
"adjustHelper": "Capacità: {total} unità.",
|
||||
"adjustSubmit": "Salva",
|
||||
"adjustSuccess": "Scorta aggiornata.",
|
||||
"adjustSuccess": "Scorta aggiornata",
|
||||
"adjustFailed": "Impossibile aggiornare la scorta.",
|
||||
"deleteTitle": "Eliminare la confezione?",
|
||||
"deleteDescription": "Rimuove definitivamente questa confezione o contenitore dalla scorta. Le assunzioni registrate restano invariate.",
|
||||
"deleteSuccess": "Scorta eliminata.",
|
||||
"deleteSuccess": "Scorta eliminata",
|
||||
"deleteFailed": "Impossibile eliminare la scorta.",
|
||||
"containerTypeLabel": "Tipo di contenitore",
|
||||
"containerType": {
|
||||
@@ -2139,7 +2149,7 @@
|
||||
"scaleToggleLabel": "Scala del grafico"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "{imported} assunzioni importate.",
|
||||
"resultSuccess": "{imported} assunzioni importate",
|
||||
"resultPartial": "{imported} importate, {skipped} ignorate.",
|
||||
"resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.",
|
||||
"resultAlreadyRecorded": "{recorded} assunzioni sono già registrate. Non è stato aggiunto nulla di nuovo.",
|
||||
@@ -2302,7 +2312,6 @@
|
||||
}
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Confronta con",
|
||||
"baseline": {
|
||||
"none": "Disattivato",
|
||||
"lastMonth": "Mese precedente",
|
||||
@@ -2423,23 +2432,6 @@
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personale: {value} {unit} il {date}"
|
||||
},
|
||||
"recommendation": {
|
||||
"source": "Fonte",
|
||||
"viewSource": "Apri la linea guida in una nuova scheda",
|
||||
"rationaleWindow": "Finestra",
|
||||
"rationaleComparedTo": "Confrontato con",
|
||||
"rationaleDeviation": "Scostamento",
|
||||
"rationaleExpand": "Mostra motivazione",
|
||||
"rationaleCollapse": "Nascondi motivazione",
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackNotHelpful": "Poco utile",
|
||||
"feedbackThanks": "Grazie per il riscontro",
|
||||
"feedbackAlreadyRated": "Già valutato",
|
||||
"feedbackError": "Impossibile salvare il tuo riscontro. Riprova.",
|
||||
"confidenceAria": "Confidenza: {value} su 100",
|
||||
"confidenceLow": "Confidenza bassa — pochi dati",
|
||||
"confidenceDraft": "Bozza"
|
||||
},
|
||||
"loadError": "Impossibile caricare le analisi",
|
||||
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
|
||||
"heroRegenerating": "Generazione in corso",
|
||||
@@ -2545,11 +2537,6 @@
|
||||
"areas": "{present} ambiti di salute su {required}"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Chiedimi",
|
||||
"tellMyDoctor": "Cosa dovrei dire al mio medico?",
|
||||
"medicationWorking": "Il mio farmaco sta funzionando?"
|
||||
},
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing del giorno",
|
||||
"keyFindingsTitle": "Riscontri principali",
|
||||
@@ -2761,8 +2748,8 @@
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni",
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"lastYear": "anno in corso",
|
||||
"allTime": "Tutto il tempo"
|
||||
"lastYear": "ultimi 12 mesi",
|
||||
"allTime": "tutto il tempo (12 mesi in dettaglio)"
|
||||
},
|
||||
"windowLabel": "Periodo",
|
||||
"settingsAriaLabel": "Impostazioni del coach",
|
||||
@@ -2776,6 +2763,7 @@
|
||||
"settingsVerbosityBrief": "Breve",
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Dettagliata",
|
||||
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
|
||||
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
|
||||
"settingsSourcesPointerLink": "Apri il coach",
|
||||
"settingsExcludeLabel": "Escludi metriche",
|
||||
@@ -2787,7 +2775,7 @@
|
||||
"settingsDefaultWindowLabel": "Periodo di analisi predefinito",
|
||||
"settingsDefaultWindowHint": "Il coach legge questo periodo per chat, salvo che tu non scelga diversamente nell’intestazione del drawer.",
|
||||
"settingsSave": "Salva",
|
||||
"settingsSaved": "Salvato.",
|
||||
"settingsSaved": "Salvato",
|
||||
"settingsCancel": "Annulla",
|
||||
"feedbackHelpful": "Utile",
|
||||
"feedbackUnhelpful": "Poco utile",
|
||||
@@ -3059,7 +3047,7 @@
|
||||
"loadError": "Impossibile caricare questa metrica al momento.",
|
||||
"blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.",
|
||||
"gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.",
|
||||
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
|
||||
"pulsDescription": "Battito a riposo confrontato con una fascia di riferimento per età e sesso, basata sui percentili di popolazione CDC/NCHS, con una valutazione scritta.",
|
||||
"stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.",
|
||||
"medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.",
|
||||
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
|
||||
@@ -3160,7 +3148,8 @@
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
|
||||
"headlineTitle": "Media per notte",
|
||||
"mainSleep": "Sonno principale",
|
||||
"totalSleep": "Sonno totale",
|
||||
"compositionTitle": "Distribuzione delle fasi",
|
||||
"compositionSubtitle": "Ultime {nights} notti",
|
||||
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
|
||||
@@ -4162,7 +4151,7 @@
|
||||
"title": "Ordina le schede di navigazione",
|
||||
"description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio.",
|
||||
"dragHandle": "Riordina",
|
||||
"saveSuccess": "Ordine delle schede salvato.",
|
||||
"saveSuccess": "Ordine delle schede salvato",
|
||||
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
|
||||
"save": "Salva",
|
||||
"empty": "Nessuna scheda di navigazione ancora disponibile.",
|
||||
@@ -4509,7 +4498,7 @@
|
||||
"title": "Withings",
|
||||
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.",
|
||||
"cta": "Connetti Withings",
|
||||
"setupCta": "Configura nelle Impostazioni",
|
||||
"setupCta": "Configura",
|
||||
"setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum."
|
||||
},
|
||||
"more": {
|
||||
@@ -4520,7 +4509,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health."
|
||||
},
|
||||
"import": "Importa la cronologia esistente"
|
||||
},
|
||||
@@ -4719,7 +4709,6 @@
|
||||
"last30days": "Ultimi 30 giorni",
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"allTime": "Tutto il periodo",
|
||||
"showEvidence": "Mostra le fonti per impostazione predefinita",
|
||||
"excludedMetrics": "Metriche escluse",
|
||||
"dataClusters": "Gruppi di dati",
|
||||
"customClusters": "Usa una selezione personalizzata di gruppi",
|
||||
@@ -4834,8 +4823,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Cruscotto",
|
||||
"description": "Layout e ordine dei riquadri.",
|
||||
"subtitle": "Disponi i riquadri della tua dashboard."
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -4856,9 +4843,9 @@
|
||||
"resetDefaultsConfirm": "Reimposta ordine",
|
||||
"moveUp": "Sposta su",
|
||||
"moveDown": "Sposta giù",
|
||||
"saveSuccess": "Priorità delle fonti salvata.",
|
||||
"saveSuccess": "Priorità delle fonti salvata",
|
||||
"saveError": "Salvataggio della priorità delle fonti non riuscito.",
|
||||
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.",
|
||||
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti",
|
||||
"perMetricToggle": "Personalizzazioni per metrica ({count})",
|
||||
"perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.",
|
||||
"perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.",
|
||||
@@ -5002,7 +4989,14 @@
|
||||
"unpacking": "Estrazione dell'archivio…",
|
||||
"parsing": "Lettura dell'esportazione…",
|
||||
"upserting": "Importazione dei record…"
|
||||
}
|
||||
},
|
||||
"skipUnsupported": "{count} campioni saltati — tipi di dati che HealthLog non importa.",
|
||||
"skipRefused": "{count} campioni non sono stati scritti (illeggibili, fuori intervallo o salvataggio non riuscito).",
|
||||
"skipBreakdown": "Mostra dettaglio ({count} tipi)",
|
||||
"skipBreakdownMore": "+ {count} altri tipi",
|
||||
"cycleSummary": "{samples} campioni del ciclo uniti in {days} registri giornalieri.",
|
||||
"cycleModuleOff": "{count} campioni del ciclo saltati — il monitoraggio del ciclo è disattivato per questo account.",
|
||||
"cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "File JSON",
|
||||
@@ -5020,7 +5014,7 @@
|
||||
"failed": "L'importazione non è riuscita. Riprova.",
|
||||
"readFailed": "Impossibile leggere il file.",
|
||||
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
|
||||
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.",
|
||||
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate",
|
||||
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
|
||||
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
|
||||
},
|
||||
@@ -5044,10 +5038,10 @@
|
||||
"previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.",
|
||||
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
|
||||
"rowError": "Riga {line}: {reason}",
|
||||
"resultSuccess": "{inserted} nuove importate e {updated} aggiornate.",
|
||||
"resultSuccess": "{inserted} nuove importate e {updated} aggiornate",
|
||||
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.",
|
||||
"resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.",
|
||||
"previewSuccess": "Anteprima: {inserted} verrebbero importate.",
|
||||
"previewSuccess": "Anteprima: {inserted} verrebbero importate",
|
||||
"previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.",
|
||||
"skipGroupOne": "{count} riga saltata: {reason}",
|
||||
"skipGroupFew": "{count} righe saltate: {reason}",
|
||||
@@ -5138,38 +5132,26 @@
|
||||
"subtitle": "Condividi l'accesso in sola lettura ai tuoi dati."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Approfondimenti",
|
||||
"description": "Ordine della panoramica e delle schede di navigazione.",
|
||||
"subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill."
|
||||
"subtitle": "Disponi le sezioni della panoramica e l'ordine delle schede."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
"description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.",
|
||||
"subtitle": "Gestisci vista e ordine del tuo elenco di farmaci."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Tag dell’umore",
|
||||
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
|
||||
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Esami",
|
||||
"description": "Vista, ordinamento e biomarcatori dei tuoi esami.",
|
||||
"subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Diario delle malattie",
|
||||
"description": "Vista e ordine dei tuoi episodi di malattia.",
|
||||
"subtitle": "Gestisci la vista e l’ordine degli episodi."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Controlli",
|
||||
"description": "Vista, ordine e singoli promemoria di controllo.",
|
||||
"subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduli",
|
||||
"saved": "Salvato.",
|
||||
"saved": "Salvato",
|
||||
"error": "Salvataggio non riuscito. Riprova.",
|
||||
"toggleable": {
|
||||
"title": "Cosa monitori",
|
||||
@@ -5217,7 +5199,7 @@
|
||||
"use": "Usa",
|
||||
"select": "Seleziona",
|
||||
"saveError": "Impossibile salvare la modifica.",
|
||||
"homeSaved": "Posizione principale salvata.",
|
||||
"homeSaved": "Posizione principale salvata",
|
||||
"travelAdded": "Periodo di posizione aggiunto.",
|
||||
"travelRemoved": "Periodo di posizione rimosso.",
|
||||
"backfillQueued": "Recupero pianificato.",
|
||||
@@ -5274,7 +5256,7 @@
|
||||
"current": "La tua selezione salvata"
|
||||
},
|
||||
"save": "Salva",
|
||||
"saved": "Salvato.",
|
||||
"saved": "Salvato",
|
||||
"error": "Non è stato possibile salvare. Riprova.",
|
||||
"loadFailed": "Non è stato possibile caricare la tua selezione.",
|
||||
"eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.",
|
||||
@@ -5312,7 +5294,7 @@
|
||||
"auto": "Automatico (lingua)",
|
||||
"h24": "24 ore",
|
||||
"h12": "12 ore (AM/PM)",
|
||||
"saved": "Formato orario salvato.",
|
||||
"saved": "Formato orario salvato",
|
||||
"saveError": "Salvataggio del formato orario non riuscito."
|
||||
},
|
||||
"dateFormat": {
|
||||
@@ -5322,7 +5304,7 @@
|
||||
"dmy": "GG.MM.AAAA",
|
||||
"mdy": "MM/GG/AAAA",
|
||||
"ymd": "AAAA-MM-GG",
|
||||
"saved": "Formato data salvato.",
|
||||
"saved": "Formato data salvato",
|
||||
"saveError": "Salvataggio del formato data non riuscito."
|
||||
},
|
||||
"username": "Nome utente",
|
||||
@@ -5616,7 +5598,7 @@
|
||||
"clearBody": "Rimuove solo il testo libero. Allergie e intolleranze restano invariate.",
|
||||
"clearConfirm": "Svuota nota",
|
||||
"save": "Salva",
|
||||
"savedToast": "Nota «Su di me» salvata.",
|
||||
"savedToast": "Nota «Su di me» salvata",
|
||||
"clearedToast": "Nota «Su di me» eliminata.",
|
||||
"saveError": "Impossibile salvare. Riprova.",
|
||||
"loadError": "Impossibile caricare la nota.",
|
||||
@@ -5638,14 +5620,14 @@
|
||||
"title": "Leggi i documenti automaticamente con l'IA",
|
||||
"subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
|
||||
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.",
|
||||
"confirm": "Ho capito: attiva la lettura automatica con l'IA",
|
||||
"confirm": "Ho capito — attiva",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Usa l'accesso IA condiviso del server",
|
||||
"subLabel": "Instrada le richieste IA attraverso la connessione che l'operatore ha configurato per questo server, insieme ai tuoi provider come ripiego.",
|
||||
"honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.",
|
||||
"confirm": "Ho capito — usa la connessione condivisa",
|
||||
"confirm": "Ho capito — usa la connessione",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"consent": {
|
||||
@@ -5667,7 +5649,7 @@
|
||||
"description": "Scegli come vengono mostrate distanze e velocità nei grafici e nei riquadri.",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"saved": "Preferenza unità salvata.",
|
||||
"saved": "Preferenza unità salvata",
|
||||
"saveError": "Impossibile salvare la preferenza delle unità."
|
||||
}
|
||||
},
|
||||
@@ -5855,7 +5837,7 @@
|
||||
"errorReconnect": "Errore — riconnetti",
|
||||
"warningServerError": "Sincronizzazione in errore",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "In pausa — riconnettere manualmente",
|
||||
"parkedReconnect": "In pausa — riconnetti",
|
||||
"notConnected": "Non connesso",
|
||||
"justNow": "proprio ora",
|
||||
"minutesAgo": "{count} min fa",
|
||||
@@ -5939,6 +5921,7 @@
|
||||
"repository": "Codice sorgente",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
},
|
||||
@@ -5966,7 +5949,7 @@
|
||||
},
|
||||
"notificationStatus": {
|
||||
"title": "Affidabilità dei canali",
|
||||
"description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.",
|
||||
"description": "Stato di ogni canale — «Disattivato automaticamente» significa fermato dopo errori permanenti.",
|
||||
"emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.",
|
||||
"stateActive": "Attivo",
|
||||
"stateAutoDisabled": "Disattivato automaticamente",
|
||||
@@ -6032,7 +6015,7 @@
|
||||
"expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.",
|
||||
"create": "Crea link",
|
||||
"tokenCreated": "Link di condivisione creato",
|
||||
"copied": "Copiato negli appunti.",
|
||||
"copied": "Copiato negli appunti",
|
||||
"activeTitle": "Link attivi",
|
||||
"noActive": "Nessun link di condivisione attivo.",
|
||||
"created": "Creato",
|
||||
@@ -6184,7 +6167,7 @@
|
||||
"revokeSession": "Disconnetti",
|
||||
"revokeError": "Impossibile completare la richiesta. Riprova.",
|
||||
"signOutEverywhere": "Disconnetti ovunque",
|
||||
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.",
|
||||
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i",
|
||||
"activityTitle": "Attività di sicurezza",
|
||||
"activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.",
|
||||
"activityEmpty": "Nessuna attività di sicurezza.",
|
||||
@@ -6265,7 +6248,7 @@
|
||||
"revokeError": "Impossibile revocare il dispositivo. Riprova.",
|
||||
"revoke": "Revoca",
|
||||
"revokeAll": "Dimentica tutti i dispositivi",
|
||||
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
|
||||
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i",
|
||||
"unnamed": "Dispositivo sconosciuto",
|
||||
"current": "Questo dispositivo",
|
||||
"expires": "Attendibile fino al {date}",
|
||||
@@ -6329,8 +6312,8 @@
|
||||
"endpointLabel": "URL del connettore",
|
||||
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
|
||||
"tokensTitle": "Token del connettore",
|
||||
"tokensDescription": "Genera un token in sola lettura per collegare un assistente.",
|
||||
"scopeNote": "Per impostazione predefinita, i token hanno accesso in sola lettura ai tuoi dati. L'accesso in scrittura è opzionale e si attiva per singolo token con l'interruttore qui sotto. I token non possono mai raggiungere funzioni di amministrazione.",
|
||||
"tokensDescription": "Genera un token per collegare un assistente: sola lettura, a meno che tu non consenta la scrittura qui sotto.",
|
||||
"scopeNote": "I nuovi token possono solo leggere i tuoi dati. L'accesso in scrittura (health:write) si concede per singolo token con l'interruttore qui sotto. Un token non può mai raggiungere le funzioni di amministrazione.",
|
||||
"writeScopeToggleLabel": "Consenti la registrazione (health:write)",
|
||||
"writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.",
|
||||
"connectionsTitle": "Assistenti collegati",
|
||||
@@ -6386,6 +6369,13 @@
|
||||
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
|
||||
"importAction": "Apri importazione una tantum",
|
||||
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
|
||||
"progress": {
|
||||
"title": "Avanzamento della sincronizzazione",
|
||||
"receivedLabel": "Record ricevuti",
|
||||
"oldestLabel": "Lettura più vecchia raggiunta",
|
||||
"flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.",
|
||||
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Consegna",
|
||||
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
|
||||
@@ -6590,13 +6580,13 @@
|
||||
"maxUsesLabel": "Utilizzi massimi",
|
||||
"maxUsesHint": "Quante registrazioni consente questo link (1–50).",
|
||||
"createConfirm": "Crea invito",
|
||||
"createdToast": "Invito creato.",
|
||||
"createdToast": "Invito creato",
|
||||
"createError": "Impossibile creare l'invito.",
|
||||
"tokenTitle": "Link di invito",
|
||||
"tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.",
|
||||
"hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.",
|
||||
"copyLink": "Copia link",
|
||||
"copiedToast": "Link copiato.",
|
||||
"copiedToast": "Link copiato",
|
||||
"copyError": "Copia non riuscita.",
|
||||
"qrAlt": "Codice QR del link di invito",
|
||||
"loadError": "Impossibile caricare gli inviti.",
|
||||
@@ -6890,6 +6880,23 @@
|
||||
"restoreTitle": "Ripristina dal backup",
|
||||
"restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.",
|
||||
"restorePromptLabel": "Digita RESTORE per continuare",
|
||||
"previewTitle": "Contenuto del backup",
|
||||
"previewLoading": "Lettura del contenuto…",
|
||||
"previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.",
|
||||
"previewEmpty": "Il file non contiene record.",
|
||||
"previewOther": "Altri record",
|
||||
"previewMeasurements": "Misurazioni",
|
||||
"previewMedications": "Farmaci e assunzioni",
|
||||
"previewMood": "Voci dell'umore",
|
||||
"previewLabs": "Risultati di laboratorio",
|
||||
"previewDocuments": "Documenti",
|
||||
"previewWorkouts": "Allenamenti",
|
||||
"previewCycles": "Dati del ciclo",
|
||||
"previewVisits": "Visite mediche",
|
||||
"previewVaccinations": "Vaccinazioni",
|
||||
"previewReminders": "Promemoria di prevenzione",
|
||||
"previewCustomMetrics": "Metriche personalizzate",
|
||||
"previewHealthScores": "Storico del punteggio",
|
||||
"restoreConfirm": "Ripristina ora",
|
||||
"restoreInProgress": "Ripristino in corso...",
|
||||
"restoreSuccess": "Ripristino completato",
|
||||
@@ -6905,7 +6912,17 @@
|
||||
"restoreSkippedVisitReference": "Riferimento alla visita",
|
||||
"restoreSkippedVaccinationReference": "Riferimento alla vaccinazione",
|
||||
"restoreSkippedCheckupClosure": "Chiusura del controllo",
|
||||
"docsLink": "Documentazione"
|
||||
"restoreSkippedReminderReference": "Riferimento al promemoria",
|
||||
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
|
||||
"restoreSkippedCoachReference": "Riferimento del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registrazione del dato",
|
||||
"docsLink": "Documentazione",
|
||||
"restoreSkippedPersonalRecordReference": "Provenienza di un primato personale",
|
||||
"restoreSkippedEcgReference": "Provenienza di un tracciato ECG",
|
||||
"restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco",
|
||||
"restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
@@ -7339,7 +7356,7 @@
|
||||
"saveError": "Salvataggio non riuscito. Riprova.",
|
||||
"hourLabel": "Ora del promemoria",
|
||||
"hourAria": "Ora del promemoria dell’umore",
|
||||
"hourSavedToast": "Ora del promemoria aggiornata.",
|
||||
"hourSavedToast": "Ora del promemoria aggiornata",
|
||||
"detail": "Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi."
|
||||
},
|
||||
"coachNudge": {
|
||||
@@ -7380,8 +7397,8 @@
|
||||
"leadLabel": "Tempo di riordino (giorni)",
|
||||
"leadAria": "Tempo di riordino in giorni",
|
||||
"leadHelp": "L’avviso arriva questo numero di giorni più un intervallo di dose in anticipo, così la fornitura arriva prima dell’ultima dose. Un singolo farmaco può sovrascrivere questo valore nella sua scheda scorte.",
|
||||
"leadSavedToast": "Tempo di riordino salvato.",
|
||||
"savedToast": "Soglia delle scorte salvata.",
|
||||
"leadSavedToast": "Tempo di riordino salvato",
|
||||
"savedToast": "Soglia delle scorte salvata",
|
||||
"enabledToast": "Avvisi di scorte basse attivati.",
|
||||
"disabledToast": "Avvisi di scorte basse disattivati.",
|
||||
"saveError": "Salvataggio non riuscito. Riprova.",
|
||||
@@ -8041,10 +8058,10 @@
|
||||
"protected": "Se durante il rapporto è stata usata la contraccezione.",
|
||||
"protectedLabel": "Cosa significa protetto?"
|
||||
},
|
||||
"saveSuccess": "Voce salvata.",
|
||||
"deleteSuccess": "Voce eliminata.",
|
||||
"periodStartSaved": "Inizio delle mestruazioni salvato.",
|
||||
"periodEndSaved": "Fine delle mestruazioni salvata.",
|
||||
"saveSuccess": "Voce salvata",
|
||||
"deleteSuccess": "Voce eliminata",
|
||||
"periodStartSaved": "Inizio delle mestruazioni salvato",
|
||||
"periodEndSaved": "Fine delle mestruazioni salvata",
|
||||
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
|
||||
},
|
||||
@@ -8084,6 +8101,23 @@
|
||||
"delete": "Elimina",
|
||||
"deleteConfirmTitle": "Eliminare il promemoria?",
|
||||
"deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.",
|
||||
"postpone": {
|
||||
"menuItem": "Rimanda…",
|
||||
"title": "Rimanda la scadenza",
|
||||
"description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.",
|
||||
"chip": "+{days} giorni",
|
||||
"dateLabel": "Nuova data",
|
||||
"confirm": "Rimanda"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Salta questa scadenza",
|
||||
"explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.",
|
||||
"action": "Salta",
|
||||
"confirmTitle": "Saltare questa scadenza?"
|
||||
},
|
||||
"snoozedUntil": "Rimandato al {date}",
|
||||
"skippedOn": "Saltato il {date}",
|
||||
"skipCount": "saltato {count} volte",
|
||||
"disabledBadge": "Disattivato",
|
||||
"nextDue": {
|
||||
"none": "Non pianificato",
|
||||
@@ -8933,7 +8967,8 @@
|
||||
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
|
||||
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
|
||||
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
@@ -9034,7 +9069,9 @@
|
||||
"flaggedBadge": "Supporto mostrato",
|
||||
"chartTitle": "Andamento del punteggio",
|
||||
"totalLabel": "Punteggio",
|
||||
"bandLabel": "Categoria"
|
||||
"bandLabel": "Categoria",
|
||||
"answersToggle": "Risposte",
|
||||
"answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Non devi affrontare tutto questo da solo/a",
|
||||
@@ -9305,7 +9342,7 @@
|
||||
"freeText": {
|
||||
"label": "Nota a testo libero (allergie e intolleranze)",
|
||||
"hint": "Un'integrazione a testo libero all'elenco strutturato qui sopra, per il contesto che una singola voce non può contenere.",
|
||||
"savedToast": "Nota sulle allergie salvata.",
|
||||
"savedToast": "Nota sulle allergie salvata",
|
||||
"saveError": "Impossibile salvare. Riprova."
|
||||
}
|
||||
},
|
||||
@@ -9364,7 +9401,7 @@
|
||||
"conditionsLabel": "Patologie croniche",
|
||||
"focusLabel": "Cosa tiene d'occhio il Coach",
|
||||
"loadError": "Impossibile caricare le tue patologie.",
|
||||
"savedToast": "Patologie salvate.",
|
||||
"savedToast": "Patologie salvate",
|
||||
"saveError": "Impossibile salvare. Riprova."
|
||||
},
|
||||
"emergency": {
|
||||
@@ -9468,7 +9505,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Già archiviato — il documento esistente viene evidenziato.",
|
||||
"deleted": "Documento eliminato.",
|
||||
"deleted": "Documento eliminato",
|
||||
"restoreFailed": "Impossibile ripristinare il documento."
|
||||
},
|
||||
"timeline": {
|
||||
@@ -9641,9 +9678,9 @@
|
||||
"unverifiedNoteOther": "{count} valori devono ancora essere controllati e restano in sospeso.",
|
||||
"finish": "Concludi la revisione",
|
||||
"confirmFailed": "La revisione non è stata salvata. Riprova.",
|
||||
"savedToastOne": "1 valore salvato.",
|
||||
"savedToastFew": "{count} valori salvati.",
|
||||
"savedToastOther": "{count} valori salvati.",
|
||||
"savedToastOne": "1 valore salvato",
|
||||
"savedToastFew": "{count} valori salvati",
|
||||
"savedToastOther": "{count} valori salvati",
|
||||
"nothingSavedToast": "Revisione salvata. Nessun valore è stato aggiunto.",
|
||||
"extract": "Estrai i valori di laboratorio",
|
||||
"extracting": "Estrazione…",
|
||||
@@ -9738,6 +9775,8 @@
|
||||
"editedToast": "Valore aggiornato",
|
||||
"editError": "Impossibile aggiornare il valore",
|
||||
"deletedToast": "Valore eliminato",
|
||||
"restoredToast": "Valore ripristinato",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"deleteError": "Impossibile eliminare il valore"
|
||||
},
|
||||
"chart": {
|
||||
@@ -10184,7 +10223,7 @@
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Salvato nella cartella di {name}."
|
||||
"savedTo": "Salvato nella cartella di {name}"
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} ha aggiunto una misurazione",
|
||||
@@ -10207,6 +10246,8 @@
|
||||
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
|
||||
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
|
||||
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
|
||||
"measurementReminderSkip": "{name} ha saltato un promemoria di misurazione",
|
||||
"measurementReminderSnooze": "{name} ha rimandato un promemoria di misurazione",
|
||||
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
|
||||
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
|
||||
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
|
||||
|
||||
+140
-99
@@ -516,6 +516,9 @@
|
||||
"heroItemsTitle": "Dzisiejsze wyróżnienia",
|
||||
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
|
||||
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
|
||||
"heroContentTitle": "Górna karta pokazuje",
|
||||
"heroContentScore": "Wynik zdrowia i podsumowanie dnia",
|
||||
"heroContentReminders": "Dzisiejsze wyróżnienia",
|
||||
"heroItems": {
|
||||
"coach_checkin": "Podsumowania z Coachem",
|
||||
"dose_window": "Dawki leków",
|
||||
@@ -560,7 +563,7 @@
|
||||
"viewAll": "Zobacz wszystkie",
|
||||
"empty": {
|
||||
"title": "Brak treningów",
|
||||
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
|
||||
"cta": "Połącz źródło danych w ustawieniach, aby zobaczyć tu treningi."
|
||||
}
|
||||
},
|
||||
"metric": {
|
||||
@@ -729,9 +732,12 @@
|
||||
"sourceWhoop": "WHOOP",
|
||||
"sourceFitbit": "Fitbit",
|
||||
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
|
||||
"bulkDeleteSuccessOne": "Usunięto pomiar",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
|
||||
"bulkDeleteConfirmTitleOne": "Usunąć ten pomiar?",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"bulkDeleteConfirmBodyOne": "Wybrany pomiar zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"typePhq9Score": "Wynik PHQ-9",
|
||||
"typeGad7Score": "Wynik GAD-7",
|
||||
"typeGripStrength": "Siła chwytu",
|
||||
@@ -879,9 +885,12 @@
|
||||
"sourceTelegram": "Telegram",
|
||||
"sourceDaylio": "Daylio",
|
||||
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
|
||||
"bulkDeleteSuccessOne": "Usunięto wpis",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
|
||||
"bulkDeleteConfirmTitleOne": "Usunąć ten wpis?",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"bulkDeleteConfirmBodyOne": "Wybrany wpis nastroju zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"customize": "Dostosuj tagi nastroju",
|
||||
"addTagInline": "Dodaj tag",
|
||||
"manage": {
|
||||
@@ -1779,7 +1788,8 @@
|
||||
"enabledToast": "Przypomnienia włączone",
|
||||
"disabledToast": "Przypomnienia wyłączone",
|
||||
"toggleFailed": "Nie udało się zmienić ustawienia przypomnień.",
|
||||
"clientManagedChip": "Twój iPhone zarządza przypomnieniami dla tego leku."
|
||||
"clientManagedChip": "Zarządzane przez iPhone’a",
|
||||
"clientManagedHint": "Twój iPhone zarządza przypomnieniami dla tego leku."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
@@ -1932,7 +1942,7 @@
|
||||
"addQuantityHelper": "1–1000 jednostek na opakowanie lub pojemnik.",
|
||||
"addExpiryLabel": "Data ważności (opcjonalnie)",
|
||||
"addSubmit": "Zarejestruj",
|
||||
"addSuccess": "Zapas zarejestrowany.",
|
||||
"addSuccess": "Zapas zarejestrowany",
|
||||
"addFailed": "Nie udało się zarejestrować zapasu.",
|
||||
"adjustButton": "Skoryguj",
|
||||
"adjustTitle": "Popraw opakowanie",
|
||||
@@ -1940,11 +1950,11 @@
|
||||
"adjustQuantityLabel": "Pozostałe jednostki",
|
||||
"adjustHelper": "Pojemność: {total} jednostek.",
|
||||
"adjustSubmit": "Zapisz",
|
||||
"adjustSuccess": "Zapas zaktualizowany.",
|
||||
"adjustSuccess": "Zapas zaktualizowany",
|
||||
"adjustFailed": "Nie udało się zaktualizować zapasu.",
|
||||
"deleteTitle": "Usunąć opakowanie?",
|
||||
"deleteDescription": "Trwale usuwa to opakowanie lub pojemnik z zapasu. Zarejestrowane dawki pozostają bez zmian.",
|
||||
"deleteSuccess": "Zapas usunięty.",
|
||||
"deleteSuccess": "Zapas usunięty",
|
||||
"deleteFailed": "Nie udało się usunąć zapasu.",
|
||||
"containerTypeLabel": "Rodzaj pojemnika",
|
||||
"containerType": {
|
||||
@@ -2139,7 +2149,7 @@
|
||||
"scaleToggleLabel": "Skala wykresu"
|
||||
},
|
||||
"import": {
|
||||
"resultSuccess": "Zaimportowano przyjęcia: {imported}.",
|
||||
"resultSuccess": "Zaimportowano przyjęcia: {imported}",
|
||||
"resultPartial": "Zaimportowano {imported}, pominięto {skipped}.",
|
||||
"resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).",
|
||||
"resultAlreadyRecorded": "Przyjęcia już zapisane: {recorded}. Nie dodano niczego nowego.",
|
||||
@@ -2302,7 +2312,6 @@
|
||||
}
|
||||
},
|
||||
"comparison": {
|
||||
"toggleLabel": "Porównaj z",
|
||||
"baseline": {
|
||||
"none": "Wyłączone",
|
||||
"lastMonth": "Poprzedni miesiąc",
|
||||
@@ -2402,7 +2411,7 @@
|
||||
"dragHandle": "Przeciągnij, aby zmienić kolejność",
|
||||
"show": "Pokaż",
|
||||
"hide": "Ukryj",
|
||||
"manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach",
|
||||
"manageInSettings": "Zarządzaj chipami i stronami szczegółów w ustawieniach",
|
||||
"groupSleep": "Sen",
|
||||
"groupMood": "Nastrój",
|
||||
"groupEvents": "Zdarzenia",
|
||||
@@ -2423,23 +2432,6 @@
|
||||
"badge": "PR",
|
||||
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
|
||||
},
|
||||
"recommendation": {
|
||||
"source": "Źródło",
|
||||
"viewSource": "Otwórz wytyczne w nowej karcie",
|
||||
"rationaleWindow": "Okno",
|
||||
"rationaleComparedTo": "W porównaniu z",
|
||||
"rationaleDeviation": "Odchylenie",
|
||||
"rationaleExpand": "Pokaż uzasadnienie",
|
||||
"rationaleCollapse": "Ukryj uzasadnienie",
|
||||
"feedbackHelpful": "Przydatne",
|
||||
"feedbackNotHelpful": "Mało przydatne",
|
||||
"feedbackThanks": "Dzięki za opinię",
|
||||
"feedbackAlreadyRated": "Już ocenione",
|
||||
"feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.",
|
||||
"confidenceAria": "Pewność: {value} na 100",
|
||||
"confidenceLow": "Niska pewność — mało danych",
|
||||
"confidenceDraft": "Wersja robocza"
|
||||
},
|
||||
"loadError": "Nie udało się załadować analiz",
|
||||
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
|
||||
"heroRegenerating": "Generowanie",
|
||||
@@ -2545,11 +2537,6 @@
|
||||
"areas": "{present} z {required} obszarów zdrowia"
|
||||
}
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Zapytaj mnie",
|
||||
"tellMyDoctor": "Co powinienem powiedzieć lekarzowi?",
|
||||
"medicationWorking": "Czy mój lek działa?"
|
||||
},
|
||||
"dailyBriefing": {
|
||||
"title": "Briefing dnia",
|
||||
"keyFindingsTitle": "Kluczowe wnioski",
|
||||
@@ -2761,8 +2748,8 @@
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"lastYear": "od początku roku",
|
||||
"allTime": "Cały okres"
|
||||
"lastYear": "ostatnie 12 miesięcy",
|
||||
"allTime": "cały okres (12 miesięcy szczegółowo)"
|
||||
},
|
||||
"windowLabel": "Okres",
|
||||
"settingsAriaLabel": "Ustawienia coacha",
|
||||
@@ -2776,6 +2763,7 @@
|
||||
"settingsVerbosityBrief": "Krótka",
|
||||
"settingsVerbosityDefault": "Standardowa",
|
||||
"settingsVerbosityDetailed": "Szczegółowa",
|
||||
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
|
||||
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
|
||||
"settingsSourcesPointerLink": "Otwórz coacha",
|
||||
"settingsExcludeLabel": "Wyklucz metryki",
|
||||
@@ -2787,7 +2775,7 @@
|
||||
"settingsDefaultWindowLabel": "Domyślny okres analizy",
|
||||
"settingsDefaultWindowHint": "Coach czyta ten okres na rozmowę, chyba że wybierzesz inny w nagłówku panelu.",
|
||||
"settingsSave": "Zapisz",
|
||||
"settingsSaved": "Zapisano.",
|
||||
"settingsSaved": "Zapisano",
|
||||
"settingsCancel": "Anuluj",
|
||||
"feedbackHelpful": "Przydatne",
|
||||
"feedbackUnhelpful": "Mało przydatne",
|
||||
@@ -3059,7 +3047,7 @@
|
||||
"loadError": "Nie udało się teraz wczytać tej metryki.",
|
||||
"blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.",
|
||||
"gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.",
|
||||
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
|
||||
"pulsDescription": "Tętno spoczynkowe na tle zakresu referencyjnego według wieku i płci, opartego na percentylach populacyjnych CDC/NCHS, wraz z oceną opisową.",
|
||||
"stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.",
|
||||
"medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.",
|
||||
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
|
||||
@@ -3160,7 +3148,8 @@
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
|
||||
"headlineTitle": "Średnia na noc",
|
||||
"mainSleep": "Sen główny",
|
||||
"totalSleep": "Sen łączny",
|
||||
"compositionTitle": "Rozkład faz",
|
||||
"compositionSubtitle": "Ostatnie {nights} nocy",
|
||||
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
|
||||
@@ -4159,13 +4148,13 @@
|
||||
"overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz."
|
||||
},
|
||||
"pillOrder": {
|
||||
"title": "Sortuj pigułki nawigacyjne",
|
||||
"description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka.",
|
||||
"title": "Sortuj chipy nawigacyjne",
|
||||
"description": "Przeciągnij chipy w wybranej kolejności i pokaż lub ukryj je ikoną oka.",
|
||||
"dragHandle": "Zmień kolejność",
|
||||
"saveSuccess": "Zapisano kolejność pigułek.",
|
||||
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
|
||||
"saveSuccess": "Zapisano kolejność chipów",
|
||||
"saveError": "Zapisanie kolejności chipów nie powiodło się.",
|
||||
"save": "Zapisz",
|
||||
"empty": "Brak dostępnych pigułek nawigacyjnych.",
|
||||
"empty": "Brak dostępnych chipów nawigacyjnych.",
|
||||
"detail": "Ukryte strony szczegółów znikają również z górnej nawigacji."
|
||||
},
|
||||
"customize": "Dostosuj Analizy",
|
||||
@@ -4509,7 +4498,7 @@
|
||||
"title": "Withings",
|
||||
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.",
|
||||
"cta": "Połącz Withings",
|
||||
"setupCta": "Skonfiguruj w Ustawieniach",
|
||||
"setupCta": "Skonfiguruj",
|
||||
"setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo."
|
||||
},
|
||||
"more": {
|
||||
@@ -4520,7 +4509,8 @@
|
||||
"oura": "Oura",
|
||||
"polar": "Polar",
|
||||
"nightscout": "Nightscout",
|
||||
"fitbit": "Fitbit"
|
||||
"fitbit": "Fitbit",
|
||||
"appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health."
|
||||
},
|
||||
"import": "Importuj istniejącą historię"
|
||||
},
|
||||
@@ -4719,7 +4709,6 @@
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"allTime": "Cały okres",
|
||||
"showEvidence": "Domyślnie pokazuj źródła",
|
||||
"excludedMetrics": "Wykluczone metryki",
|
||||
"dataClusters": "Grupy danych",
|
||||
"customClusters": "Użyj własnego wyboru grup",
|
||||
@@ -4834,8 +4823,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit",
|
||||
"description": "Układ i kolejność kafelków.",
|
||||
"subtitle": "Rozmieść kafelki na pulpicie."
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -4856,9 +4843,9 @@
|
||||
"resetDefaultsConfirm": "Zresetuj kolejność",
|
||||
"moveUp": "W górę",
|
||||
"moveDown": "W dół",
|
||||
"saveSuccess": "Zapisano priorytet źródeł.",
|
||||
"saveSuccess": "Zapisano priorytet źródeł",
|
||||
"saveError": "Zapisanie priorytetu źródeł nie powiodło się.",
|
||||
"resetSuccess": "Przywrócono domyślny priorytet źródeł.",
|
||||
"resetSuccess": "Przywrócono domyślny priorytet źródeł",
|
||||
"perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})",
|
||||
"perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.",
|
||||
"perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.",
|
||||
@@ -5002,7 +4989,14 @@
|
||||
"unpacking": "Rozpakowywanie archiwum…",
|
||||
"parsing": "Odczytywanie eksportu…",
|
||||
"upserting": "Importowanie rekordów…"
|
||||
}
|
||||
},
|
||||
"skipUnsupported": "{count} próbek pominięto — typy danych, których HealthLog nie importuje.",
|
||||
"skipRefused": "{count} próbek nie udało się zapisać (nieczytelne, poza zakresem lub zapis się nie powiódł).",
|
||||
"skipBreakdown": "Pokaż zestawienie ({count} typów)",
|
||||
"skipBreakdownMore": "+ {count} kolejnych typów",
|
||||
"cycleSummary": "{samples} próbek cyklu połączono w {days} dzienników dziennych.",
|
||||
"cycleModuleOff": "{count} próbek cyklu pominięto — śledzenie cyklu jest wyłączone dla tego konta.",
|
||||
"cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}"
|
||||
},
|
||||
"json": {
|
||||
"title": "Plik JSON",
|
||||
@@ -5020,7 +5014,7 @@
|
||||
"failed": "Import nie powiódł się. Spróbuj ponownie.",
|
||||
"readFailed": "Nie można odczytać tego pliku.",
|
||||
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
|
||||
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.",
|
||||
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju",
|
||||
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
|
||||
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
|
||||
},
|
||||
@@ -5044,10 +5038,10 @@
|
||||
"previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.",
|
||||
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
|
||||
"rowError": "Wiersz {line}: {reason}",
|
||||
"resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.",
|
||||
"resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}",
|
||||
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.",
|
||||
"resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.",
|
||||
"previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.",
|
||||
"previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych",
|
||||
"previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.",
|
||||
"skipGroupOne": "Pominięto {count} wiersz: {reason}",
|
||||
"skipGroupFew": "Pominięto {count} wiersze: {reason}",
|
||||
@@ -5138,38 +5132,26 @@
|
||||
"subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych."
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analizy",
|
||||
"description": "Kolejność przeglądu i pigułek nawigacyjnych.",
|
||||
"subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet."
|
||||
"subtitle": "Rozmieść sekcje przeglądu i kolejność chipów."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
"description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.",
|
||||
"subtitle": "Zarządzaj widokiem i kolejnością listy leków."
|
||||
},
|
||||
"mood": {
|
||||
"title": "Tagi nastroju",
|
||||
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
|
||||
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"description": "Widok, sortowanie i biomarkery Twoich wyników badań.",
|
||||
"subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami."
|
||||
},
|
||||
"illness": {
|
||||
"title": "Dziennik chorób",
|
||||
"description": "Widok i kolejność Twoich epizodów chorobowych.",
|
||||
"subtitle": "Zarządzaj widokiem i kolejnością epizodów."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Badania",
|
||||
"description": "Widok, kolejność i poszczególne przypomnienia o badaniach.",
|
||||
"subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
|
||||
},
|
||||
"modules": {
|
||||
"title": "Moduły",
|
||||
"saved": "Zapisano.",
|
||||
"saved": "Zapisano",
|
||||
"error": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"toggleable": {
|
||||
"title": "Co śledzisz",
|
||||
@@ -5217,7 +5199,7 @@
|
||||
"use": "Użyj",
|
||||
"select": "Wybierz",
|
||||
"saveError": "Nie udało się zapisać zmiany.",
|
||||
"homeSaved": "Zapisano lokalizację domową.",
|
||||
"homeSaved": "Zapisano lokalizację domową",
|
||||
"travelAdded": "Dodano okres lokalizacji.",
|
||||
"travelRemoved": "Usunięto okres lokalizacji.",
|
||||
"backfillQueued": "Zaplanowano uzupełnianie.",
|
||||
@@ -5274,7 +5256,7 @@
|
||||
"current": "Twój zapisany wybór"
|
||||
},
|
||||
"save": "Zapisz",
|
||||
"saved": "Zapisano.",
|
||||
"saved": "Zapisano",
|
||||
"error": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"loadFailed": "Nie udało się wczytać Twojego wyboru.",
|
||||
"eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.",
|
||||
@@ -5312,7 +5294,7 @@
|
||||
"auto": "Automatycznie (język)",
|
||||
"h24": "24-godzinny",
|
||||
"h12": "12-godzinny (AM/PM)",
|
||||
"saved": "Zapisano format godziny.",
|
||||
"saved": "Zapisano format godziny",
|
||||
"saveError": "Nie udało się zapisać formatu godziny."
|
||||
},
|
||||
"dateFormat": {
|
||||
@@ -5322,7 +5304,7 @@
|
||||
"dmy": "DD.MM.RRRR",
|
||||
"mdy": "MM/DD/RRRR",
|
||||
"ymd": "RRRR-MM-DD",
|
||||
"saved": "Zapisano format daty.",
|
||||
"saved": "Zapisano format daty",
|
||||
"saveError": "Nie udało się zapisać formatu daty."
|
||||
},
|
||||
"username": "Nazwa użytkownika",
|
||||
@@ -5616,7 +5598,7 @@
|
||||
"clearBody": "Usuwa tylko tekst notatki. Alergie i nietolerancje pozostają bez zmian.",
|
||||
"clearConfirm": "Wyczyść notatkę",
|
||||
"save": "Zapisz",
|
||||
"savedToast": "Notatka „O mnie” zapisana.",
|
||||
"savedToast": "Notatka „O mnie” zapisana",
|
||||
"clearedToast": "Notatka „O mnie” usunięta.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"loadError": "Nie udało się wczytać notatki.",
|
||||
@@ -5638,14 +5620,14 @@
|
||||
"title": "Automatyczne czytanie dokumentów przez AI",
|
||||
"subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
|
||||
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.",
|
||||
"confirm": "Rozumiem — włącz automatyczne czytanie przez AI",
|
||||
"confirm": "Rozumiem — włącz",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Użyj wspólnego dostępu AI serwera",
|
||||
"subLabel": "Kieruj zapytania AI przez połączenie skonfigurowane przez operatora dla tego serwera, obok Twoich własnych dostawców jako rozwiązanie awaryjne.",
|
||||
"honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.",
|
||||
"confirm": "Rozumiem — użyj wspólnego połączenia",
|
||||
"confirm": "Rozumiem — użyj połączenia",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"consent": {
|
||||
@@ -5667,7 +5649,7 @@
|
||||
"description": "Wybierz sposób wyświetlania odległości i prędkości na wykresach i kafelkach.",
|
||||
"metric": "Metryczne",
|
||||
"imperial": "Imperialne",
|
||||
"saved": "Zapisano preferencję jednostek.",
|
||||
"saved": "Zapisano preferencję jednostek",
|
||||
"saveError": "Nie udało się zapisać preferencji jednostek."
|
||||
}
|
||||
},
|
||||
@@ -5855,7 +5837,7 @@
|
||||
"errorReconnect": "Błąd — połącz ponownie",
|
||||
"warningServerError": "Synchronizacja nie działa",
|
||||
"failureCount": "{count}/{threshold}",
|
||||
"parkedReconnect": "Wstrzymane — połącz ręcznie",
|
||||
"parkedReconnect": "Wstrzymane — połącz ponownie",
|
||||
"notConnected": "Nie połączono",
|
||||
"justNow": "przed chwilą",
|
||||
"minutesAgo": "{count} min temu",
|
||||
@@ -5939,6 +5921,7 @@
|
||||
"repository": "Kod źródłowy",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
},
|
||||
@@ -5966,7 +5949,7 @@
|
||||
},
|
||||
"notificationStatus": {
|
||||
"title": "Niezawodność kanałów",
|
||||
"description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.",
|
||||
"description": "Stan każdego kanału — „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.",
|
||||
"emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.",
|
||||
"stateActive": "Aktywne",
|
||||
"stateAutoDisabled": "Automatycznie wyłączone",
|
||||
@@ -6032,7 +6015,7 @@
|
||||
"expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.",
|
||||
"create": "Utwórz link",
|
||||
"tokenCreated": "Link udostępniania utworzony",
|
||||
"copied": "Skopiowano do schowka.",
|
||||
"copied": "Skopiowano do schowka",
|
||||
"activeTitle": "Aktywne linki",
|
||||
"noActive": "Brak aktywnych linków udostępniania.",
|
||||
"created": "Utworzono",
|
||||
@@ -6184,7 +6167,7 @@
|
||||
"revokeSession": "Wyloguj",
|
||||
"revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.",
|
||||
"signOutEverywhere": "Wyloguj na pozostałych urządzeniach",
|
||||
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.",
|
||||
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e",
|
||||
"activityTitle": "Aktywność zabezpieczeń",
|
||||
"activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.",
|
||||
"activityEmpty": "Brak aktywności zabezpieczeń.",
|
||||
@@ -6265,7 +6248,7 @@
|
||||
"revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.",
|
||||
"revoke": "Cofnij",
|
||||
"revokeAll": "Zapomnij wszystkie urządzenia",
|
||||
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
|
||||
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a",
|
||||
"unnamed": "Nieznane urządzenie",
|
||||
"current": "To urządzenie",
|
||||
"expires": "Zaufane do {date}",
|
||||
@@ -6329,8 +6312,8 @@
|
||||
"endpointLabel": "Adres URL łącznika",
|
||||
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
|
||||
"tokensTitle": "Tokeny łącznika",
|
||||
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta.",
|
||||
"scopeNote": "Domyślnie tokeny mają dostęp tylko do odczytu własnych danych. Dostęp do zapisu jest opcjonalny i włączany dla każdego tokena przełącznikiem poniżej. Tokeny nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
|
||||
"tokensDescription": "Wygeneruj token dla asystenta — tylko odczyt, chyba że pozwolisz poniżej na zapis.",
|
||||
"scopeNote": "Nowe tokeny mogą tylko odczytywać Twoje dane. Dostęp do zapisu (health:write) przyznajesz dla każdego tokena przełącznikiem poniżej. Token nigdy nie może uzyskać dostępu do funkcji administracyjnych.",
|
||||
"writeScopeToggleLabel": "Zezwól na zapis (health:write)",
|
||||
"writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.",
|
||||
"connectionsTitle": "Połączeni asystenci",
|
||||
@@ -6386,6 +6369,13 @@
|
||||
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
|
||||
"importAction": "Otwórz jednorazowy import",
|
||||
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
|
||||
"progress": {
|
||||
"title": "Postęp synchronizacji",
|
||||
"receivedLabel": "Odebrane rekordy",
|
||||
"oldestLabel": "Najstarszy osiągnięty pomiar",
|
||||
"flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.",
|
||||
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Dostarczanie",
|
||||
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
|
||||
@@ -6590,13 +6580,13 @@
|
||||
"maxUsesLabel": "Maksymalna liczba użyć",
|
||||
"maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).",
|
||||
"createConfirm": "Utwórz zaproszenie",
|
||||
"createdToast": "Zaproszenie utworzone.",
|
||||
"createdToast": "Zaproszenie utworzone",
|
||||
"createError": "Nie udało się utworzyć zaproszenia.",
|
||||
"tokenTitle": "Link z zaproszeniem",
|
||||
"tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.",
|
||||
"hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.",
|
||||
"copyLink": "Kopiuj link",
|
||||
"copiedToast": "Link skopiowany.",
|
||||
"copiedToast": "Link skopiowany",
|
||||
"copyError": "Kopiowanie nie powiodło się.",
|
||||
"qrAlt": "Kod QR linku z zaproszeniem",
|
||||
"loadError": "Nie udało się wczytać zaproszeń.",
|
||||
@@ -6890,6 +6880,23 @@
|
||||
"restoreTitle": "Przywróć z kopii zapasowej",
|
||||
"restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.",
|
||||
"restorePromptLabel": "Wpisz RESTORE, aby kontynuować",
|
||||
"previewTitle": "Zawartość kopii",
|
||||
"previewLoading": "Odczytywanie zawartości…",
|
||||
"previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.",
|
||||
"previewEmpty": "Plik nie zawiera rekordów.",
|
||||
"previewOther": "Inne rekordy",
|
||||
"previewMeasurements": "Pomiary",
|
||||
"previewMedications": "Leki i dawki",
|
||||
"previewMood": "Wpisy nastroju",
|
||||
"previewLabs": "Wyniki badań",
|
||||
"previewDocuments": "Dokumenty",
|
||||
"previewWorkouts": "Treningi",
|
||||
"previewCycles": "Dane cyklu",
|
||||
"previewVisits": "Wizyty lekarskie",
|
||||
"previewVaccinations": "Szczepienia",
|
||||
"previewReminders": "Przypomnienia profilaktyczne",
|
||||
"previewCustomMetrics": "Własne metryki",
|
||||
"previewHealthScores": "Historia wyniku",
|
||||
"restoreConfirm": "Przywróć teraz",
|
||||
"restoreInProgress": "Przywracanie...",
|
||||
"restoreSuccess": "Przywracanie zakończone",
|
||||
@@ -6905,7 +6912,17 @@
|
||||
"restoreSkippedVisitReference": "Odwołanie wizyty",
|
||||
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
|
||||
"restoreSkippedCheckupClosure": "Zamknięcie badania",
|
||||
"docsLink": "Dokumentacja"
|
||||
"restoreSkippedReminderReference": "Odwołanie do przypomnienia",
|
||||
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
|
||||
"restoreSkippedCoachReference": "Odwołanie Coacha",
|
||||
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
|
||||
"restoreSkippedExtractedFact": "Fakt z dokumentu",
|
||||
"restoreSkippedFactCommitment": "Zapis faktu",
|
||||
"docsLink": "Dokumentacja",
|
||||
"restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego",
|
||||
"restoreSkippedEcgReference": "Pochodzenie zapisu EKG",
|
||||
"restoreSkippedMedicationTarget": "Analit docelowy leku",
|
||||
"restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu"
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
@@ -7339,7 +7356,7 @@
|
||||
"saveError": "Zapis nieudany. Spróbuj ponownie.",
|
||||
"hourLabel": "Godzina przypomnienia",
|
||||
"hourAria": "Godzina przypomnienia o nastroju",
|
||||
"hourSavedToast": "Godzina przypomnienia zaktualizowana.",
|
||||
"hourSavedToast": "Godzina przypomnienia zaktualizowana",
|
||||
"detail": "Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój."
|
||||
},
|
||||
"coachNudge": {
|
||||
@@ -7380,8 +7397,8 @@
|
||||
"leadLabel": "Czas na ponowne zamówienie (dni)",
|
||||
"leadAria": "Czas na ponowne zamówienie w dniach",
|
||||
"leadHelp": "Alert pojawia się o tyle dni plus jeden interwał dawki wcześniej, aby nowy zapas dotarł przed Twoją ostatnią dawką. Pojedynczy lek może nadpisać tę wartość na swojej karcie zapasu.",
|
||||
"leadSavedToast": "Czas na ponowne zamówienie zapisany.",
|
||||
"savedToast": "Próg zapasu zapisany.",
|
||||
"leadSavedToast": "Czas na ponowne zamówienie zapisany",
|
||||
"savedToast": "Próg zapasu zapisany",
|
||||
"enabledToast": "Alerty niskiego zapasu włączone.",
|
||||
"disabledToast": "Alerty niskiego zapasu wyłączone.",
|
||||
"saveError": "Zapis nieudany. Spróbuj ponownie.",
|
||||
@@ -8041,10 +8058,10 @@
|
||||
"protected": "Czy podczas stosunku zastosowano antykoncepcję.",
|
||||
"protectedLabel": "Co oznacza zabezpieczony?"
|
||||
},
|
||||
"saveSuccess": "Wpis zapisany.",
|
||||
"deleteSuccess": "Wpis usunięty.",
|
||||
"periodStartSaved": "Początek miesiączki zapisany.",
|
||||
"periodEndSaved": "Koniec miesiączki zapisany.",
|
||||
"saveSuccess": "Wpis zapisany",
|
||||
"deleteSuccess": "Wpis usunięty",
|
||||
"periodStartSaved": "Początek miesiączki zapisany",
|
||||
"periodEndSaved": "Koniec miesiączki zapisany",
|
||||
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
|
||||
},
|
||||
@@ -8084,6 +8101,23 @@
|
||||
"delete": "Usuń",
|
||||
"deleteConfirmTitle": "Usunąć przypomnienie?",
|
||||
"deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.",
|
||||
"postpone": {
|
||||
"menuItem": "Przełóż…",
|
||||
"title": "Przełóż termin",
|
||||
"description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.",
|
||||
"chip": "+{days} dni",
|
||||
"dateLabel": "Nowa data",
|
||||
"confirm": "Przełóż"
|
||||
},
|
||||
"skip": {
|
||||
"heading": "Pomiń ten termin",
|
||||
"explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.",
|
||||
"action": "Pomiń",
|
||||
"confirmTitle": "Pominąć ten termin?"
|
||||
},
|
||||
"snoozedUntil": "Przełożono do {date}",
|
||||
"skippedOn": "Pominięto {date}",
|
||||
"skipCount": "pominięto {count} razy",
|
||||
"disabledBadge": "Wył.",
|
||||
"nextDue": {
|
||||
"none": "Niezaplanowane",
|
||||
@@ -8933,7 +8967,8 @@
|
||||
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
|
||||
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
|
||||
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
@@ -9034,7 +9069,9 @@
|
||||
"flaggedBadge": "Pokazano wsparcie",
|
||||
"chartTitle": "Przebieg wyniku",
|
||||
"totalLabel": "Wynik",
|
||||
"bandLabel": "Kategoria"
|
||||
"bandLabel": "Kategoria",
|
||||
"answersToggle": "Odpowiedzi",
|
||||
"answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Nie musisz mierzyć się z tym sam(a)",
|
||||
@@ -9305,7 +9342,7 @@
|
||||
"freeText": {
|
||||
"label": "Notatka tekstowa (alergie i nietolerancje)",
|
||||
"hint": "Tekstowe uzupełnienie ustrukturyzowanej listy powyżej, na kontekst, którego pojedynczy wpis nie odda.",
|
||||
"savedToast": "Notatka o alergiach zapisana.",
|
||||
"savedToast": "Notatka o alergiach zapisana",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
@@ -9364,7 +9401,7 @@
|
||||
"conditionsLabel": "Choroby przewlekłe",
|
||||
"focusLabel": "Na co zwraca uwagę Coach",
|
||||
"loadError": "Nie udało się wczytać Twoich chorób.",
|
||||
"savedToast": "Choroby zapisane.",
|
||||
"savedToast": "Choroby zapisane",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
},
|
||||
"emergency": {
|
||||
@@ -9468,7 +9505,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"duplicate": "Już zapisano — istniejący dokument został wyróżniony.",
|
||||
"deleted": "Dokument usunięty.",
|
||||
"deleted": "Dokument usunięty",
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu."
|
||||
},
|
||||
"timeline": {
|
||||
@@ -9641,9 +9678,9 @@
|
||||
"unverifiedNoteOther": "{count} wartości wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.",
|
||||
"finish": "Zakończ przegląd",
|
||||
"confirmFailed": "Nie udało się zapisać przeglądu. Spróbuj ponownie.",
|
||||
"savedToastOne": "Zapisano 1 wartość.",
|
||||
"savedToastFew": "Zapisano {count} wartości.",
|
||||
"savedToastOther": "Zapisano {count} wartości.",
|
||||
"savedToastOne": "Zapisano 1 wartość",
|
||||
"savedToastFew": "Zapisano {count} wartości",
|
||||
"savedToastOther": "Zapisano {count} wartości",
|
||||
"nothingSavedToast": "Przegląd zapisany. Nie dodano żadnych wartości.",
|
||||
"extract": "Odczytaj wyniki laboratoryjne",
|
||||
"extracting": "Odczytywanie…",
|
||||
@@ -9738,6 +9775,8 @@
|
||||
"editedToast": "Zaktualizowano wartość",
|
||||
"editError": "Nie udało się zaktualizować wartości",
|
||||
"deletedToast": "Usunięto wartość",
|
||||
"restoredToast": "Przywrócono wartość",
|
||||
"restoreError": "Nie udało się przywrócić",
|
||||
"deleteError": "Nie udało się usunąć wartości"
|
||||
},
|
||||
"chart": {
|
||||
@@ -10184,7 +10223,7 @@
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie."
|
||||
},
|
||||
"toast": {
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}."
|
||||
"savedTo": "Zapisano w karcie użytkownika {name}"
|
||||
},
|
||||
"activityVerb": {
|
||||
"measurementCreate": "{name} dodał pomiar",
|
||||
@@ -10207,6 +10246,8 @@
|
||||
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
|
||||
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
|
||||
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
|
||||
"measurementReminderSkip": "{name} pominął(-ęła) przypomnienie o pomiarze",
|
||||
"measurementReminderSnooze": "{name} przełożył(-yła) przypomnienie o pomiarze",
|
||||
"labResultUpdate": "{name} zmienił wynik badania",
|
||||
"labResultDelete": "{name} usunął wynik badania",
|
||||
"labResultRestore": "{name} przywrócił wynik badania",
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.37.17",
|
||||
"version": "1.37.26",
|
||||
"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",
|
||||
@@ -84,7 +84,7 @@
|
||||
"nodemailer": "9.0.3",
|
||||
"otpauth": "^9.5.1",
|
||||
"p-limit": "^7.3.1",
|
||||
"pg-boss": "^12.26.0",
|
||||
"pg-boss": "^12.27.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.2",
|
||||
@@ -109,9 +109,9 @@
|
||||
"@axe-core/playwright": "^4.12.1",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@openai/codex-security": "^0.1.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^12.0.3",
|
||||
"@testcontainers/postgresql": "^12.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
@@ -128,11 +128,11 @@
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "6.2.108",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"shadcn": "^4.16.1",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^12.0.3",
|
||||
"testcontainers": "^12.1.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
Generated
+158
-170
@@ -16,6 +16,7 @@ overrides:
|
||||
ip-address@<10.3.1: ^10.3.1
|
||||
sharp@<0.35.0: ^0.35.3
|
||||
pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108
|
||||
deepmerge-ts@<8.0.0: ^8.0.0
|
||||
|
||||
importers:
|
||||
|
||||
@@ -101,7 +102,7 @@ importers:
|
||||
version: 3.0.2
|
||||
next:
|
||||
specifier: 16.2.11
|
||||
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.60.0)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -115,8 +116,8 @@ importers:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1
|
||||
pg-boss:
|
||||
specifier: ^12.26.0
|
||||
version: 12.26.0
|
||||
specifier: ^12.27.0
|
||||
version: 12.27.0
|
||||
prisma:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
@@ -177,7 +178,7 @@ importers:
|
||||
devDependencies:
|
||||
'@axe-core/playwright':
|
||||
specifier: ^4.12.1
|
||||
version: 4.12.1(playwright-core@1.60.0)
|
||||
version: 4.12.1(playwright-core@1.62.1)
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.2.12
|
||||
version: 16.2.12
|
||||
@@ -185,14 +186,14 @@ importers:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1(@types/node@26.1.1)(supports-color@7.2.0)
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
specifier: ^1.62.1
|
||||
version: 1.62.1
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
version: 4.3.0
|
||||
'@testcontainers/postgresql':
|
||||
specifier: ^12.0.3
|
||||
version: 12.0.3(supports-color@7.2.0)
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0(supports-color@7.2.0)
|
||||
'@types/node':
|
||||
specifier: 26.1.1
|
||||
version: 26.1.1
|
||||
@@ -242,11 +243,11 @@ importers:
|
||||
specifier: ^8.22.0
|
||||
version: 8.22.0
|
||||
prettier:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1
|
||||
specifier: ^3.9.6
|
||||
version: 3.9.6
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.8.1
|
||||
version: 0.8.1(prettier@3.9.1)
|
||||
version: 0.8.1(prettier@3.9.6)
|
||||
shadcn:
|
||||
specifier: ^4.16.1
|
||||
version: 4.16.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(typescript@6.0.3)
|
||||
@@ -254,8 +255,8 @@ importers:
|
||||
specifier: ^4
|
||||
version: 4.3.3
|
||||
testcontainers:
|
||||
specifier: ^12.0.3
|
||||
version: 12.0.3(supports-color@7.2.0)
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0(supports-color@7.2.0)
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
@@ -1933,9 +1934,9 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
engines: {node: '>=18'}
|
||||
'@playwright/test@1.62.1':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
@@ -2025,17 +2026,14 @@ packages:
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.2':
|
||||
resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
'@protobufjs/utf8@1.1.2':
|
||||
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
||||
|
||||
'@radix-ui/number@1.1.2':
|
||||
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
|
||||
@@ -3085,8 +3083,8 @@ packages:
|
||||
'@tanstack/virtual-core@3.17.3':
|
||||
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
|
||||
|
||||
'@testcontainers/postgresql@12.0.3':
|
||||
resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==}
|
||||
'@testcontainers/postgresql@12.1.0':
|
||||
resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==}
|
||||
|
||||
'@toon-format/toon@2.3.1':
|
||||
resolution: {integrity: sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw==}
|
||||
@@ -3635,32 +3633,28 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
bare-events@2.8.3:
|
||||
resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==}
|
||||
bare-events@2.9.1:
|
||||
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.7.1:
|
||||
resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
bare-fs@4.8.0:
|
||||
resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==}
|
||||
engines: {bare: '>=1.28.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-os@3.9.1:
|
||||
resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
|
||||
engines: {bare: '>=1.14.0'}
|
||||
bare-path@3.1.1:
|
||||
resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==}
|
||||
|
||||
bare-path@3.0.0:
|
||||
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
|
||||
|
||||
bare-stream@2.13.1:
|
||||
resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
|
||||
bare-stream@2.13.3:
|
||||
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
@@ -3673,8 +3667,8 @@ packages:
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.4.3:
|
||||
resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==}
|
||||
bare-url@2.5.2:
|
||||
resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
@@ -3724,11 +3718,8 @@ packages:
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
|
||||
brace-expansion@2.1.4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
@@ -3966,10 +3957,9 @@ packages:
|
||||
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
cron-parser@5.6.2:
|
||||
resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==}
|
||||
cron-parser@5.10.0:
|
||||
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: 'Published tarball was built from a stale dist/ and is missing the fixes from #414 and #415 — its contents are effectively v5.6.1. Upgrade to v5.7.0.'
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
@@ -4090,8 +4080,8 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
deepmerge-ts@7.1.5:
|
||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
||||
deepmerge-ts@8.0.1:
|
||||
resolution: {integrity: sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
deepmerge@4.3.1:
|
||||
@@ -4158,8 +4148,8 @@ packages:
|
||||
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
|
||||
engines: {node: '>= 8.0'}
|
||||
|
||||
dockerode@5.0.0:
|
||||
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
|
||||
dockerode@5.0.1:
|
||||
resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==}
|
||||
engines: {node: '>= 14.17'}
|
||||
|
||||
doctrine@2.1.0:
|
||||
@@ -4673,9 +4663,9 @@ packages:
|
||||
get-port-please@3.2.0:
|
||||
resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==}
|
||||
|
||||
get-port@7.2.0:
|
||||
resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==}
|
||||
engines: {node: '>=16'}
|
||||
get-port@5.1.1:
|
||||
resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
@@ -5554,8 +5544,8 @@ packages:
|
||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
nan@2.27.0:
|
||||
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
|
||||
nan@2.28.0:
|
||||
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
||||
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
@@ -5850,8 +5840,8 @@ packages:
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
pg-boss@12.26.0:
|
||||
resolution: {integrity: sha512-qMLyokiG2/Ey8U3sptl0We4nRGr1iveHL/lQ7g6n7cI/ubp9HVJZJnfPf6B9jzAbpd55uSdltKL/DMkyBC0ynw==}
|
||||
pg-boss@12.27.0:
|
||||
resolution: {integrity: sha512-DawcUffyDVJvxGYvaoc7B+G1yY86dJtvugVYMAt+CtO6Lg+RSyQuAU7IRfNyhqi0Bo/JeaxLqhqIy5+twpmYlg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5876,6 +5866,9 @@ packages:
|
||||
pg-protocol@1.15.0:
|
||||
resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==}
|
||||
|
||||
pg-protocol@1.16.0:
|
||||
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5889,6 +5882,15 @@ packages:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pg@8.23.0:
|
||||
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
@@ -5914,14 +5916,14 @@ packages:
|
||||
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
playwright-core@1.62.1:
|
||||
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
playwright@1.62.1:
|
||||
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
pngjs@5.0.0:
|
||||
@@ -6035,8 +6037,8 @@ packages:
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
prettier@3.9.1:
|
||||
resolution: {integrity: sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==}
|
||||
prettier@3.9.6:
|
||||
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
@@ -6078,8 +6080,8 @@ packages:
|
||||
resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
protobufjs@7.6.2:
|
||||
resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==}
|
||||
protobufjs@7.6.5:
|
||||
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
@@ -6535,8 +6537,8 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
streamx@2.26.0:
|
||||
resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==}
|
||||
streamx@2.28.0:
|
||||
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
|
||||
|
||||
strict-event-emitter@0.5.1:
|
||||
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
|
||||
@@ -6662,11 +6664,11 @@ packages:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
|
||||
tar-fs@2.1.5:
|
||||
resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
|
||||
tar-fs@3.1.3:
|
||||
resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
@@ -6684,8 +6686,9 @@ packages:
|
||||
tesseract.js@7.0.0:
|
||||
resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==}
|
||||
|
||||
testcontainers@12.0.3:
|
||||
resolution: {integrity: sha512-6vfiL9CWIX0rWhTJitzrzHcv4Q/sUfUdBV12jMz6HZ58Lz6ZtXVecO6jljd1O3ookEWQHNICiYoYNEPpDLzuRw==}
|
||||
testcontainers@12.1.0:
|
||||
resolution: {integrity: sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==}
|
||||
engines: {node: '>= 22.22'}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
@@ -6842,10 +6845,6 @@ packages:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
undici@8.9.0:
|
||||
resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unicorn-magic@0.3.0:
|
||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7142,10 +7141,6 @@ packages:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.3:
|
||||
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -7438,10 +7433,10 @@ snapshots:
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4': {}
|
||||
|
||||
'@axe-core/playwright@4.12.1(playwright-core@1.60.0)':
|
||||
'@axe-core/playwright@4.12.1(playwright-core@1.62.1)':
|
||||
dependencies:
|
||||
axe-core: 4.12.1
|
||||
playwright-core: 1.60.0
|
||||
playwright-core: 1.62.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
@@ -7900,15 +7895,15 @@ snapshots:
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.6.2
|
||||
yargs: 17.7.2
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
|
||||
'@grpc/proto-loader@0.8.1':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.6.2
|
||||
yargs: 17.7.2
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
@@ -8848,9 +8843,9 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
'@playwright/test@1.62.1':
|
||||
dependencies:
|
||||
playwright: 1.60.0
|
||||
playwright: 1.62.1
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
@@ -8875,7 +8870,7 @@ snapshots:
|
||||
'@prisma/config@7.8.0(magicast@0.5.2)':
|
||||
dependencies:
|
||||
c12: 3.3.4(magicast@0.5.2)
|
||||
deepmerge-ts: 7.1.5
|
||||
deepmerge-ts: 8.0.1
|
||||
effect: 3.20.0
|
||||
empathic: 2.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -8969,13 +8964,11 @@ snapshots:
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.2': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
'@protobufjs/utf8@1.1.2': {}
|
||||
|
||||
'@radix-ui/number@1.1.2': {}
|
||||
|
||||
@@ -10003,9 +9996,9 @@ snapshots:
|
||||
|
||||
'@tanstack/virtual-core@3.17.3': {}
|
||||
|
||||
'@testcontainers/postgresql@12.0.3(supports-color@7.2.0)':
|
||||
'@testcontainers/postgresql@12.1.0(supports-color@7.2.0)':
|
||||
dependencies:
|
||||
testcontainers: 12.0.3(supports-color@7.2.0)
|
||||
testcontainers: 12.1.0(supports-color@7.2.0)
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
@@ -10593,37 +10586,34 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bare-events@2.8.3: {}
|
||||
bare-events@2.9.1: {}
|
||||
|
||||
bare-fs@4.7.1:
|
||||
bare-fs@4.8.0:
|
||||
dependencies:
|
||||
bare-events: 2.8.3
|
||||
bare-path: 3.0.0
|
||||
bare-stream: 2.13.1(bare-events@2.8.3)
|
||||
bare-url: 2.4.3
|
||||
bare-events: 2.9.1
|
||||
bare-path: 3.1.1
|
||||
bare-stream: 2.13.3(bare-events@2.9.1)
|
||||
bare-url: 2.5.2
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-os@3.9.1: {}
|
||||
bare-path@3.1.1: {}
|
||||
|
||||
bare-path@3.0.0:
|
||||
bare-stream@2.13.3(bare-events@2.9.1):
|
||||
dependencies:
|
||||
bare-os: 3.9.1
|
||||
|
||||
bare-stream@2.13.1(bare-events@2.8.3):
|
||||
dependencies:
|
||||
streamx: 2.26.0
|
||||
b4a: 1.8.1
|
||||
streamx: 2.28.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.8.3
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.4.3:
|
||||
bare-url@2.5.2:
|
||||
dependencies:
|
||||
bare-path: 3.0.0
|
||||
bare-path: 3.1.1
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
optional: true
|
||||
@@ -10678,11 +10668,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.1:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@2.1.2:
|
||||
brace-expansion@2.1.4:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -10910,7 +10896,7 @@ snapshots:
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
buildcheck: 0.0.7
|
||||
nan: 2.27.0
|
||||
nan: 2.28.0
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
@@ -10920,7 +10906,7 @@ snapshots:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
cron-parser@5.6.2:
|
||||
cron-parser@5.10.0:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
@@ -11025,7 +11011,7 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
deepmerge-ts@8.0.1: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
@@ -11081,14 +11067,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
dockerode@5.0.0(supports-color@7.2.0):
|
||||
dockerode@5.0.1(supports-color@7.2.0):
|
||||
dependencies:
|
||||
'@balena/dockerignore': 1.0.2
|
||||
'@grpc/grpc-js': 1.14.4
|
||||
'@grpc/proto-loader': 0.7.15
|
||||
docker-modem: 5.0.7(supports-color@7.2.0)
|
||||
protobufjs: 7.6.2
|
||||
tar-fs: 2.1.4
|
||||
protobufjs: 7.6.5
|
||||
tar-fs: 2.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -11532,7 +11518,7 @@ snapshots:
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.8.3
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
@@ -11827,7 +11813,7 @@ snapshots:
|
||||
|
||||
get-port-please@3.2.0: {}
|
||||
|
||||
get-port@7.2.0: {}
|
||||
get-port@5.1.1: {}
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -12565,11 +12551,11 @@ snapshots:
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.1
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.2
|
||||
brace-expansion: 2.1.4
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -12629,7 +12615,7 @@ snapshots:
|
||||
dependencies:
|
||||
lru.min: 1.1.4
|
||||
|
||||
nan@2.27.0:
|
||||
nan@2.28.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
@@ -12645,7 +12631,7 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.60.0)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@next/env': 16.2.11
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12664,7 +12650,7 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.2.11
|
||||
'@next/swc-win32-arm64-msvc': 16.2.11
|
||||
'@next/swc-win32-x64-msvc': 16.2.11
|
||||
'@playwright/test': 1.60.0
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.3(@types/node@26.1.1)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -12957,10 +12943,10 @@ snapshots:
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
pg-boss@12.26.0:
|
||||
pg-boss@12.27.0:
|
||||
dependencies:
|
||||
cron-parser: 5.6.2
|
||||
pg: 8.22.0
|
||||
cron-parser: 5.10.0
|
||||
pg: 8.23.0
|
||||
serialize-error: 13.0.1
|
||||
transitivePeerDependencies:
|
||||
- pg-native
|
||||
@@ -12976,10 +12962,16 @@ snapshots:
|
||||
dependencies:
|
||||
pg: 8.22.0
|
||||
|
||||
pg-pool@3.14.0(pg@8.23.0):
|
||||
dependencies:
|
||||
pg: 8.23.0
|
||||
|
||||
pg-protocol@1.13.0: {}
|
||||
|
||||
pg-protocol@1.15.0: {}
|
||||
|
||||
pg-protocol@1.16.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
@@ -12998,6 +12990,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pg@8.23.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.14.0
|
||||
pg-pool: 3.14.0(pg@8.23.0)
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
@@ -13020,11 +13022,11 @@ snapshots:
|
||||
dependencies:
|
||||
find-up: 3.0.0
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
playwright-core@1.62.1: {}
|
||||
|
||||
playwright@1.60.0:
|
||||
playwright@1.62.1:
|
||||
dependencies:
|
||||
playwright-core: 1.60.0
|
||||
playwright-core: 1.62.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
@@ -13073,11 +13075,11 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-tailwindcss@0.8.1(prettier@3.9.1):
|
||||
prettier-plugin-tailwindcss@0.8.1(prettier@3.9.6):
|
||||
dependencies:
|
||||
prettier: 3.9.1
|
||||
prettier: 3.9.6
|
||||
|
||||
prettier@3.9.1: {}
|
||||
prettier@3.9.6: {}
|
||||
|
||||
pretty-ms@9.3.0:
|
||||
dependencies:
|
||||
@@ -13128,7 +13130,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
protobufjs@7.6.2:
|
||||
protobufjs@7.6.5:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
@@ -13136,10 +13138,9 @@ snapshots:
|
||||
'@protobufjs/eventemitter': 1.1.1
|
||||
'@protobufjs/fetch': 1.1.1
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.2
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.1
|
||||
'@protobufjs/utf8': 1.1.2
|
||||
'@types/node': 26.1.1
|
||||
long: 5.3.2
|
||||
|
||||
@@ -13737,7 +13738,7 @@ snapshots:
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.10
|
||||
nan: 2.27.0
|
||||
nan: 2.28.0
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
@@ -13759,7 +13760,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
streamx@2.26.0:
|
||||
streamx@2.28.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
@@ -13900,20 +13901,20 @@ snapshots:
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
tar-fs@2.1.5:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.4
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-fs@3.1.2:
|
||||
tar-fs@3.1.3:
|
||||
dependencies:
|
||||
pump: 3.0.4
|
||||
tar-stream: 3.2.0
|
||||
optionalDependencies:
|
||||
bare-fs: 4.7.1
|
||||
bare-path: 3.0.0
|
||||
bare-fs: 4.8.0
|
||||
bare-path: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
@@ -13930,9 +13931,9 @@ snapshots:
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
bare-fs: 4.7.1
|
||||
bare-fs: 4.8.0
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.26.0
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
@@ -13940,7 +13941,7 @@ snapshots:
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.26.0
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
@@ -13961,7 +13962,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
testcontainers@12.0.3(supports-color@7.2.0):
|
||||
testcontainers@12.1.0(supports-color@7.2.0):
|
||||
dependencies:
|
||||
'@balena/dockerignore': 1.0.2
|
||||
'@types/dockerode': 4.0.1
|
||||
@@ -13970,14 +13971,14 @@ snapshots:
|
||||
byline: 5.0.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
docker-compose: 1.4.2
|
||||
dockerode: 5.0.0(supports-color@7.2.0)
|
||||
get-port: 7.2.0
|
||||
dockerode: 5.0.1(supports-color@7.2.0)
|
||||
get-port: 5.1.1
|
||||
proper-lockfile: 4.1.2
|
||||
properties-reader: 3.0.1(supports-color@7.2.0)
|
||||
ssh-remote-port-forward: 1.0.4
|
||||
tar-fs: 3.1.2
|
||||
tar-fs: 3.1.3
|
||||
tmp: 0.2.7
|
||||
undici: 8.9.0
|
||||
undici: 8.10.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
@@ -14151,8 +14152,6 @@ snapshots:
|
||||
|
||||
undici@8.10.0: {}
|
||||
|
||||
undici@8.9.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
@@ -14458,16 +14457,6 @@ snapshots:
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yargs@17.7.3:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
@@ -14477,7 +14466,6 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
optional: true
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
|
||||
@@ -21,6 +21,12 @@ overrides:
|
||||
# `pdf-parse` resolves 5.4.296, which predates the flaw and drives the local
|
||||
# text-extraction path, so it must not be dragged onto a new major.
|
||||
"pdfjs-dist@>=5.6.83 <6.2.108": "6.2.108"
|
||||
# Deeply nested input drives DeepmergeTS into unbounded recursion until the
|
||||
# stack gives out. The copy here is Prisma's: `@prisma/config` still asks for
|
||||
# 7.1.5 even on the current 7.9.1, so no Prisma bump reaches it and the
|
||||
# override is the only way through. It runs on the config-loading and CLI
|
||||
# path, not under a request, but a red audit blocks the whole release train.
|
||||
"deepmerge-ts@<8.0.0": "^8.0.0"
|
||||
|
||||
allowBuilds:
|
||||
"@prisma/engines": true
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger.
|
||||
-- All additive; every existing reminder keeps its exact behaviour.
|
||||
|
||||
-- Snooze cursor: non-NULL and in the future = "pushed back until this
|
||||
-- instant". The snooze write sets next_due_at to the SAME instant, so every
|
||||
-- due-state consumer moves with it; expiry is the clock passing the value.
|
||||
ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3);
|
||||
|
||||
-- Last honest skip. A skip re-anchors next_due_at at the skip instant but
|
||||
-- never moves last_satisfied_at — done and skipped stay distinguishable.
|
||||
ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3);
|
||||
|
||||
-- Lifetime skip counter ("skipped N times").
|
||||
ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- The completion ledger (iOS #68). The engine is single-cursor, so before
|
||||
-- this table each satisfy overwrote the last and history was unrecoverable.
|
||||
-- Append-only: one row per satisfy / skip, written by the one satisfy
|
||||
-- primitive and the skip route. History starts with this release — the
|
||||
-- cursor holds nothing to backfill from.
|
||||
CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED');
|
||||
|
||||
CREATE TABLE "measurement_reminder_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"reminder_id" TEXT NOT NULL,
|
||||
"kind" "measurement_reminder_event_kind" NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"on_time" BOOLEAN NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx"
|
||||
ON "measurement_reminder_events"("reminder_id", "occurred_at");
|
||||
|
||||
CREATE INDEX "measurement_reminder_events_user_id_idx"
|
||||
ON "measurement_reminder_events"("user_id");
|
||||
|
||||
ALTER TABLE "measurement_reminder_events"
|
||||
ADD CONSTRAINT "measurement_reminder_events_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "measurement_reminder_events"
|
||||
ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey"
|
||||
FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Custom-metric entries gain the soft-delete tombstone every peer entry
|
||||
-- surface already has (undo parity — the one hard delete left).
|
||||
ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
+121
-8
@@ -102,6 +102,10 @@ model User {
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||||
/// @internal: dead mirror of the expiry inside the encrypted Codex creds —
|
||||
/// the refresh path reads the decrypted blob's own `expiresAt`
|
||||
/// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written)
|
||||
/// for wire/backup compatibility rather than dropped.
|
||||
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
||||
codexConnectedAt DateTime? @map("codex_connected_at")
|
||||
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
||||
@@ -879,6 +883,9 @@ model User {
|
||||
// Server computes the canonical `nextDueAt`; the cron fires through the
|
||||
// existing notification dispatcher. Soft-deleted.
|
||||
measurementReminders MeasurementReminder[]
|
||||
// v1.37.20 — completion ledger behind the reminders (iOS #68): one
|
||||
// append-only row per satisfy / skip. Cascade-deleted with the account.
|
||||
measurementReminderEvents MeasurementReminderEvent[]
|
||||
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
|
||||
// that pairs with the Vorsorge "annual blood panel" reminder so a result
|
||||
// has somewhere to land. Soft-deleted.
|
||||
@@ -1045,6 +1052,11 @@ model EnvironmentContext {
|
||||
/// that won precedence).
|
||||
lat Float
|
||||
lon Float
|
||||
/// @internal: human-readable label of the resolved location, stored as
|
||||
/// operator/debug provenance beside the coordinates; surfaces select the
|
||||
/// numeric and weather columns only, never this label. The backup selects it
|
||||
/// (`src/lib/export/environment-backup.ts`) with the rest of the row: a
|
||||
/// restored reading has to keep saying where it was read.
|
||||
locationLabel String @map("location_label")
|
||||
source EnvironmentLocationSource
|
||||
|
||||
@@ -2416,6 +2428,13 @@ model PersonalRecord {
|
||||
/// FK to the Measurement row that achieved the record. SET NULL
|
||||
/// on delete so the historical fact survives even if the
|
||||
/// underlying measurement is later removed.
|
||||
/// @internal: audit-trail provenance written by the PR detection worker; no
|
||||
/// SURFACE selects it: the record row itself carries the value, unit and
|
||||
/// achievedAt a reader sees. The backup does select it
|
||||
/// (`src/lib/export/awards-backup.ts`), because the column is a real foreign
|
||||
/// key in the database even though no relation is declared here, so a
|
||||
/// restore has to resolve it against the measurements it wrote instead of
|
||||
/// writing it blind and failing the constraint.
|
||||
sourceMeasurementId String? @map("source_measurement_id")
|
||||
source MeasurementSource @default(MANUAL)
|
||||
externalId String? @map("external_id")
|
||||
@@ -3581,10 +3600,16 @@ model McpOAuthConnection {
|
||||
model InviteToken {
|
||||
id String @id @default(cuid())
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// Consumed through the `creator` relation: the admin invites list
|
||||
/// (`src/app/api/admin/invites/route.ts`) selects the creating admin's
|
||||
/// id + username via it.
|
||||
createdBy String @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// LAST consumer only (overwritten on multi-use invites; the full history
|
||||
/// lives in `InviteRedemption`). Consumed through the `consumer` relation
|
||||
/// by the admin invites list (`src/app/api/admin/invites/route.ts`).
|
||||
usedBy String? @map("used_by")
|
||||
maxUses Int @default(1) @map("max_uses")
|
||||
uses Int @default(0)
|
||||
@@ -3641,8 +3666,11 @@ model RefreshToken {
|
||||
/// When this token was used (one-time-use). Set during rotation; a second
|
||||
/// use of the same token is a hard fail and triggers reuse-detection.
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// Pointer to the row that replaced this one during rotation. Helps audit
|
||||
/// trail and reuse-detection ("this token was already exchanged into X").
|
||||
/// Pointer to the row that replaced this one during rotation ("this token
|
||||
/// was already exchanged into X").
|
||||
/// @internal: audit-trail bookkeeping written by the rotation path
|
||||
/// (`src/lib/auth/refresh-token.ts`); reuse-detection keys on
|
||||
/// `usedAt`/`tokenHash`, so no code path reads this pointer.
|
||||
replacedById String? @map("replaced_by_id")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
@@ -4920,12 +4948,18 @@ model ImportJob {
|
||||
failureReason String? @map("failure_reason")
|
||||
/// Bytes uploaded — surfaced to the operator audit trail.
|
||||
uploadBytes Int @map("upload_bytes")
|
||||
/// SHA-256 hex digest of the uploaded `export.zip`. Lets the
|
||||
/// kick-off endpoint short-circuit a re-upload of the exact same
|
||||
/// file back to the existing job id without re-queueing.
|
||||
/// SHA-256 hex digest of the uploaded `export.zip`. Consumer: both kick-off
|
||||
/// endpoints (`src/app/api/import/apple-health-export/route.ts` and the
|
||||
/// admin variant) dedupe on (userId, uploadSha256, parserRevision) and
|
||||
/// short-circuit a re-upload of the exact same file back to the existing
|
||||
/// job id without re-queueing. A prior `failed` job is excluded from the
|
||||
/// match (issue #486), so the same bytes can always be retried.
|
||||
uploadSha256 String? @map("upload_sha256")
|
||||
/// Parser semantics revision. Existing archives migrated at revision 1 can
|
||||
/// be deliberately reprocessed when the current parser revision advances.
|
||||
/// be deliberately reprocessed when the current parser revision advances:
|
||||
/// the kick-off dedupe lookup (see `uploadSha256`) pins the CURRENT
|
||||
/// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older
|
||||
/// revision never short-circuits a fresh upload of the same bytes.
|
||||
parserRevision Int @default(1) @map("parser_revision")
|
||||
/// Apple's `ExportDate` attribute string-formatted, parsed during
|
||||
/// the unpack phase. NULL until the parser sees it.
|
||||
@@ -5187,6 +5221,20 @@ model MeasurementReminder {
|
||||
/// excluded everywhere), whereas a booster is the engine's native case —
|
||||
/// recurring, satisfiable, cadence-bearing.
|
||||
vaccinationAntigen String? @map("vaccination_antigen")
|
||||
/// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until
|
||||
/// this instant". The snooze write sets `nextDueAt` to the SAME instant, so
|
||||
/// every due-state consumer (digest, dashboard, cron, AI block) moves with
|
||||
/// it without knowing snooze exists. Self-expiring: once the instant passes,
|
||||
/// the clock comparison stops being true and no cleanup is needed. Any
|
||||
/// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it.
|
||||
snoozedUntil DateTime? @map("snoozed_until")
|
||||
/// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip
|
||||
/// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay
|
||||
/// distinguishable forever. "Skipped cycle" derives as
|
||||
/// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet).
|
||||
lastSkippedAt DateTime? @map("last_skipped_at")
|
||||
/// v1.37.20 — lifetime skip counter ("skipped N times").
|
||||
skipCount Int @default(0) @map("skip_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
@@ -5201,6 +5249,10 @@ model MeasurementReminder {
|
||||
/// this reminder. SetNull on the far side — deleting the reminder leaves
|
||||
/// the vaccination record intact.
|
||||
vaccinations VaccinationRecord[]
|
||||
/// v1.37.20 — the completion ledger (iOS #68): every satisfy and every
|
||||
/// skip appends one row. Cascade: the ledger has no meaning without its
|
||||
/// reminder.
|
||||
events MeasurementReminderEvent[]
|
||||
|
||||
@@index([userId, deletedAt])
|
||||
@@index([userId, nextDueAt])
|
||||
@@ -5234,6 +5286,55 @@ enum ReminderOrigin {
|
||||
@@map("reminder_origin")
|
||||
}
|
||||
|
||||
/// v1.37.20 — what a completion-ledger row records: an honest fulfilment
|
||||
/// (`SATISFIED` — a matching reading landed or the user marked it done) or an
|
||||
/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is
|
||||
/// NOT a completion and never touches `lastSatisfiedAt`).
|
||||
enum MeasurementReminderEventKind {
|
||||
SATISFIED
|
||||
SKIPPED
|
||||
|
||||
@@map("measurement_reminder_event_kind")
|
||||
}
|
||||
|
||||
/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is
|
||||
/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each
|
||||
/// satisfy overwrote the previous one and history was unrecoverable. Every
|
||||
/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip
|
||||
/// route appends one row here; nothing ever updates or deletes a row short of
|
||||
/// deleting the reminder itself. History starts at the release that ships
|
||||
/// this — the single cursor holds nothing to backfill from, which the iOS #68
|
||||
/// thread records as agreed.
|
||||
model MeasurementReminderEvent {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
reminderId String @map("reminder_id")
|
||||
kind MeasurementReminderEventKind
|
||||
/// The event instant: the satisfy instant (which may be the backdated
|
||||
/// reading's own timestamp) or the skip instant (always the server clock).
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood
|
||||
/// before the event re-anchored it: `true` when the event landed at or
|
||||
/// before the due instant (or the cadence had no computable due). Clients
|
||||
/// (web + iOS) consume this resolved value and never re-derive it — the
|
||||
/// pre-event `nextDueAt` is gone after the write, so write time is the only
|
||||
/// honest moment to answer the question.
|
||||
onTime Boolean @map("on_time")
|
||||
/// Which path produced the event — a closed set of engine call sites
|
||||
/// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`,
|
||||
/// `encounter`, `skip`), asserted in code, not free text.
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The history endpoint reads newest-first per reminder.
|
||||
@@index([reminderId, occurredAt])
|
||||
@@index([userId])
|
||||
@@map("measurement_reminder_events")
|
||||
}
|
||||
|
||||
// ─── Lab results ─────────────────────────────────────────────
|
||||
|
||||
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
|
||||
@@ -5432,6 +5533,10 @@ model CustomMetricEntry {
|
||||
measuredAt DateTime @map("measured_at")
|
||||
note String?
|
||||
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.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
|
||||
@@ -5695,6 +5800,8 @@ model CoachConversation {
|
||||
// conversation keeps memory of what came before without re-sending every
|
||||
// turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted).
|
||||
summaryEncrypted Bytes? @map("summary_encrypted")
|
||||
/// @internal: diagnostic timestamp of the last summary rewrite — the elision
|
||||
/// logic keys on `summaryTurnCount`, not on this instant; nothing reads it.
|
||||
summaryUpdatedAt DateTime? @map("summary_updated_at")
|
||||
summaryTurnCount Int @default(0) @map("summary_turn_count")
|
||||
|
||||
@@ -7713,7 +7820,12 @@ model DocumentContentIndex {
|
||||
/// Provider type that produced the text (diagnostic only), or null on the
|
||||
/// text-ocr path.
|
||||
providerType String? @map("provider_type")
|
||||
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
|
||||
/// Tokeniser algorithm version — bump `CONTENT_TOKENIZER_VERSION`
|
||||
/// (`src/lib/documents/content-index.ts`) to force a re-index on a scheme
|
||||
/// change. Consumer: the content-index backfill job re-tokenises any row
|
||||
/// whose stored version trails the current one from the decrypted
|
||||
/// `textEncrypted` (`retokeniseStaleRows` in
|
||||
/// `src/lib/jobs/document-content-index-backfill.ts`).
|
||||
tokenizerVersion String @map("tokenizer_version")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -7750,7 +7862,8 @@ model DocumentThumbnail {
|
||||
height Int
|
||||
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
|
||||
byteSize Int @map("byte_size")
|
||||
/// Source MIME the preview was rendered from (diagnostic only).
|
||||
/// @internal: source MIME the preview was rendered from — operator-side
|
||||
/// diagnostic provenance only; no query selects it and no client surfaces it.
|
||||
sourceMime String @map("source_mime")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
+4
-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.37.15";
|
||||
/* @sw-version-fallback */ "v1.37.26";
|
||||
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/*`
|
||||
@@ -79,6 +79,9 @@ const MAX_DATA_ENTRIES = 60;
|
||||
// and the version probe are eligible.
|
||||
const API_READ_ALLOWLIST = [
|
||||
"/api/dashboard/snapshot",
|
||||
// v1.37.20 — daily digest: the Today rail's offline read. Keep in
|
||||
// lockstep with src/lib/pwa/sw-cache-policy.ts.
|
||||
"/api/daily/digest",
|
||||
"/api/dashboard/widgets",
|
||||
"/api/measurements",
|
||||
"/api/medications",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The demo password is published in the README so anyone can sign in to
|
||||
* demo.healthlog.dev. Its Argon2id hash lives in two places that must agree:
|
||||
*
|
||||
* - scripts/seed-demo.ts writes it when a demo tenant is seeded
|
||||
* - scripts/migrate-demo-edge01.sh overwrites it after a migration load
|
||||
*
|
||||
* Public issue #805: the migration used to carry the SOURCE account's hash
|
||||
* across instead, so once the operator rotated their own demo password the
|
||||
* credentials the README advertises stopped working, and nothing noticed until
|
||||
* a visitor filed a bug. These assertions make the two copies move together.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts");
|
||||
const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh");
|
||||
|
||||
/** Every Argon2id hash literal in a file, in source order. */
|
||||
function argon2Hashes(path: string): string[] {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return source.match(/\$argon2id\$[^"'\s]+/g) ?? [];
|
||||
}
|
||||
|
||||
describe("published demo password", () => {
|
||||
it("is the same hash in the seed script and the edge01 migration", () => {
|
||||
const seeded = argon2Hashes(SEED);
|
||||
const migrated = argon2Hashes(MIGRATE);
|
||||
|
||||
// An empty match set would make every assertion below vacuously true —
|
||||
// the failure mode this repo keeps rediscovering. Demand a hit first.
|
||||
expect(seeded.length).toBeGreaterThan(0);
|
||||
expect(migrated.length).toBeGreaterThan(0);
|
||||
|
||||
// Each file states the hash exactly once, so a stray second copy (the
|
||||
// thing that lets the two drift again) is a failure in its own right.
|
||||
expect(new Set(seeded).size).toBe(1);
|
||||
expect(new Set(migrated).size).toBe(1);
|
||||
|
||||
expect(migrated[0]).toBe(seeded[0]);
|
||||
});
|
||||
|
||||
it("is written by the migration rather than carried from the source", () => {
|
||||
const script = readFileSync(MIGRATE, "utf8");
|
||||
|
||||
// The overwrite must exist...
|
||||
expect(script).toMatch(
|
||||
/UPDATE users SET password_hash = '\$\{?PUBLIC_DEMO_PASSWORD_HASH\}?'/,
|
||||
);
|
||||
|
||||
// ...and it must be proven, not merely attempted. Without the check a
|
||||
// mangled hash would reach a visitor's login screen instead of the script.
|
||||
expect(script).toContain(
|
||||
"ABORT/FAIL: demo password_hash on edge01 is not the published literal",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the overwrite off the ssh command line", () => {
|
||||
const script = readFileSync(MIGRATE, "utf8");
|
||||
|
||||
// The hash carries '$argon2id', '$v', '$m', '$t', '$p'. Interpolated into
|
||||
// a double-quoted remote command those expand to empty strings and the
|
||||
// stored hash matches nothing. It has to travel on stdin.
|
||||
const sshWithHashInline = new RegExp(
|
||||
String.raw`ssh\s+"\$DST_SSH"\s+"[^"]*PUBLIC_DEMO_PASSWORD_HASH`,
|
||||
);
|
||||
expect(script).not.toMatch(sshWithHashInline);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,17 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
|
||||
Forced flags: `telegram_enabled=false`,
|
||||
`role='ADMIN'`.
|
||||
|
||||
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
|
||||
**Overwritten:** `password_hash`. The row arrives carrying the source
|
||||
account's hash, and the load is immediately followed by an `UPDATE` that sets
|
||||
the Argon2id hash of the demo password the README publishes, then asserts the
|
||||
stored value matches it byte for byte and aborts if it does not. The source is
|
||||
the operator's own instance, so its demo password can be rotated at any time;
|
||||
carrying that hash across is what silently broke the advertised demo login in
|
||||
issue #805. The statement travels on psql's stdin, never inside the ssh command
|
||||
string, because the hash contains `$argon2id` / `$v` / `$m` / `$t` / `$p`
|
||||
segments that a remote shell would expand to nothing.
|
||||
|
||||
**Kept:** `avatar_bytes` /
|
||||
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
|
||||
display / preference / layout JSON (`thresholds_json`,
|
||||
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
|
||||
|
||||
@@ -102,6 +102,23 @@ readonly SRC_DB_CONTAINER
|
||||
readonly PG_USER="healthlog"
|
||||
readonly PG_DB="healthlog"
|
||||
|
||||
# Argon2id hash of the demo password the README publishes ("demo123demo123").
|
||||
# Not a secret: it is printed in the README so anyone can sign in to the demo,
|
||||
# and it is the same literal that scripts/seed-demo.ts writes on a fresh seed.
|
||||
# The transfer still carries the source column, but the load is followed by an
|
||||
# explicit overwrite with this literal (see "FORCE THE PUBLISHED DEMO PASSWORD"
|
||||
# below). The source is the operator's own instance and its demo password can
|
||||
# be rotated at any time; carrying it across silently invalidated the published
|
||||
# credentials once already (public issue #805). Overwriting afterwards makes the
|
||||
# documented login true by construction after every migration.
|
||||
#
|
||||
# The overwrite travels on psql's STDIN, never inside the ssh command string:
|
||||
# the hash contains '$' segments that a remote shell would expand away.
|
||||
#
|
||||
# scripts/__tests__/demo-published-password-guard.test.ts pins this literal to
|
||||
# the one in scripts/seed-demo.ts, so editing one without the other fails.
|
||||
readonly PUBLIC_DEMO_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI'
|
||||
|
||||
# psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero
|
||||
# exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc.
|
||||
psql_src() {
|
||||
@@ -466,6 +483,38 @@ copy_table() {
|
||||
copy_table "users" "$USERS_SELECT" \
|
||||
"users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# FORCE THE PUBLISHED DEMO PASSWORD
|
||||
# ----------------------------------------------------------------------------
|
||||
# The row just loaded carries the SOURCE account's password_hash. The source is
|
||||
# the operator's own instance, so that hash is whatever the operator last set —
|
||||
# not necessarily the password the README publishes. Overwrite it here so the
|
||||
# documented demo login works after every migration, not just the first one.
|
||||
#
|
||||
# The statement goes in on psql's STDIN. It must NOT be interpolated into the
|
||||
# ssh command string: the Argon2id hash contains '$argon2id', '$v', '$m', '$t'
|
||||
# and '$p' segments, and a remote shell would expand each of those to an empty
|
||||
# string and store a mangled hash that no password can ever match.
|
||||
echo
|
||||
echo "## FORCE published demo password on the loaded row ..."
|
||||
printf '%s\n' \
|
||||
"UPDATE users SET password_hash = '$PUBLIC_DEMO_PASSWORD_HASH' WHERE id = '$DEMO_USER_ID';" \
|
||||
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
|
||||
psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB"
|
||||
|
||||
# Prove the stored hash is byte-for-byte the published one. A mangled or
|
||||
# carried-over hash fails here instead of at some visitor's login screen.
|
||||
PW_OK="$(printf '%s\n' \
|
||||
"SELECT count(*) FROM users WHERE id = '$DEMO_USER_ID' AND password_hash = '$PUBLIC_DEMO_PASSWORD_HASH';" \
|
||||
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
|
||||
psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')"
|
||||
if [[ "$PW_OK" != "1" ]]; then
|
||||
echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)."
|
||||
echo " The demo login would be broken. Fix before serving the demo."
|
||||
exit 1
|
||||
fi
|
||||
echo " demo password_hash matches the published literal."
|
||||
|
||||
# 2. measurements (live rows only).
|
||||
copy_table "measurements" "$MEAS_COLS" \
|
||||
"measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The error classes live in ONE leaf module — the cycle stays broken.
|
||||
*
|
||||
* `api-handler.ts` imports the record-session fence, and the fence throws
|
||||
* `RecordSessionChangedError` / `SharingAccessDeniedError`. When those
|
||||
* classes lived inside api-handler the two modules imported each other and
|
||||
* the loop worked only by evaluation-order luck. This guard pins the split:
|
||||
*
|
||||
* 1. identity — the class an importer gets from `api-handler` IS the class
|
||||
* the fence throws (from `api-errors`), so `instanceof` across the two
|
||||
* import paths can never diverge;
|
||||
* 2. structure — the fence carries no VALUE import from api-handler
|
||||
* (a type-only import is erased and allowed), and the leaf imports
|
||||
* nothing but the fence contract.
|
||||
*
|
||||
* Watched red: re-declaring `HttpError` inside api-handler (the pre-split
|
||||
* shape) fails the identity leg; adding a value import of api-handler back
|
||||
* into the fence fails the structural leg.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as apiErrors from "@/lib/api-errors";
|
||||
import {
|
||||
HttpError,
|
||||
RecordSessionChangedError,
|
||||
SharingAccessDeniedError,
|
||||
SharingAuthError,
|
||||
SharingNotPermittedError,
|
||||
StepUpRequiredError,
|
||||
} from "@/lib/api-handler";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
describe("api-errors leaf module", () => {
|
||||
it("api-handler re-exports the identical class objects", () => {
|
||||
expect(HttpError).toBe(apiErrors.HttpError);
|
||||
expect(SharingAuthError).toBe(apiErrors.SharingAuthError);
|
||||
expect(SharingAccessDeniedError).toBe(apiErrors.SharingAccessDeniedError);
|
||||
expect(SharingNotPermittedError).toBe(apiErrors.SharingNotPermittedError);
|
||||
expect(RecordSessionChangedError).toBe(apiErrors.RecordSessionChangedError);
|
||||
expect(StepUpRequiredError).toBe(apiErrors.StepUpRequiredError);
|
||||
});
|
||||
|
||||
it("the record-session fence carries no value import from api-handler", () => {
|
||||
const source = readFileSync(
|
||||
join(SRC, "lib/sharing/record-session-fence.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const imports = source.match(/^import[^;]+from "@\/lib\/api-handler";/gm);
|
||||
for (const statement of imports ?? []) {
|
||||
expect(
|
||||
statement.startsWith("import type"),
|
||||
`record-session-fence.ts imports a VALUE from api-handler — that is the cycle the api-errors leaf removed:\n${statement}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("the leaf imports nothing but the fence contract", () => {
|
||||
const source = readFileSync(join(SRC, "lib/api-errors.ts"), "utf8");
|
||||
const imports = source.match(/^import[^;]+;/gm) ?? [];
|
||||
// Self-check: the walker found the one expected import.
|
||||
expect(imports.length).toBeGreaterThan(0);
|
||||
for (const statement of imports) {
|
||||
expect(
|
||||
statement.includes("record-session-fence-contract"),
|
||||
`api-errors.ts must stay a leaf; unexpected import:\n${statement}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("proves the reminder engine models are on the covered side", () => {
|
||||
// v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and
|
||||
// the completion ledger landed carried from the release that introduced
|
||||
// it. Pinned by name so a future edit cannot quietly move either back to
|
||||
// COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip
|
||||
// before the latest one, so "pending" would be a silent history loss, not
|
||||
// a deferral.
|
||||
for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) {
|
||||
expect(BACKED_UP_MODELS).toContain(model);
|
||||
expect(TWO_ENDED_MODELS).toContain(model);
|
||||
expect(COVERAGE_PENDING).not.toHaveProperty(model);
|
||||
}
|
||||
});
|
||||
|
||||
it("proves the structured-profile models are on the covered side", () => {
|
||||
// These account-owned rows are exported and restored explicitly rather
|
||||
// than being treated as reconstructible data.
|
||||
|
||||
@@ -87,8 +87,11 @@ describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
// three releases without this guard ever seeing it. A matcher that misses
|
||||
// a real resolver is green because it matched nothing, not because
|
||||
// nothing was there.
|
||||
// `tokenHash` may sit anywhere inside the `where` object — demanding it
|
||||
// first would let a formatting change (another key ahead of it) walk a
|
||||
// resolver out of the guard's sight.
|
||||
const resolvers = filesMatching(
|
||||
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{\s*tokenHash/,
|
||||
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/,
|
||||
);
|
||||
|
||||
// Non-zero proof: an empty match set must fail rather than agree with an
|
||||
@@ -149,10 +152,16 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
};
|
||||
|
||||
it("only the known files create ApiToken rows", () => {
|
||||
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
|
||||
// `issue-token.ts` defines the helper; the others call it.
|
||||
(rel) => rel !== "lib/auth/issue-token.ts" || true,
|
||||
// Whitespace-tolerant like the T1 resolver matcher — the lesson this
|
||||
// very file documents: a prettier-wrapped `apiToken\n .create({` was
|
||||
// invisible to the literal matcher, and a guard that matches nothing
|
||||
// is green for the wrong reason.
|
||||
const minters = filesMatching(
|
||||
/apiToken\s*\.\s*create\s*\(|issueApiToken\s*\(/,
|
||||
);
|
||||
// Non-zero proof: an empty match set must fail rather than agree with
|
||||
// an emptied site table.
|
||||
expect(minters.length).toBeGreaterThan(0);
|
||||
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -374,3 +374,41 @@ describe("the mood prognosis never states a cause", () => {
|
||||
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the MCP token copy never claims the mint is read-only", () => {
|
||||
/**
|
||||
* 5. MCP TOKEN SCOPE. The mint card sits directly above the
|
||||
* health:write toggle (`mcp-section.tsx`), and `requireAuth` in
|
||||
* api-handler.ts honours that scope — a minted token writes when the
|
||||
* toggle was on. The card's own description and scope note therefore
|
||||
* must not describe the mint as read-only; "read-only by default /
|
||||
* unless enabled" is the honest shape.
|
||||
*
|
||||
* Watched red: restoring the pre-fix strings ("Mint a read-only token
|
||||
* to connect an assistant." et al.) makes the matching locale fail.
|
||||
*/
|
||||
const READONLY_CLAIMS: Record<string, readonly string[]> = {
|
||||
en: ["read-only token"],
|
||||
de: ["schreibgeschütztes token", "schreibgeschütztes-token"],
|
||||
es: ["token de solo lectura"],
|
||||
fr: ["jeton en lecture seule"],
|
||||
it: ["token in sola lettura"],
|
||||
pl: ["token tylko do odczytu"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} mint copy does not promise a read-only token`, () => {
|
||||
const b = bundle(locale);
|
||||
const description = resolve(b, "settings.mcp.tokensDescription");
|
||||
const scopeNote = resolve(b, "settings.mcp.scopeNote");
|
||||
for (const claim of READONLY_CLAIMS[locale]) {
|
||||
for (const copy of [description, scopeNote]) {
|
||||
expect(
|
||||
copy.toLowerCase(),
|
||||
`${locale} settings.mcp copy claims the minted token is read-only while the write toggle sits beneath it`,
|
||||
).not.toContain(claim);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -280,4 +280,36 @@ describe("data-wipe completeness", () => {
|
||||
OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes the set of user-linked tables that carry no foreign key", () => {
|
||||
// v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair
|
||||
// of FK migrations. The two Telegram plumbing tables link to an account
|
||||
// by a bare `userId` string on purpose: both hold a bounded, short-lived
|
||||
// window of chat state (scheduled message deletions, prompt contexts)
|
||||
// that a cron already reaps, and a cascade would buy nothing the wipe
|
||||
// plan does not already do — rule 1 catches them because the column NAME
|
||||
// matches the convention. That is the exact fragility this test freezes:
|
||||
// a THIRD such table would also pass rule 1 silently, without anyone
|
||||
// deciding it should. So the set is closed here; growing it is a
|
||||
// decision made in this file, with a reason, or by adding the FK.
|
||||
const NO_FK_BY_DESIGN = new Set([
|
||||
"TelegramScheduledDeletion",
|
||||
"TelegramPromptContext",
|
||||
]);
|
||||
const bareUserIdModels = [...models.entries()]
|
||||
.filter(
|
||||
([name, shape]) =>
|
||||
name !== "User" &&
|
||||
shape.scalars.includes("userId") &&
|
||||
!shape.relations.some((rel) => rel.target === "User"),
|
||||
)
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
expect(bareUserIdModels).toEqual([...NO_FK_BY_DESIGN].sort());
|
||||
// And both stay in the wipe plan — the assertion above is about schema
|
||||
// shape, this one about the plan actually reaching them.
|
||||
for (const model of NO_FK_BY_DESIGN) {
|
||||
expect(WIPE_MODELS).toContain(model);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The two override mechanisms agree, or the build fails here.
|
||||
*
|
||||
* The image carries two independent installs. `pnpm install` builds the app
|
||||
* tree and honours the `overrides` block in `pnpm-workspace.yaml`. A second,
|
||||
* npm-owned install builds `/opt/prisma-cli` (Dockerfile) and honours nothing
|
||||
* from that file — it reads its own `overrides` field instead.
|
||||
*
|
||||
* On 2026-08-21 only the first one was set. The dependency audit over the app
|
||||
* tree passed, and the Trivy image scan reported CVE-2026-40345 against
|
||||
* `deepmerge-ts@7.1.5` under `/opt/prisma-cli`, pulled in by
|
||||
* `prisma -> @prisma/config`. A pin that covers one of two installs reads
|
||||
* exactly like a pin that covers both, right up until something scans the
|
||||
* artefact rather than the lockfile.
|
||||
*
|
||||
* So: every package pinned in BOTH places must be pinned to the same range,
|
||||
* and the Dockerfile's pin must survive as long as the workspace one does.
|
||||
* This does NOT assert that every workspace override is mirrored — most exist
|
||||
* for packages `/opt/prisma-cli` never installs, and demanding a mirror there
|
||||
* would encode noise. It asserts agreement where both files speak, which is
|
||||
* the failure that actually happened.
|
||||
*
|
||||
* Limit, written down so the next reader does not over-trust a green run: this
|
||||
* compares declared ranges. It cannot see a NEW vulnerable transitive that
|
||||
* neither file mentions. Only the image scan can, and that job runs on
|
||||
* main-push, not on pull requests.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
||||
const WORKSPACE = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
|
||||
|
||||
/** `npm pkg set 'overrides.<name>=<range>'` occurrences in the Dockerfile. */
|
||||
function dockerfileOverrides(): Map<string, string> {
|
||||
const found = new Map<string, string>();
|
||||
const pattern = /npm\s+pkg\s+set\s+['"]overrides\.([^='"]+)=([^'"]+)['"]/g;
|
||||
for (const match of DOCKERFILE.matchAll(pattern)) {
|
||||
found.set(match[1].trim(), match[2].trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* `"<name>@<selector>": "<range>"` entries under the workspace `overrides:`
|
||||
* block. The key carries a version selector, so `deepmerge-ts@<8.0.0` and a
|
||||
* bare `deepmerge-ts` both have to reduce to the same package name.
|
||||
*/
|
||||
function workspaceOverrides(): Map<string, string> {
|
||||
const found = new Map<string, string>();
|
||||
const pattern = /^\s+"([^"@][^"]*?)(?:@[^"]*)?"\s*:\s*"([^"]+)"/gm;
|
||||
const block = WORKSPACE.split(/^allowBuilds:/m)[0];
|
||||
for (const match of block.matchAll(pattern)) {
|
||||
found.set(match[1].trim(), match[2].trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("dependency overrides — pnpm workspace and the npm install agree", () => {
|
||||
it("finds the Dockerfile's npm-side overrides at all", () => {
|
||||
const docker = dockerfileOverrides();
|
||||
// A zero-match regex would make every assertion below vacuously true.
|
||||
// This is the check that keeps the guard honest about its own matcher.
|
||||
expect(docker.size).toBeGreaterThan(0);
|
||||
expect(docker.has("deepmerge-ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the workspace overrides at all", () => {
|
||||
const workspace = workspaceOverrides();
|
||||
expect(workspace.size).toBeGreaterThan(0);
|
||||
expect(workspace.has("deepmerge-ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("pins the same range wherever both files name the same package", () => {
|
||||
const docker = dockerfileOverrides();
|
||||
const workspace = workspaceOverrides();
|
||||
|
||||
const shared = [...docker.keys()].filter((name) => workspace.has(name));
|
||||
expect(shared.length).toBeGreaterThan(0);
|
||||
|
||||
for (const name of shared) {
|
||||
expect(
|
||||
docker.get(name),
|
||||
`Dockerfile pins ${name} to ${docker.get(name)} while ` +
|
||||
`pnpm-workspace.yaml pins it to ${workspace.get(name)}. The image ` +
|
||||
`carries both installs, so the looser of the two is what ships.`,
|
||||
).toBe(workspace.get(name));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the override ahead of the version Prisma's config asks for", () => {
|
||||
// `@prisma/config` requests deepmerge-ts 7.x; the advisory is fixed in
|
||||
// 8.0.0. Anything that lets a 7.x back in re-opens CVE-2026-40345.
|
||||
const range = dockerfileOverrides().get("deepmerge-ts");
|
||||
expect(range).toBeDefined();
|
||||
expect(range).toMatch(/\^?8\./);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,9 @@ function reminderRow(
|
||||
location: null,
|
||||
nextDueAt: at,
|
||||
lastSatisfiedAt: null,
|
||||
snoozedUntil: null,
|
||||
lastSkippedAt: null,
|
||||
skipCount: 0,
|
||||
enabled: true,
|
||||
vaccinationAntigen: null,
|
||||
createdAt: at,
|
||||
@@ -152,6 +155,27 @@ const BY_ID_SITES: ReadonlyArray<{
|
||||
sites: 1,
|
||||
surface: "the manual satisfy action",
|
||||
},
|
||||
{
|
||||
// v1.37.20 (#223) — skipping an appointment would move a date the visit
|
||||
// record still believes it owns; snoozing one likewise. Both refuse in
|
||||
// the lookup, before any write. This entry went in RED (the routes did
|
||||
// not exist yet) and turned green when they landed with the exclusion.
|
||||
file: "src/app/api/measurement-reminders/[id]/skip/route.ts",
|
||||
sites: 1,
|
||||
surface: "the honest skip action",
|
||||
},
|
||||
{
|
||||
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
|
||||
sites: 1,
|
||||
surface: "the snooze action",
|
||||
},
|
||||
{
|
||||
// v1.37.20 (iOS #68) — the ledger read answers 404 for an appointment
|
||||
// row like every by-id sibling.
|
||||
file: "src/app/api/measurement-reminders/[id]/history/route.ts",
|
||||
sites: 1,
|
||||
surface: "the completion-ledger history read",
|
||||
},
|
||||
{
|
||||
file: "src/lib/telegram-webhook-handlers.ts",
|
||||
sites: 2,
|
||||
@@ -161,9 +185,9 @@ const BY_ID_SITES: ReadonlyArray<{
|
||||
|
||||
describe("an appointment reminder is not addressable by id", () => {
|
||||
it("names a by-id set that is not empty (no vacuous pass)", () => {
|
||||
expect(BY_ID_SITES.length).toBe(4);
|
||||
expect(BY_ID_SITES.length).toBe(7);
|
||||
expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe(
|
||||
7,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -204,6 +204,15 @@ describe("i18n English-leak guard", () => {
|
||||
flattenValues(en, "", enFlat);
|
||||
const enMap = new Map(enFlat);
|
||||
|
||||
it("actually reads the bundles (non-zero floor)", () => {
|
||||
// The walker is the guard's eyes. If `flattenValues` rots — a shape
|
||||
// change, a broken recursion — every leak test above it goes green over
|
||||
// locales it never read. The bundle carries ~7800 keys; a floor well
|
||||
// below that but far above zero fails on rot without failing on
|
||||
// ordinary key churn.
|
||||
expect(enFlat.length).toBeGreaterThan(3000);
|
||||
});
|
||||
|
||||
it.each(LEAK_LOCALES.map((locale) => ({ locale })))(
|
||||
"$locale does not echo English for multi-word strings",
|
||||
({ locale }) => {
|
||||
@@ -212,6 +221,9 @@ describe("i18n English-leak guard", () => {
|
||||
) as Record<string, unknown>;
|
||||
const flat: [string, string][] = [];
|
||||
flattenValues(data, "", flat);
|
||||
// Same non-zero proof per locale — an unreadable or empty bundle must
|
||||
// fail loudly, never pass as "no leaks found".
|
||||
expect(flat.length).toBeGreaterThan(3000);
|
||||
|
||||
const leaks = flat
|
||||
.filter(([key, value]) => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Every idempotent route says so in the contract, and only those do.
|
||||
*
|
||||
* `withIdempotency` has wrapped writes since v1.5.x. `Idempotency-Key` appeared
|
||||
* in a dozen route comments and in no published schema, so an app generated
|
||||
* against `docs/api/openapi.yaml` could not tell that a retry was safe — which
|
||||
* is exactly what the iOS side asked for. Publishing it once is easy; keeping
|
||||
* it published as routes come and go is what this guard is for.
|
||||
*
|
||||
* Both directions matter, and for different reasons:
|
||||
*
|
||||
* - A route that gains `withIdempotency` and forgets to publish it leaves the
|
||||
* capability invisible, which is today's defect repeating itself.
|
||||
* - A route that publishes the parameter and drops the wrapper is worse: the
|
||||
* contract then promises a retry is safe when it is not, and a client that
|
||||
* believes it writes twice.
|
||||
*
|
||||
* The wrapper is the source of truth here, not this file. Nothing is
|
||||
* hand-listed: the expected set is read out of the route tree on every run, so
|
||||
* the guard cannot fall behind the code it watches.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, tests excluded. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
const found: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "__tests__") continue;
|
||||
found.push(...routeFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "route.ts") found.push(full);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `src/app/api/medications/[id]/intake/route.ts` -> `/api/medications/{id}/intake` */
|
||||
function toOpenApiPath(file: string): string {
|
||||
const rel = relative(API_ROOT, file).split(sep).slice(0, -1);
|
||||
const segments = rel.map((s) =>
|
||||
s.startsWith("[") && s.endsWith("]") ? `{${s.slice(1, -1)}}` : s,
|
||||
);
|
||||
return `/api/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method-level, not path-level, and the difference is the whole point.
|
||||
*
|
||||
* `/api/measurements` exports GET, POST, PUT and DELETE, and only POST is
|
||||
* wrapped:
|
||||
*
|
||||
* export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
* export const PUT = apiHandler(putMeasurement);
|
||||
*
|
||||
* A path-level check would go green on that path the moment ANY operation
|
||||
* declared the header, and publishing it on PUT as well would promise a safe
|
||||
* retry the server does not honour. So the unit here is `POST /api/x`, and the
|
||||
* matcher requires the wrapper in the same assignment as the export.
|
||||
*/
|
||||
function idempotentOperationsFromSource(): Set<string> {
|
||||
const found = new Set<string>();
|
||||
// `export const POST = ...withIdempotency...` up to the end of that
|
||||
// statement. Non-greedy to the next top-level `export const`, so a wrapped
|
||||
// POST cannot lend its wrapper to the PUT declared below it.
|
||||
const pattern =
|
||||
/export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=\s*([\s\S]*?)(?=\n(?:export\s+const|export\s+async\s+function|$))/g;
|
||||
for (const file of routeFiles(API_ROOT)) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (!source.includes("withIdempotency")) continue;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
if (!/\bwithIdempotency\s*[(<]/.test(match[2])) continue;
|
||||
found.add(`${match[1].toLowerCase()} ${toOpenApiPath(file)}`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Operations in the emitted document that declare the `Idempotency-Key`. */
|
||||
function idempotentOperationsFromContract(): Set<string> {
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const found = new Set<string>();
|
||||
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
const parameters = (operation as { parameters?: unknown })?.parameters;
|
||||
if (!Array.isArray(parameters)) continue;
|
||||
const declares = parameters.some(
|
||||
(p) => (p as { name?: string })?.name === "Idempotency-Key",
|
||||
);
|
||||
if (declares) found.add(`${method.toLowerCase()} ${path}`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent operations that cannot be published, because the PATH is not in
|
||||
* the document at all. A header cannot hang on a path that does not exist.
|
||||
*
|
||||
* One entry, and the list was four until the test below refused it. The first
|
||||
* draft claimed the whole `/api/medications` family was missing, on the
|
||||
* strength of a grep for path literals in `src/lib/openapi/routes/` that came
|
||||
* back empty. It came back empty because those paths are not written as
|
||||
* searchable literals there — the emitted document carries them, including
|
||||
* every one the iOS client actually argues about. Measure the artefact, not
|
||||
* the source that builds it.
|
||||
*
|
||||
* `/api/insights/feedback` left this list when the route joined the route
|
||||
* table, and the leg below is what made that a diff rather than a permanent
|
||||
* exemption: the moment the path appeared in the document, the entry claiming
|
||||
* it could not be published became false and the run went red. An exemption
|
||||
* nobody removes is how a gap becomes furniture.
|
||||
*
|
||||
* Listed rather than filtered silently, so the exclusion stays visible in the
|
||||
* file that would otherwise claim full coverage.
|
||||
*/
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
|
||||
"/api/admin/backups/{id}/restore":
|
||||
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
|
||||
};
|
||||
|
||||
describe("idempotency — the contract matches the wrapper", () => {
|
||||
it("finds idempotent operations in the source tree at all", () => {
|
||||
// Without this the two comparisons below could both pass on empty sets,
|
||||
// which is the failure mode this whole file exists to prevent elsewhere.
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
expect(fromSource.size).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("publishes the header on every route that wraps withIdempotency", () => {
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
const fromContract = idempotentOperationsFromContract();
|
||||
|
||||
const unpublished = [...fromSource]
|
||||
.filter((p) => !fromContract.has(p))
|
||||
.filter((p) => !(p.split(" ")[1] in UNPUBLISHED_PATHS))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
unpublished,
|
||||
`These routes accept an Idempotency-Key and do not say so in the ` +
|
||||
`contract, so a generated client cannot know a retry is safe. Add ` +
|
||||
`\`idempotencyKeyParameter\` to the write operation and ` +
|
||||
`\`...idempotentWrite()\` to its responses.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the unpublished list honest — every entry is still absent", () => {
|
||||
const doc = buildOpenApiDocument() as { paths?: Record<string, unknown> };
|
||||
const present = Object.keys(doc.paths ?? {});
|
||||
|
||||
const nowPublished = Object.keys(UNPUBLISHED_PATHS)
|
||||
.filter((p) => present.includes(p))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
nowPublished,
|
||||
`These paths are in the contract now, so their exemption above is stale. ` +
|
||||
`Delete the entry and add \`idempotencyKeyParameter\` to the operation ` +
|
||||
`— an exemption nobody removes is how a gap becomes permanent.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not promise idempotency on a route that dropped the wrapper", () => {
|
||||
const fromSource = idempotentOperationsFromSource();
|
||||
const fromContract = idempotentOperationsFromContract();
|
||||
|
||||
const overclaimed = [...fromContract]
|
||||
.filter((p) => !fromSource.has(p))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
overclaimed,
|
||||
`The contract offers an Idempotency-Key on these paths and no handler ` +
|
||||
`honours it. A client that trusts this writes twice on a retry — the ` +
|
||||
`more dangerous of the two directions.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Every route is published, declared unpublished, or registered as retired.
|
||||
*
|
||||
* `pnpm openapi:check` compares the registry against `docs/api/openapi.yaml`
|
||||
* and fails on drift between them. It has never compared the ROUTES against
|
||||
* the registry, so a route that was never registered at all produced no drift
|
||||
* and no failure: the spec was internally consistent and silently incomplete.
|
||||
* That is how two hundred and twenty-two paths, forty-three of them called by
|
||||
* the native client, ended up undocumented while the contract check ran green
|
||||
* on every commit for months. The first count taken of this gap said
|
||||
* forty-five, because it compared against the paths the YAML already listed
|
||||
* rather than against the verbs the routes export.
|
||||
*
|
||||
* This file closes the third side of the triangle. It walks `src/app` for
|
||||
* route modules, reads the HTTP verbs each one actually exports, and requires
|
||||
* every verb to be either present in the route table or named in
|
||||
* {@link UNPUBLISHED} with a reason. Adding a route without touching either
|
||||
* side fails here, which is the only place it can be caught before a client
|
||||
* goes looking for a contract that was never written.
|
||||
*
|
||||
* ## The third state: retired
|
||||
*
|
||||
* Published and exempted both describe a route that exists. Removing one used
|
||||
* to leave exactly one legal move — drop it from the contract too — and that
|
||||
* move is invisible to every client already calling it. The native app spent
|
||||
* twelve days rendering a permanent error card over a chart because
|
||||
* `/api/auth/me/research-mode` went from published to absent with no third
|
||||
* thing in between, and a bare 404 reads as a broken deployment rather than a
|
||||
* decision.
|
||||
*
|
||||
* So retirement is a state here rather than a deletion. A path in
|
||||
* `RETIRED_ROUTES` is required to be gone from `src/app` AND present in the
|
||||
* contract, which is the one combination the fourth case below used to reject
|
||||
* outright. Removing a route now forces a choice: drop it from the contract, or
|
||||
* register it as retired and let the proxy answer 410 for it. What is no longer
|
||||
* available is doing neither and finding out from a client.
|
||||
*
|
||||
* Be exact about what that is worth, because the obvious overclaim is easy to
|
||||
* write and was written here first. This guard reads the tree as it stands. It
|
||||
* cannot see a deletion, so it cannot insist that a removal SHOULD have been
|
||||
* registered: a route deleted from `src/app` and from its route module in one
|
||||
* commit leaves nothing behind to notice, and no check over a single commit's
|
||||
* state can recover the fact that something used to be there. Proving otherwise
|
||||
* needs an anchor outside the commit — the previous release tag — and the job
|
||||
* that runs this suite checks out at depth one with no tags, so a history
|
||||
* comparison here would be a check that silently cannot fail.
|
||||
*
|
||||
* What the third state does buy, and it is not nothing: deleting a route while
|
||||
* it is still published now has TWO legal answers instead of one, and the
|
||||
* failure message names both. Before, the only way past the fourth case was to
|
||||
* erase the path — the move that is invisible to every client. Now the cheaper
|
||||
* move is to leave the path standing as a tombstone, which is the move that
|
||||
* reaches them. And once registered, the registration is held in three
|
||||
* directions by the cases below, so it cannot rot into a lie about a route that
|
||||
* came back or a retirement the contract never mentions.
|
||||
*
|
||||
* ## Why the exemption list carries prose
|
||||
*
|
||||
* A bare allowlist of paths decays into a place to put things. Each entry here
|
||||
* says WHY the route is absent from the public contract, so the next reader can
|
||||
* disagree with the reason rather than guess at it, and so an entry whose
|
||||
* reason has stopped being true is visible as text rather than as a path in a
|
||||
* list. The reasons fall into a small number of kinds — browser-only
|
||||
* navigation, an OAuth callback the provider drives, a webhook authenticated by
|
||||
* a shared secret, an internal cron trigger — and a route that does not fit one
|
||||
* of those kinds probably belongs in the spec.
|
||||
*
|
||||
* ## What this guard does NOT prove
|
||||
*
|
||||
* - It proves a verb is PRESENT in the table, not that the operation is
|
||||
* accurate. A published path whose request schema has drifted from the
|
||||
* handler passes here. That is the limit of a structural check; the accuracy
|
||||
* comes from the schemas being imported from `src/lib/validations/*` rather
|
||||
* than retyped.
|
||||
* - It reads exports with a regex over comment-stripped source. A verb exported
|
||||
* through an indirection this matcher cannot see would read as absent, which
|
||||
* fails loudly rather than passing quietly, and is the direction an
|
||||
* imprecise matcher should fail in.
|
||||
* - `globSync` skips dot-directories by default, which once hid fourteen guards
|
||||
* from `src/app/.well-known`. The sweep below passes an explicit pattern list
|
||||
* that includes it, and asserts a floor on the number of modules found so an
|
||||
* enumeration that silently matches nothing cannot read as full coverage.
|
||||
*
|
||||
* Mutation checks, each run and confirmed red:
|
||||
*
|
||||
* 1. Delete any single entry from {@link UNPUBLISHED} → the third case fails
|
||||
* naming that path.
|
||||
* 2. Delete a path from a route module → the second case fails naming the
|
||||
* verbs that lost their contract.
|
||||
* 3. Delete a live route module that is published — `src/app/api/version` —
|
||||
* → the fourth case fails naming `/api/version` as a published path no
|
||||
* route serves. This is the seam the third state opens: the failure now
|
||||
* has two ways out, erase the path or register the retirement, and the
|
||||
* message says so.
|
||||
* 4. Recreate `src/app/api/auth/me/research-mode/route.ts` with a GET export
|
||||
* → the fifth case fails naming it, because a tombstone over a live route
|
||||
* is wrong in three places at once (this contract, the capability list,
|
||||
* and the 410 the proxy answers before the route can ever run).
|
||||
* 5. Drop `...retiredPaths` from the route-table index → the sixth case fails
|
||||
* naming every registered path as answered by the server and absent from
|
||||
* the contract.
|
||||
*
|
||||
* Deliberately NOT claimed: deleting an entry from `RETIRED_ROUTES` does not
|
||||
* fail anything here, because the contract entries are generated from that same
|
||||
* constant and both sides disappear together. That is the limit stated above,
|
||||
* written down so the next reader does not mistake this file for a deletion
|
||||
* detector.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { globSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiPaths } from "@/lib/openapi/routes";
|
||||
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
/**
|
||||
* The verbs a route module can export. `HEAD` and `OPTIONS` are deliberately
|
||||
* out: Next.js answers both from the others, and neither is a contract a client
|
||||
* writes against.
|
||||
*/
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
/**
|
||||
* The kinds of route that are deliberately outside the published contract.
|
||||
*
|
||||
* A kind rather than a free-text reason per path, because these fall into a
|
||||
* small number of genuinely different shapes and writing the same sentence
|
||||
* seventy-three times would hide the one entry whose sentence is different. A
|
||||
* route that fits none of these kinds belongs in the spec.
|
||||
*/
|
||||
const REASONS = {
|
||||
/**
|
||||
* `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`),
|
||||
* so a Bearer token cannot reach these however wide its scope. The only
|
||||
* caller that can is the operator's own browser on the admin console, which
|
||||
* ships with the server and does not read a spec to find out what it talks
|
||||
* to. Publishing them would describe an API surface that no API client can
|
||||
* use.
|
||||
*/
|
||||
adminConsole:
|
||||
"cookie-only admin console; requireAdmin() refuses every Bearer caller",
|
||||
/**
|
||||
* A browser navigation, not a request a client makes and reads. The response
|
||||
* is a redirect to the provider's own authorise page; the client that starts
|
||||
* it is a link, and the thing that follows it is the address bar.
|
||||
*/
|
||||
browserHandoff:
|
||||
"browser navigation that ends at the provider's authorise page",
|
||||
/**
|
||||
* Driven by the provider, arriving with the provider's own query string. A
|
||||
* client never constructs one, and the contract that governs its shape is
|
||||
* the provider's, not this one.
|
||||
*/
|
||||
oauthCallback: "provider-driven redirect back from an OAuth authorise step",
|
||||
/**
|
||||
* Called by the provider, authenticated by a shared secret rather than by a
|
||||
* session or a token. The payload shape is the provider's to define, and
|
||||
* documenting it here would suggest a caller could construct one.
|
||||
*/
|
||||
providerWebhook:
|
||||
"provider-called, authenticated by a shared secret rather than a session",
|
||||
/**
|
||||
* Serves the deployment itself: crash reports, web vitals, the analytics
|
||||
* proxy, the deploy hook. No user-facing client calls these, and two of them
|
||||
* accept a body defined by a browser reporting standard rather than by us.
|
||||
*/
|
||||
internalOps: "serves the deployment itself, not any user-facing client",
|
||||
/**
|
||||
* The Model Context Protocol transport and the OAuth dance in front of it.
|
||||
* These speak wire formats that MCP and RFC 8414 / RFC 7591 define, including
|
||||
* error bodies the standard response envelope cannot express, which is why
|
||||
* they are the documented exception to the `apiHandler` rule as well.
|
||||
*/
|
||||
mcpTransport: "speaks the MCP and OAuth wire formats, not this envelope",
|
||||
/**
|
||||
* A clinician share link, authenticated by the URL credential itself. The
|
||||
* audience is a person who was sent a link, and the whole point is that they
|
||||
* need nothing else — no account, no token, and no contract to read.
|
||||
*/
|
||||
shareLink:
|
||||
"authenticated by the URL credential, for a recipient with no account",
|
||||
/**
|
||||
* A discovery document whose format belongs to somebody else: Apple's
|
||||
* associated-domains file, and the two OAuth metadata documents. Their shape
|
||||
* is fixed by an external specification, so restating it here would create a
|
||||
* second source of truth that can only be wrong.
|
||||
*/
|
||||
wellKnown: "format fixed by an external specification",
|
||||
} as const;
|
||||
|
||||
type ExemptionKind = keyof typeof REASONS;
|
||||
|
||||
interface Exemption {
|
||||
kind: ExemptionKind;
|
||||
/** The verbs this exemption covers. Any other verb must be published. */
|
||||
methods: readonly HttpMethod[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes that are deliberately not in the public contract.
|
||||
*
|
||||
* Keyed by path, listing the verbs the exemption covers, so a route that grows
|
||||
* a new verb has to justify it rather than inherit somebody else's reason.
|
||||
*/
|
||||
const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/ai-quality": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/ai-settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
||||
"/api/admin/app-logs": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/audit-log": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/audit-log/actions": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/backups/{id}/download": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/backups/{id}/restore": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/central-codex": {
|
||||
kind: "adminConsole",
|
||||
methods: ["DELETE", "GET"],
|
||||
},
|
||||
"/api/admin/central-codex/device-poll": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/central-codex/device-start": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/data": { kind: "adminConsole", methods: ["DELETE"] },
|
||||
"/api/admin/drain-per-sample-cumulative": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/import-apple-health-export": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/monitoring/glitchtip-test": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/monitoring/umami-test": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/reminder-check": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
||||
"/api/admin/settings/assistant-flags": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET", "PUT"],
|
||||
},
|
||||
"/api/admin/settings/module-availability": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET", "PATCH"],
|
||||
},
|
||||
"/api/admin/settings/web-push-vapid/generate": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
"/api/admin/users/{id}/force-logout": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/users/{id}/reset-password": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/nightscout/connect": { kind: "browserHandoff", methods: ["POST"] },
|
||||
"/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/whoop/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/withings/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/internal/deploy-webhook": {
|
||||
kind: "internalOps",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
|
||||
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
|
||||
"/api/send": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/mcp/oauth/authorize": {
|
||||
kind: "mcpTransport",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/mcp/oauth/register": { kind: "mcpTransport", methods: ["POST"] },
|
||||
"/api/mcp/oauth/token": { kind: "mcpTransport", methods: ["POST"] },
|
||||
"/mcp": { kind: "mcpTransport", methods: ["DELETE", "GET", "POST"] },
|
||||
"/api/auth/oidc/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/fitbit/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/google-health/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/oura/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/polar/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/strava/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/whoop/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/withings/callback": { kind: "oauthCallback", methods: ["GET"] },
|
||||
"/api/telegram/webhook": {
|
||||
kind: "providerWebhook",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/whoop/webhook/{token}": { kind: "providerWebhook", methods: ["POST"] },
|
||||
"/api/withings/webhook": {
|
||||
kind: "providerWebhook",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/withings/webhook/{token}": {
|
||||
kind: "providerWebhook",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/c/{token}/fhir": { kind: "shareLink", methods: ["GET"] },
|
||||
"/c/{token}/report.pdf": { kind: "shareLink", methods: ["GET"] },
|
||||
"/.well-known/apple-app-site-association": {
|
||||
kind: "wellKnown",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/.well-known/oauth-authorization-server": {
|
||||
kind: "wellKnown",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/.well-known/oauth-protected-resource": {
|
||||
kind: "wellKnown",
|
||||
methods: ["GET"],
|
||||
},
|
||||
};
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** `src/app/api/foo/[id]/route.ts` → `/api/foo/{id}`. */
|
||||
function toApiPath(file: string): string {
|
||||
return (
|
||||
"/" +
|
||||
relative("src/app", file)
|
||||
.replace(/\/route\.ts$/, "")
|
||||
.replace(/\[\.\.\.([A-Za-z0-9_$]+)\]/g, "{$1}")
|
||||
.replace(/\[([A-Za-z0-9_$]+)\]/g, "{$1}")
|
||||
);
|
||||
}
|
||||
|
||||
function exportedMethods(source: string): HttpMethod[] {
|
||||
return HTTP_METHODS.filter((method) =>
|
||||
new RegExp(
|
||||
`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|const\\s+${method}\\b)`,
|
||||
).test(source),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every route module under `src/app`, dot-directories included.
|
||||
*
|
||||
* The two patterns are not redundant: `globSync("src/app/**")` does not descend
|
||||
* into `.well-known`, and a sweep that quietly skips it is exactly the failure
|
||||
* this project has already had once.
|
||||
*/
|
||||
function routeModules(): string[] {
|
||||
const found = new Set<string>([
|
||||
...globSync("src/app/**/route.ts", { cwd: ROOT }),
|
||||
...globSync("src/app/**/.*/**/route.ts", { cwd: ROOT }),
|
||||
]);
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
interface DiskRoute {
|
||||
path: string;
|
||||
methods: HttpMethod[];
|
||||
}
|
||||
|
||||
function routesOnDisk(): DiskRoute[] {
|
||||
const routes: DiskRoute[] = [];
|
||||
for (const file of routeModules()) {
|
||||
const methods = exportedMethods(stripComments(readFileSync(file, "utf8")));
|
||||
if (methods.length === 0) continue;
|
||||
routes.push({ path: toApiPath(file), methods });
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
describe("every route is published, unpublished, or retired", () => {
|
||||
const disk = routesOnDisk();
|
||||
const retiredPathSet = new Set(RETIRED_ROUTES.map((route) => route.path));
|
||||
|
||||
it("finds the route modules at all", () => {
|
||||
// A floor, not a count: the point is that an enumeration returning nothing
|
||||
// cannot read as "everything is covered". The number moves with the tree,
|
||||
// so it sits well below the real one.
|
||||
expect(
|
||||
disk.length,
|
||||
"the route sweep found almost nothing, which means the pattern is wrong rather than the tree empty",
|
||||
).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("no route verb is missing from the contract without a reason", () => {
|
||||
const table = openApiPaths as Record<string, Record<string, unknown>>;
|
||||
const undocumented: string[] = [];
|
||||
|
||||
for (const { path, methods } of disk) {
|
||||
const exempt = UNPUBLISHED[path];
|
||||
const published = table[path] ?? {};
|
||||
for (const method of methods) {
|
||||
if (exempt?.methods.includes(method)) continue;
|
||||
if (method.toLowerCase() in published) continue;
|
||||
undocumented.push(`${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
undocumented,
|
||||
"these routes answer requests and no client can read what they answer with",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the exemption list names routes that exist", () => {
|
||||
const byPath = new Map(disk.map((route) => [route.path, route.methods]));
|
||||
const stale: string[] = [];
|
||||
|
||||
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
|
||||
const methods = byPath.get(path);
|
||||
if (!methods) {
|
||||
stale.push(`${path} (no such route)`);
|
||||
continue;
|
||||
}
|
||||
for (const method of exempt.methods) {
|
||||
if (!methods.includes(method)) {
|
||||
stale.push(`${method} ${path} (route does not export it)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
stale,
|
||||
"an exemption for a route that is gone hides the next route that needs one",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the contract does not publish a path that no route serves, unless it is retired", () => {
|
||||
const byPath = new Set(disk.map((route) => route.path));
|
||||
const phantom = Object.keys(openApiPaths).filter(
|
||||
(path) => !byPath.has(path) && !retiredPathSet.has(path),
|
||||
);
|
||||
|
||||
expect(
|
||||
phantom,
|
||||
"a published path with no route behind it is a promise the deployment cannot keep — register it in RETIRED_ROUTES if it is gone on purpose",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("a retired path is really gone from src/app", () => {
|
||||
// The other direction of the exemption above. A tombstone standing over a
|
||||
// route that still answers would publish 410 as the only response while
|
||||
// the proxy short-circuits the live handler — the contract, the capability
|
||||
// list and the runtime would all be wrong together, and each of them looks
|
||||
// internally consistent on its own.
|
||||
const byPath = new Set(disk.map((route) => route.path));
|
||||
const undead = RETIRED_ROUTES.map((route) => route.path).filter((path) =>
|
||||
byPath.has(path),
|
||||
);
|
||||
|
||||
expect(
|
||||
undead,
|
||||
"these paths are registered as retired and a route module still exports verbs for them",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every retired path is published as gone", () => {
|
||||
// Registering a retirement and not publishing it puts a client back where
|
||||
// it started: the proxy answers 410, and a client generated from the
|
||||
// contract has never heard of the path. The retirement is only worth
|
||||
// anything if it reaches the reader.
|
||||
const table = openApiPaths as Record<string, Record<string, unknown>>;
|
||||
const unpublished: string[] = [];
|
||||
|
||||
for (const route of RETIRED_ROUTES) {
|
||||
const published = table[route.path];
|
||||
if (!published) {
|
||||
unpublished.push(`${route.path} (absent from the contract)`);
|
||||
continue;
|
||||
}
|
||||
for (const method of route.methods) {
|
||||
const operation = published[method.toLowerCase()] as
|
||||
{ responses?: Record<string, unknown> } | undefined;
|
||||
if (!operation) {
|
||||
unpublished.push(`${method} ${route.path} (verb not published)`);
|
||||
continue;
|
||||
}
|
||||
if (!operation.responses?.["410"]) {
|
||||
unpublished.push(`${method} ${route.path} (published without a 410)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
unpublished,
|
||||
"these retirements are answered by the server and invisible in the contract",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Every published operation says how a caller authenticates, or says it does
|
||||
* not have to.
|
||||
*
|
||||
* The document defined `bearerAuth` and `cookieAuth` under
|
||||
* `components.securitySchemes` and then referenced neither. There was no
|
||||
* document-level `security` and no operation-level one, so the schemes were
|
||||
* decoration: a client generated from the contract wires no `Authorization`
|
||||
* header and no cookie, and reads all of it as an open API. The schemes being
|
||||
* present is what made it invisible, because the obvious check ("are the
|
||||
* schemes declared?") passed.
|
||||
*
|
||||
* So this guard checks the reference, not the declaration. Each operation must
|
||||
* resolve to exactly one of two states:
|
||||
*
|
||||
* - it inherits the document-level `security`, which offers the two schemes
|
||||
* as alternatives, or
|
||||
* - it carries `security: []`, the explicit way OpenAPI says "no credential
|
||||
* required", and its path appears in {@link UNAUTHENTICATED} below.
|
||||
*
|
||||
* ## Why the allowlist is derived from handlers, not from the proxy
|
||||
*
|
||||
* `PUBLIC_PATHS` in `src/proxy.ts` is a different question. It says which paths
|
||||
* the edge lets through without a session; it does not say the handler behind
|
||||
* one accepts an anonymous caller. `/api/ingest/medication` is on that list and
|
||||
* resolves a Bearer token itself, so it is authenticated in the contract sense
|
||||
* while being public at the edge. Reading the proxy list as the answer would
|
||||
* publish that route as open.
|
||||
*
|
||||
* ## What this does NOT prove
|
||||
*
|
||||
* It proves the operation names a scheme, not that the server enforces the one
|
||||
* it names. A route documented as `cookieAuth` that in fact admits a Bearer
|
||||
* would pass here; `bearer-scope-enforcement-guard.test.ts` and
|
||||
* `session-surface-guard.test.ts` are what hold that end.
|
||||
*
|
||||
* Mutation check: drop the document-level `security` from the registry and
|
||||
* every authenticated operation fails here; add `security: []` to an operation
|
||||
* that is not on the list below and it fails naming that operation.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiPaths } from "@/lib/openapi/routes";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
|
||||
|
||||
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
|
||||
|
||||
/**
|
||||
* Operations that genuinely take no credential, with what makes each one so.
|
||||
*
|
||||
* Every entry is a route whose handler serves an anonymous caller. A route that
|
||||
* merely sits on the proxy's public-path list does not belong here.
|
||||
*/
|
||||
const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
// `/api/auth/oidc/callback` is deliberately absent. It is anonymous, but it
|
||||
// is not published at all: the provider drives it with its own query string
|
||||
// and no client constructs one, so it is exempted from the contract by the
|
||||
// coverage guard rather than published with an opt-out. This test is what
|
||||
// caught the entry when it was written from the proxy's public-path list
|
||||
// instead of from the published set.
|
||||
"/api/health":
|
||||
"Container healthcheck. Answers before any user exists and outside the response envelope.",
|
||||
"/api/version":
|
||||
"Build identity. Read by operators and by the update check; carries nothing account-scoped.",
|
||||
"/api/auth/login":
|
||||
"Mints the session. There is no credential to present yet.",
|
||||
"/api/auth/register":
|
||||
"Creates the first credential. Refused outright when registration is closed.",
|
||||
"/api/auth/registration-status":
|
||||
"Says whether registration is open, so the sign-in screen can decide what to offer.",
|
||||
"/api/auth/passkey/login-options":
|
||||
"The WebAuthn challenge that precedes a passkey sign-in.",
|
||||
"/api/auth/passkey/login-verify":
|
||||
"Completes the passkey sign-in; the assertion is the credential.",
|
||||
"/api/auth/oidc/login":
|
||||
"Starts the identity-provider handoff, for the browser and for the native client.",
|
||||
"/api/auth/oidc/status":
|
||||
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
|
||||
"/api/notifications/vapid":
|
||||
"The instance's public Web Push key. Public by definition.",
|
||||
// The tombstones, sourced from the registry rather than restated one by one.
|
||||
// A retired path answers 410 from the proxy before any handler runs, and that
|
||||
// answer never reads the request's cookies or Authorization header — so it is
|
||||
// genuinely credential-free, and publishing it as authenticated would tell a
|
||||
// client to sign in to be told the path is gone. Sourced rather than listed
|
||||
// because a hand-written entry per retirement is a step someone forgets, and
|
||||
// forgetting it here would fail the opt-out check rather than the retirement.
|
||||
...Object.fromEntries(
|
||||
RETIRED_ROUTES.map((route) => [
|
||||
route.path,
|
||||
`Removed in v${route.removedIn}. The 410 is produced at the edge and reads no credential.`,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
describe("the contract says how each operation is authenticated", () => {
|
||||
const doc = buildOpenApiDocument() as unknown as {
|
||||
security?: unknown[];
|
||||
components?: { securitySchemes?: Record<string, unknown> };
|
||||
};
|
||||
const paths = openApiPaths as Record<string, Record<string, unknown>>;
|
||||
|
||||
it("declares a document-level default that references the schemes", () => {
|
||||
const schemes = Object.keys(doc.components?.securitySchemes ?? {});
|
||||
expect(
|
||||
schemes.length,
|
||||
"the schemes themselves went missing, which is a different failure",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const declared = JSON.stringify(doc.security ?? []);
|
||||
const unreferenced = schemes.filter(
|
||||
(scheme) => !declared.includes(`"${scheme}"`),
|
||||
);
|
||||
|
||||
expect(
|
||||
unreferenced,
|
||||
"these schemes are defined and referenced by nothing, so a generated client ignores them",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every operation either inherits that default or opts out on purpose", () => {
|
||||
const undeclaredOptOut: string[] = [];
|
||||
|
||||
for (const [path, item] of Object.entries(paths)) {
|
||||
for (const method of HTTP_METHODS) {
|
||||
const operation = item[method] as { security?: unknown[] } | undefined;
|
||||
if (!operation) continue;
|
||||
// No `security` key at all means the document-level default applies,
|
||||
// which is the authenticated case and needs nothing here.
|
||||
if (operation.security === undefined) continue;
|
||||
if (
|
||||
Array.isArray(operation.security) &&
|
||||
operation.security.length === 0 &&
|
||||
!(path in UNAUTHENTICATED)
|
||||
) {
|
||||
undeclaredOptOut.push(`${method.toUpperCase()} ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
undeclaredOptOut,
|
||||
"these operations publish themselves as needing no credential without a reason on record",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the opt-out list names operations that are published", () => {
|
||||
const stale = Object.keys(UNAUTHENTICATED).filter(
|
||||
(path) => !(path in paths),
|
||||
);
|
||||
|
||||
expect(
|
||||
stale,
|
||||
"an opt-out for a path the document does not publish hides the next one that needs a look",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("each opted-out operation actually carries the empty array", () => {
|
||||
// The counterpart to the check above. A path listed as unauthenticated but
|
||||
// silently inheriting the authenticated default publishes the opposite of
|
||||
// what this file records, and the list would read as if it had been applied.
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const path of Object.keys(UNAUTHENTICATED)) {
|
||||
const item = paths[path];
|
||||
if (!item) continue;
|
||||
const declared = HTTP_METHODS.filter((method) => item[method]).filter(
|
||||
(method) => {
|
||||
const operation = item[method] as { security?: unknown[] };
|
||||
return (
|
||||
Array.isArray(operation.security) && operation.security.length === 0
|
||||
);
|
||||
},
|
||||
);
|
||||
if (declared.length === 0) missing.push(path);
|
||||
}
|
||||
|
||||
expect(
|
||||
missing,
|
||||
"these paths are recorded as needing no credential and do not say so in the document",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,10 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { openApiPaths } from "@/lib/openapi/routes";
|
||||
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
|
||||
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
|
||||
|
||||
/** Published paths that deliberately have no handler behind them. */
|
||||
const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path));
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
|
||||
@@ -148,6 +152,13 @@ function publishedOperations(): PublishedOperation[] {
|
||||
const operations: PublishedOperation[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const [path, item] of Object.entries(openApiPaths)) {
|
||||
// A retired path is published deliberately and has no route module by
|
||||
// definition — that is the whole state (`src/lib/http/retired-routes.ts`).
|
||||
// It resolves no fence because it reaches no handler: the proxy answers 410
|
||||
// before routing, for every caller and every verb, so there is no record to
|
||||
// refuse access to. Skipped by the registry rather than by a path pattern,
|
||||
// so a live route can never fall through this hole.
|
||||
if (retiredPaths.has(path)) continue;
|
||||
const modulePath = routeModulePath(path);
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(path);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* A redirect must not name the host the process happens to be bound to.
|
||||
*
|
||||
* `NextResponse.redirect()` requires an absolute URL, and the obvious way to
|
||||
* build one is `new URL("/somewhere", req.url)`. Behind a reverse proxy that
|
||||
* does not pass `Host` through, `req.url` is the bind address, so the redirect
|
||||
* comes back as `https://0.0.0.0:3000/...`. A browser cannot follow it and iOS
|
||||
* refuses the port outright.
|
||||
*
|
||||
* It shipped on the native sign-in handoff, which is the primary onboarding
|
||||
* path. Two things made it invisible:
|
||||
*
|
||||
* - every error branch of that route builds a fixed `healthlog://` scheme
|
||||
* with no host in it, so the route was correct on every failure and broken
|
||||
* only on success, and
|
||||
* - a test that builds its own `Request` names a host that matches, so the
|
||||
* absolute URL it produced looked right.
|
||||
*
|
||||
* This file therefore checks the two halves separately. The behavioural test
|
||||
* calls the real route with a request whose own URL points somewhere the
|
||||
* public origin is not, which is what a proxy hop looks like from inside the
|
||||
* handler. The structural test freezes the pattern out of the four files that
|
||||
* had it, because a behavioural test can only cover the route somebody
|
||||
* remembered to write one for.
|
||||
*
|
||||
* Mutation check: put `NextResponse.redirect(new URL("/auth/login?flow=native",
|
||||
* req.url))` back in the login route and both tests fail, the first on the
|
||||
* host appearing in `Location` and the second on the file list.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, "../..");
|
||||
|
||||
/**
|
||||
* The files that carried the pattern when it was found. Frozen as a list
|
||||
* rather than a repo-wide sweep so a new occurrence somewhere else is a
|
||||
* failure of the sweep below rather than a silent pass here.
|
||||
*/
|
||||
const FILES_THAT_HAD_IT = [
|
||||
"src/app/api/auth/native/login/route.ts",
|
||||
"src/app/api/withings/connect/route.ts",
|
||||
"src/app/api/whoop/connect/route.ts",
|
||||
"src/app/api/fitbit/connect/route.ts",
|
||||
] as const;
|
||||
|
||||
function read(relativePath: string): string {
|
||||
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("redirects to this deployment do not name a host", () => {
|
||||
it("no route builds a redirect target out of the request URL", () => {
|
||||
// Matches `new URL(<anything>, req.url)` and the `request.url` spelling,
|
||||
// across a line break, which is how prettier formats the longer ones.
|
||||
const pattern = /new URL\([\s\S]*?,\s*(?:req|request)\.url\s*\)/g;
|
||||
|
||||
const offenders: string[] = [];
|
||||
for (const file of FILES_THAT_HAD_IT) {
|
||||
const source = read(file);
|
||||
const hits = source.match(pattern);
|
||||
if (hits) offenders.push(`${file}: ${hits.length}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
"these files built a redirect out of the request URL, which is the bind address behind a proxy",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("each of those files reaches for the relative helper instead", () => {
|
||||
// The counterpart to the check above: proving the pattern is gone says
|
||||
// nothing about whether the redirect still happens. A file that simply
|
||||
// deleted its redirect would pass the first test and fail its users.
|
||||
const missing = FILES_THAT_HAD_IT.filter(
|
||||
(file) => !read(file).includes("relativeRedirect("),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
"a file that lost the pattern without gaining the helper has lost its redirect",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the helper refuses a value that is a host in disguise", async () => {
|
||||
const { relativeRedirect } = await import("@/lib/http/relative-redirect");
|
||||
|
||||
// `//evil.example` is protocol-relative: a browser reads it as a host.
|
||||
expect(() => relativeRedirect("//evil.example/auth/login")).toThrow(
|
||||
/root-relative/,
|
||||
);
|
||||
// Without the leading slash it resolves against the current path instead
|
||||
// of the root, which lands somewhere nobody intended.
|
||||
expect(() => relativeRedirect("auth/login")).toThrow(/root-relative/);
|
||||
|
||||
const ok = relativeRedirect("/auth/login?flow=native");
|
||||
expect(ok.status).toBe(307);
|
||||
expect(ok.headers.get("location")).toBe("/auth/login?flow=native");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
RETIRED_ROUTES,
|
||||
RETIRED_ROUTE_ERROR_CODE,
|
||||
} from "@/lib/http/retired-routes";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* A retired path answers 410; nothing else changes.
|
||||
*
|
||||
* The failure this pins is not a crash — it is an answer that carries no
|
||||
* information. `/api/auth/me/research-mode` was removed on purpose and
|
||||
* answered a bare 404 for twelve days, which the native client read as a
|
||||
* transient fault and retried behind a button that could never succeed. 410 is
|
||||
* the status whose meaning is "this existed and is gone", so a client learns to
|
||||
* stop from the status line alone; the envelope carries the removing version
|
||||
* and the replacement for one that reads further.
|
||||
*
|
||||
* The three cases that matter are the three answers a path can get: retired,
|
||||
* live, and never-existed. The last one is the one a careless fix breaks —
|
||||
* turning every unknown path into a 410 would be worse than the 404 it
|
||||
* replaced, because it would assert history that never happened.
|
||||
*
|
||||
* Break-proofs, each run and confirmed red:
|
||||
*
|
||||
* - Remove the retirement block from `src/proxy.ts` → five cases fail.
|
||||
* - Answer 404 instead of 410 → three fail, including the anonymous one.
|
||||
* - Turn the exact lookup into a `startsWith` → only the unknown-path case
|
||||
* fails, which is the case that exists for exactly that mistake.
|
||||
* - Drop `errorCode` from the envelope → the first case and the envelope
|
||||
* equivalence both fail.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function request(pathname: string, method = "GET"): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, { method });
|
||||
}
|
||||
|
||||
async function body(response: Response) {
|
||||
return (await response.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta: {
|
||||
errorCode: string;
|
||||
removedIn: string;
|
||||
replacedBy: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("the proxy answers a retired path with 410", () => {
|
||||
it("answers 410 with the code, the removing version and the replacement", async () => {
|
||||
const response = proxy(request("/api/auth/me/research-mode"));
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
const payload = await body(response);
|
||||
expect(payload.data).toBeNull();
|
||||
expect(payload.meta.errorCode).toBe(RETIRED_ROUTE_ERROR_CODE);
|
||||
expect(payload.meta.removedIn).toBe("1.37.2");
|
||||
expect(payload.meta.replacedBy).toBeNull();
|
||||
expect(payload.error).toMatch(/removed in v1\.37\.2/);
|
||||
});
|
||||
|
||||
it("carries the replacement path for a route that moved rather than went", async () => {
|
||||
const payload = await body(proxy(request("/api/fhir/$everything")));
|
||||
|
||||
expect(payload.meta.replacedBy).toBe("/api/fhir/Patient/$everything");
|
||||
});
|
||||
|
||||
it("answers every retired path, whatever the verb", async () => {
|
||||
// The path was retired, not a selection of methods on it. A client that
|
||||
// POSTed to `/api/bugreport` must not get a 404 for the verb it used while
|
||||
// a GET gets the tombstone.
|
||||
for (const route of RETIRED_ROUTES) {
|
||||
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = proxy(request(route.path, method));
|
||||
expect(
|
||||
response.status,
|
||||
`${method} ${route.path} did not answer 410`,
|
||||
).toBe(410);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("needs no session and reads no credential", async () => {
|
||||
// The request below carries nothing: no session cookie, no Authorization
|
||||
// header. If the answer ever depended on one, a retired path would become
|
||||
// a way to tell an authenticated caller from an anonymous one.
|
||||
const anonymous = proxy(request("/api/auth/me/research-mode"));
|
||||
const withSession = new NextRequest(
|
||||
"http://localhost/api/auth/me/research-mode",
|
||||
);
|
||||
withSession.cookies.set("healthlog_session", "whatever");
|
||||
|
||||
expect(anonymous.status).toBe(410);
|
||||
expect(proxy(withSession).status).toBe(410);
|
||||
expect(await body(anonymous)).toEqual(await body(proxy(withSession)));
|
||||
});
|
||||
|
||||
it("carries the baseline security headers like every other early exit", () => {
|
||||
const response = proxy(request("/api/auth/me/research-mode"));
|
||||
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("leaves a live path alone", () => {
|
||||
// `/api/measurements` exists. The proxy must fall through to it, not decide
|
||||
// anything about it.
|
||||
expect(proxy(request("/api/measurements")).status).not.toBe(410);
|
||||
expect(proxy(request("/api/auth/me")).status).not.toBe(410);
|
||||
// The replacement for a moved route is a live path and must stay one.
|
||||
expect(proxy(request("/api/fhir/Patient/$everything")).status).not.toBe(
|
||||
410,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a genuinely unknown path to the ordinary 404", () => {
|
||||
// Nothing here claims this path ever existed, so the proxy must not answer
|
||||
// it at all — Next serves its own 404 downstream of the fall-through.
|
||||
for (const unknown of [
|
||||
"/api/auth/me/research-modes",
|
||||
"/api/auth/me/research-mode/extra",
|
||||
"/api/no-such-thing",
|
||||
"/api/bugreport-x",
|
||||
]) {
|
||||
expect(proxy(request(unknown)).status, unknown).not.toBe(410);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the 410 body is the standard error envelope", () => {
|
||||
it("is byte-identical to what apiError would have produced", async () => {
|
||||
// The proxy bundles separately from the route tree, so the envelope is
|
||||
// built as a plain object rather than by calling `apiError`. That is a
|
||||
// duplicated shape unless something holds the two together; this is that
|
||||
// something. A field added to the envelope builder and not to the
|
||||
// retirement answer fails here.
|
||||
const route = RETIRED_ROUTES[0];
|
||||
const fromProxy = await proxy(request(route.path)).json();
|
||||
const fromEnvelope = await apiError(
|
||||
`This endpoint was removed in v${route.removedIn} and is not coming back. Do not retry.`,
|
||||
410,
|
||||
{
|
||||
errorCode: RETIRED_ROUTE_ERROR_CODE,
|
||||
removedIn: route.removedIn,
|
||||
replacedBy: route.replacedBy,
|
||||
},
|
||||
).json();
|
||||
|
||||
expect(JSON.stringify(fromProxy)).toBe(JSON.stringify(fromEnvelope));
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,6 @@ describe("managed record settings configuration contract", () => {
|
||||
tone: "warm",
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
showEvidenceByDefault: false,
|
||||
defaultWindow: "allTime",
|
||||
},
|
||||
},
|
||||
@@ -100,6 +99,24 @@ describe("managed record settings configuration contract", () => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
|
||||
});
|
||||
|
||||
// Watched red: re-adding `showEvidenceByDefault: true` to the managed
|
||||
// coach pick makes the strict schema accept the key again and this
|
||||
// fails. The evidence disclosure stopped honouring the flag in v1.4.27
|
||||
// (F14); a guardian toggle over a pref nothing reads is a dead control.
|
||||
it("refuses the retired showEvidenceByDefault pref", () => {
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("coach", {
|
||||
preferences: {
|
||||
tone: "warm",
|
||||
verbosity: "default",
|
||||
excludeMetrics: [],
|
||||
defaultWindow: "allTime",
|
||||
showEvidenceByDefault: true,
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("uses the complete canonical module inventory for a fresh record", () => {
|
||||
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
|
||||
WRITABLE_MODULE_KEYS,
|
||||
|
||||
@@ -67,7 +67,12 @@ describe("record settings contract", () => {
|
||||
guardianWritable: false,
|
||||
});
|
||||
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
|
||||
expect(classifySettingsDestination("dashboard").kind).toBe(
|
||||
// v1.37.19 — "dashboard" left SETTINGS_SECTION_SLUGS (its URL is
|
||||
// owned by a permanent redirect to /settings/layout/dashboard), so an
|
||||
// unknown destination falls closed to plain unavailable; "layout" is
|
||||
// the surviving adult-shared-unavailable representative.
|
||||
expect(classifySettingsDestination("dashboard").kind).toBe("unavailable");
|
||||
expect(classifySettingsDestination("layout").kind).toBe(
|
||||
"adult-shared-unavailable",
|
||||
);
|
||||
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
|
||||
|
||||
@@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "mind",
|
||||
why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
|
||||
},
|
||||
"app/api/mental-health/assessments/[id]/route.ts": {
|
||||
domain: "mind",
|
||||
why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.",
|
||||
},
|
||||
"app/api/anamnesis/facts/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
@@ -1140,6 +1144,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Honestly skipping the current due cycle (v1.37.20, #223) — the counterpart of complete/satisfy, restarting the interval without claiming fulfilment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "Pushing one due date back to a calendar day (v1.37.20, #223), the cadence untouched. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/history/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The completion ledger behind one reminder (v1.37.20, iOS #68), read-only and newest-first. The lookup binds the resolved user id and refuses an appointment row like every by-id sibling.",
|
||||
},
|
||||
"app/api/labs/restore/route.ts": {
|
||||
domain: "labs",
|
||||
why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
@@ -1198,7 +1214,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/medications/[id]/glp1/route.ts": {
|
||||
domain: "medications",
|
||||
why: "Recording a titration step. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
|
||||
why: "Reading the titration history, the recent injections and the supply math, and recording a titration step. The READ arm joined the MANAGE one when the route was published: the write had resolved the record since v1.37.0 while the read still resolved the caller, so a manager could add a dose the history refused them — may write, may not read, which the model does not produce. The read is at the level `/inventory`, `/side-effects` and `/cadence` use for the same medication's data and is strictly narrower than the write already admitted; the argument for the write is the manage literal's reason line.",
|
||||
},
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": {
|
||||
domain: "medications",
|
||||
@@ -1477,6 +1493,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: [],
|
||||
why: "The same primitive from the other caller.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": {
|
||||
domain: "measurements",
|
||||
conditions: [],
|
||||
why: "Honestly letting one due cycle go (v1.37.20, #223). Additive management of a schedule the level admits — the interval restarts, lastSatisfiedAt is never touched.",
|
||||
},
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": {
|
||||
domain: "measurements",
|
||||
conditions: [],
|
||||
why: "Pushing one due date back (v1.37.20, #223). Additive, self-expiring, the anchor untouched.",
|
||||
},
|
||||
"app/api/labs/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C4"],
|
||||
@@ -1911,8 +1937,16 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
|
||||
* record list, one create on the write literal, and the edit/delete pair plus
|
||||
* the restore on the manage literal. 215 -> 221.
|
||||
*
|
||||
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
|
||||
* and the completion-ledger history read on the record list, plus skip and
|
||||
* snooze on the manage literal. 224 -> 229.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 224;
|
||||
const FROZEN_ENTRY_COUNT = 230;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2673,7 +2707,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-history-list.tsx": {
|
||||
"toast.success": 2,
|
||||
// v1.37.20 (A3-11) — the third success is the restore confirmation
|
||||
// behind the delete toast's Undo action.
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
// v1.36.x — two call sites, one decision: the intake toast routes through
|
||||
@@ -194,7 +196,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
|
||||
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
|
||||
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
|
||||
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -292,6 +292,29 @@ function sourceClient() {
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The bests, the badges, and the environmental history with the location
|
||||
// periods that explain it. Empty for the same reason as the tables above.
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// What a document was filed against, and what was read out of it.
|
||||
// Empty for the same reason as the sections above.
|
||||
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -50,6 +50,16 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup
|
||||
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
|
||||
import { restoreVisitsData } from "@/lib/export/visits-backup";
|
||||
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
|
||||
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
|
||||
import {
|
||||
restoreCoachData,
|
||||
restoreCoachMemoryData,
|
||||
} from "@/lib/export/coach-backup";
|
||||
import { restoreRemindersData } from "@/lib/export/reminders-backup";
|
||||
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
|
||||
import { restoreAwardsData } from "@/lib/export/awards-backup";
|
||||
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
|
||||
import { restoreEcgData } from "@/lib/export/ecg-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -84,6 +94,8 @@ interface RestoreResponse {
|
||||
familyHistory: number;
|
||||
workouts: number;
|
||||
documents: number;
|
||||
documentConditionLinks: number;
|
||||
extractedFacts: number;
|
||||
healthProfile: number;
|
||||
healthProfileFactRevisions: number;
|
||||
customMetrics: number;
|
||||
@@ -95,6 +107,21 @@ interface RestoreResponse {
|
||||
encounterLinks: number;
|
||||
vaccinations: number;
|
||||
vaccinationLinks: number;
|
||||
measurementReminders: number;
|
||||
measurementReminderEvents: number;
|
||||
coachConversations: number;
|
||||
coachMessages: number;
|
||||
coachConversationDocuments: number;
|
||||
coachFacts: number;
|
||||
coachPlans: number;
|
||||
coachReminders: number;
|
||||
mentalHealthAssessments: number;
|
||||
consentReceipts: number;
|
||||
personalRecords: number;
|
||||
userAchievements: number;
|
||||
environmentContexts: number;
|
||||
environmentTravelLocations: number;
|
||||
ecgRecordings: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -539,6 +566,23 @@ const handler = apiHandler(
|
||||
|
||||
const medByName = new Map<string, string>();
|
||||
const restoredMedicationIds = new Set<string>();
|
||||
// The efficacy targets cannot ride inside the medication create
|
||||
// beside the schedules and the packs: `biomarkerId` is a foreign key
|
||||
// into a catalogue this transaction has not restored yet. They are
|
||||
// collected here against the id the drug ACTUALLY got and written in
|
||||
// a second pass once the biomarkers exist; the pass itself says why
|
||||
// hoisting the biomarker restore above the medications is the worse
|
||||
// of the two orderings.
|
||||
const pendingEfficacyTargets: Array<{
|
||||
medicationId: string;
|
||||
target: (typeof payload.medications)[number]["efficacyTargets"][number];
|
||||
}> = [];
|
||||
// Supersede pointers the file states and the restore cannot honour.
|
||||
// Reported as JSON paths into the file rather than as ids: the file
|
||||
// is where an operator has to go to see what the position meant, and
|
||||
// an id would be one the restore never wrote.
|
||||
const unresolvedRevisionLinks: string[] = [];
|
||||
let medicationIndex = 0;
|
||||
for (const m of payload.medications) {
|
||||
const created = await tx.medication.create({
|
||||
data: {
|
||||
@@ -621,11 +665,216 @@ const handler = apiHandler(
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
// Nested for the same reason as the two above — the FK binds
|
||||
// to the id this drug actually got, so a portable file that
|
||||
// mints fresh ids needs no remap.
|
||||
//
|
||||
// `resumedAt` passes through as null when the era is open. It
|
||||
// is not defaulted to the restore time: a pause that was still
|
||||
// running when the backup was taken is still running after the
|
||||
// restore, and closing it here would silently convert an
|
||||
// ongoing decision into a historical one.
|
||||
pauseEras: {
|
||||
create: m.pauseEras.map((p) => ({
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
userId: ownerId,
|
||||
pausedAt: new Date(p.pausedAt),
|
||||
resumedAt: p.resumedAt ? new Date(p.resumedAt) : null,
|
||||
...(p.createdAt
|
||||
? { createdAt: new Date(p.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
// The titration steps, nested for the same reason. The note
|
||||
// takes the measurement contract next to it: ciphertext when
|
||||
// the file has it, legacy plaintext encrypted on the way in,
|
||||
// and the plaintext column left null either way.
|
||||
doseChanges: {
|
||||
create: m.doseChanges.map((d) => ({
|
||||
...(d.id ? { id: d.id } : {}),
|
||||
effectiveFrom: new Date(d.effectiveFrom),
|
||||
doseValue: d.doseValue,
|
||||
doseUnit: d.doseUnit,
|
||||
note: null,
|
||||
noteEncrypted:
|
||||
d.noteEncrypted == null
|
||||
? encryptNote(d.note ?? null)
|
||||
: decodeEncryptedBytes(d.noteEncrypted),
|
||||
...(d.createdAt
|
||||
? { createdAt: new Date(d.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
// The packs, and the ledger behind their counts. Nested for
|
||||
// the same reason as everything above it, and `unitsRemaining`
|
||||
// is written VERBATIM rather than recomputed from the events —
|
||||
// it is what the server had resolved and what the person was
|
||||
// shown, and recomputing here would silently correct a count
|
||||
// they may have adjusted by hand.
|
||||
inventoryItems: {
|
||||
create: m.inventoryItems.map((i) => ({
|
||||
...(i.id ? { id: i.id } : {}),
|
||||
userId: ownerId,
|
||||
state: (i.state ?? "ACTIVE") as never,
|
||||
containerType: (i.containerType ?? "OTHER") as never,
|
||||
unitsTotal: i.unitsTotal,
|
||||
unitsRemaining: i.unitsRemaining,
|
||||
firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null,
|
||||
expiresAt: i.expiresAt ? new Date(i.expiresAt) : null,
|
||||
printedExpiry: i.printedExpiry
|
||||
? new Date(i.printedExpiry)
|
||||
: null,
|
||||
purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null,
|
||||
manufacturer: i.manufacturer ?? null,
|
||||
doseStrength: i.doseStrength ?? null,
|
||||
notes: null,
|
||||
notesEncrypted:
|
||||
i.notesEncrypted == null
|
||||
? encryptNote(i.notes ?? null)
|
||||
: decodeEncryptedBytes(i.notesEncrypted),
|
||||
...(i.createdAt
|
||||
? { createdAt: new Date(i.createdAt) }
|
||||
: {}),
|
||||
...(i.updatedAt
|
||||
? { updatedAt: new Date(i.updatedAt) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
// One row or none, so `create` on the optional side rather
|
||||
// than a list. A file that says nothing leaves the drug
|
||||
// untuned, which is what it was.
|
||||
...(m.phaseConfig
|
||||
? {
|
||||
phaseConfig: {
|
||||
create: {
|
||||
...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}),
|
||||
...(m.phaseConfig.greenValue !== undefined
|
||||
? { greenValue: m.phaseConfig.greenValue }
|
||||
: {}),
|
||||
...(m.phaseConfig.greenMode
|
||||
? { greenMode: m.phaseConfig.greenMode }
|
||||
: {}),
|
||||
...(m.phaseConfig.yellowValue !== undefined
|
||||
? { yellowValue: m.phaseConfig.yellowValue }
|
||||
: {}),
|
||||
...(m.phaseConfig.yellowMode
|
||||
? { yellowMode: m.phaseConfig.yellowMode }
|
||||
: {}),
|
||||
...(m.phaseConfig.orangeValue !== undefined
|
||||
? { orangeValue: m.phaseConfig.orangeValue }
|
||||
: {}),
|
||||
...(m.phaseConfig.orangeMode
|
||||
? { orangeMode: m.phaseConfig.orangeMode }
|
||||
: {}),
|
||||
...(m.phaseConfig.redValue !== undefined
|
||||
? { redValue: m.phaseConfig.redValue }
|
||||
: {}),
|
||||
...(m.phaseConfig.redMode
|
||||
? { redMode: m.phaseConfig.redMode }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
inventoryEvents: {
|
||||
create: m.inventoryEvents.map((e) => ({
|
||||
...(e.id ? { id: e.id } : {}),
|
||||
delta: e.delta,
|
||||
reason: e.reason,
|
||||
occurredAt: new Date(e.occurredAt),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
restoredMedicationIds.add(created.id);
|
||||
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
|
||||
|
||||
// ── The archived schedule eras, in two passes ────────────────
|
||||
//
|
||||
// An era carries `supersededByRevisionId`, the pointer from a
|
||||
// corrected ARCHIVED row to the MANUAL one that replaced it. The
|
||||
// column has no `@relation`, so a value addressing nothing costs
|
||||
// no error — it just stops meaning anything, which is the more
|
||||
// dangerous shape here, because every era consumer SKIPS a row
|
||||
// that carries the pointer. Lose it and the superseded original
|
||||
// goes live again beside its own correction: two overlapping eras
|
||||
// for one window, and past days minted against a plan the account
|
||||
// had already corrected.
|
||||
//
|
||||
// A portable restore mints fresh ids, so the pointer cannot travel
|
||||
// as one. It travels as a POSITION in this drug's own ordered era
|
||||
// list, because that list is the only thing both ends agree on:
|
||||
// the builder sorts it totally (validFrom, then createdAt, then
|
||||
// id) so two eras sharing an instant cannot swap places between
|
||||
// two exports of the same account, and the pointer never crosses a
|
||||
// drug — every route that writes it scopes the update to one
|
||||
// medication — so the drug's own list is a complete address space.
|
||||
//
|
||||
// Pass one writes every era with the link NULL, because the row a
|
||||
// link addresses is as often written after the row addressing it
|
||||
// as before. Pass two patches the links once every position has an
|
||||
// id.
|
||||
const revisionIds: string[] = [];
|
||||
for (const revision of m.scheduleRevisions) {
|
||||
const row = await tx.medicationScheduleRevision.create({
|
||||
data: {
|
||||
...(revision.id ? { id: revision.id } : {}),
|
||||
medicationId: created.id,
|
||||
validFrom: new Date(revision.validFrom),
|
||||
validUntil: new Date(revision.validUntil),
|
||||
payload: toJson(revision.payload),
|
||||
source: revision.source ?? "ARCHIVED",
|
||||
supersededByRevisionId: null,
|
||||
...(revision.createdAt
|
||||
? { createdAt: new Date(revision.createdAt) }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
revisionIds.push(row.id);
|
||||
}
|
||||
for (const [index, revision] of m.scheduleRevisions.entries()) {
|
||||
const to = revision.supersededByIndex;
|
||||
if (to === null || to === undefined) continue;
|
||||
// A hand-edited file can name a position this drug does not
|
||||
// have, or the era's OWN position — and a row that supersedes
|
||||
// itself is skipped by every consumer, so the era would
|
||||
// disappear from the timeline while still sitting in the table.
|
||||
// Neither can be honoured and neither may be guessed at, so the
|
||||
// link stays NULL and the position is reported. The era still
|
||||
// restores: the window and the plan it held are the history, and
|
||||
// the correction pointer is the smaller loss.
|
||||
if (
|
||||
!Number.isInteger(to) ||
|
||||
to < 0 ||
|
||||
to >= revisionIds.length ||
|
||||
to === index
|
||||
) {
|
||||
unresolvedRevisionLinks.push(
|
||||
`medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await tx.medicationScheduleRevision.update({
|
||||
where: { id: revisionIds[index] },
|
||||
data: { supersededByRevisionId: revisionIds[to] },
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of m.efficacyTargets) {
|
||||
pendingEfficacyTargets.push({
|
||||
medicationId: created.id,
|
||||
target,
|
||||
});
|
||||
}
|
||||
medicationIndex += 1;
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"scheduleRevisionLink",
|
||||
[...new Set(unresolvedRevisionLinks)],
|
||||
unresolvedRevisionLinks,
|
||||
);
|
||||
|
||||
if (payload.intakeEvents.length > 0) {
|
||||
// An event whose medication did not come back used to be mapped to
|
||||
@@ -698,6 +947,33 @@ const handler = apiHandler(
|
||||
(entry) => entry.structuredTags,
|
||||
);
|
||||
const tagKeys = [...new Set(referencedTagKeys)];
|
||||
// The account's own CATEGORIES go in before its tags. A custom
|
||||
// tag's `categoryId` is a real foreign key, so writing the tag
|
||||
// first threw `mood_tags_category_id_fkey` and rolled the entire
|
||||
// restore back — an account that had grouped its own mood factors
|
||||
// could not be restored at all, and the failure named a constraint
|
||||
// rather than the thing that was missing.
|
||||
//
|
||||
// Upserted by key for the same reason the tags below are: `key` is
|
||||
// globally unique, so a seeded category that later claims the key
|
||||
// must be left alone rather than overwritten by a restore.
|
||||
for (const category of payload.customMoodTagCategories) {
|
||||
await tx.moodTagCategory.upsert({
|
||||
where: { key: category.key },
|
||||
create: {
|
||||
id: category.id,
|
||||
userId: ownerId,
|
||||
key: category.key,
|
||||
labelKey: category.labelKey,
|
||||
icon: category.icon ?? null,
|
||||
sortOrder: category.sortOrder ?? 0,
|
||||
isActive: category.isActive ?? true,
|
||||
labelEncrypted: category.labelEncrypted ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-create the account's own tag definitions first. The lookup
|
||||
// below used to ask only for the seeded catalogue (`userId: null`),
|
||||
// so a single custom rated tag made the whole restore throw
|
||||
@@ -726,6 +1002,53 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// Which tags the account hid. Delete-then-recreate like every
|
||||
// other section, and resolved by KEY: what people hide is almost
|
||||
// always a seeded tag, whose id differs on every instance.
|
||||
//
|
||||
// A key this instance no longer knows costs one hidden row and is
|
||||
// REPORTED. Dropping it silently would un-hide a factor the person
|
||||
// had deliberately taken out of their mood editor, and they would
|
||||
// have no way to know why it came back.
|
||||
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
|
||||
if (payload.hiddenMoodTags.length > 0) {
|
||||
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
|
||||
const hiddenRows = await tx.moodTag.findMany({
|
||||
where: {
|
||||
key: { in: [...new Set(hiddenKeys)] },
|
||||
OR: [{ userId: null }, { userId: ownerId }],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
const hiddenByKey = new Map(
|
||||
hiddenRows.map((row) => [row.key, row.id]),
|
||||
);
|
||||
const unresolvedHidden: string[] = [];
|
||||
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
|
||||
if (hiddenByKey.has(entry.key)) return true;
|
||||
unresolvedHidden.push(entry.key);
|
||||
return false;
|
||||
});
|
||||
if (writableHidden.length > 0) {
|
||||
await tx.moodTagHidden.createMany({
|
||||
data: writableHidden.map((entry) => ({
|
||||
userId: ownerId,
|
||||
moodTagId: hiddenByKey.get(entry.key)!,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"moodTag",
|
||||
[...new Set(unresolvedHidden)],
|
||||
unresolvedHidden,
|
||||
);
|
||||
}
|
||||
|
||||
const factorRows =
|
||||
factorKeys.length === 0
|
||||
? []
|
||||
@@ -1006,6 +1329,69 @@ const handler = apiHandler(
|
||||
restoredBiomarkerIds.add(created.id);
|
||||
}
|
||||
|
||||
// Collected for the staged facts further down: an approved fact's
|
||||
// `committedRecordId` names the lab result it was committed to, and
|
||||
// that reference is resolved against the rows this loop writes.
|
||||
const restoredLabResultIds = new Set<string>();
|
||||
// What each drug was supposed to move, written now that both ends
|
||||
// exist. The drugs came back several hundred lines up and the
|
||||
// analytes only just now, and one of those two had to move for a
|
||||
// target to reach the database at all.
|
||||
//
|
||||
// The second pass wins over hoisting the biomarker restore above the
|
||||
// medications, for two reasons. `biomarkerByName` and
|
||||
// `restoredBiomarkerIds` are read by the lab-result loop directly
|
||||
// below, so moving the block would separate a map from its only
|
||||
// other consumer and leave a future edit free to re-order them back
|
||||
// without anything noticing. And the direction of the dependency
|
||||
// would read backwards: a pinned target is a leaf of the medication
|
||||
// tree, and letting a leaf dictate where the clinical record is
|
||||
// rebuilt inverts what the file is about. A second pass states the
|
||||
// constraint where it applies instead of hiding it in a line order.
|
||||
//
|
||||
// A named analyte the restore did not put back cannot be written:
|
||||
// `biomarkerId` is a real foreign key, so a dangling value would
|
||||
// fail the constraint and roll the entire account back over one
|
||||
// override. The row is dropped and NAMED. Dropping rather than
|
||||
// nulling is deliberate here: the override IS the reference, and a
|
||||
// row with neither arm set is what the resolver already reads as "no
|
||||
// override", so keeping it would restore something that means
|
||||
// nothing while still claiming the primary slot. A target that
|
||||
// carried no name in the first place is a genuine live state (the
|
||||
// analyte was deleted before the export) and comes back as written.
|
||||
const unresolvedTargetAnalytes: string[] = [];
|
||||
const writableTargets = pendingEfficacyTargets.filter((pending) => {
|
||||
const name = pending.target.biomarkerName;
|
||||
if (!name || biomarkerByName.has(name)) return true;
|
||||
unresolvedTargetAnalytes.push(name);
|
||||
return false;
|
||||
});
|
||||
if (writableTargets.length > 0) {
|
||||
await tx.medicationEfficacyTarget.createMany({
|
||||
data: writableTargets.map(({ medicationId, target }) => ({
|
||||
...(target.id ? { id: target.id } : {}),
|
||||
medicationId,
|
||||
measurementType: target.measurementType ?? null,
|
||||
biomarkerId: target.biomarkerName
|
||||
? (biomarkerByName.get(target.biomarkerName) ?? null)
|
||||
: null,
|
||||
primary: target.primary ?? true,
|
||||
...(target.createdAt
|
||||
? { createdAt: new Date(target.createdAt) }
|
||||
: {}),
|
||||
...(target.updatedAt
|
||||
? { updatedAt: new Date(target.updatedAt) }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
recordUnknownKeys(
|
||||
skips,
|
||||
"medicationTarget",
|
||||
[...new Set(unresolvedTargetAnalytes)],
|
||||
unresolvedTargetAnalytes,
|
||||
);
|
||||
|
||||
for (const lab of payload.labResults) {
|
||||
const biomarkerId =
|
||||
lab.biomarkerId !== undefined
|
||||
@@ -1022,7 +1408,7 @@ const handler = apiHandler(
|
||||
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
|
||||
);
|
||||
}
|
||||
await tx.labResult.create({
|
||||
const createdLab = await tx.labResult.create({
|
||||
data: {
|
||||
...(lab.id ? { id: lab.id } : {}),
|
||||
userId: ownerId,
|
||||
@@ -1056,6 +1442,7 @@ const handler = apiHandler(
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
restoredLabResultIds.add(createdLab.id);
|
||||
}
|
||||
|
||||
const episodeIds = new Set(
|
||||
@@ -1329,12 +1716,56 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// Visits, the address book and the three link tables. Last on
|
||||
// purpose: a link is written only when both of its ends exist, and
|
||||
// the documents above are the final far side to land. Both ends of
|
||||
// this section live in `src/lib/export/visits-backup.ts`, so a grep
|
||||
// of THIS file alone will not find them — the same delegation as the
|
||||
// cycle, profile, intraday and score restores above.
|
||||
// What the vault was filed against, and what was read out of it.
|
||||
// AFTER the documents and the condition episodes because both are
|
||||
// foreign keys here — a filing written before either exists does not
|
||||
// drop quietly, it violates a constraint and costs the operator the
|
||||
// whole restore. AFTER the lab results and the medications for a
|
||||
// second reason: an approved fact's `committedRecordId` is resolved
|
||||
// against the rows those branches wrote, and resolving before they
|
||||
// exist would null every commitment and still report success. Both
|
||||
// ends of this section live in
|
||||
// `src/lib/export/document-filing-backup.ts`.
|
||||
const documentFilingCleared = await restoreDocumentFilingData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
{
|
||||
documentIds: new Set(
|
||||
payload.documents.map((document) => document.id),
|
||||
),
|
||||
episodeIds,
|
||||
committedRecordIds: new Set([
|
||||
...restoredLabResultIds,
|
||||
...episodeIds,
|
||||
...restoredMedicationIds,
|
||||
]),
|
||||
},
|
||||
skips,
|
||||
);
|
||||
|
||||
// The Vorsorge reminders and their completion ledger (v1.37.20,
|
||||
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
|
||||
// those remap a `reminderId` against the reminders in the database,
|
||||
// so the reminders have to be in the database first or every
|
||||
// appointment and booster reference would read as unresolvable and
|
||||
// drop. Both ends of this section live in
|
||||
// `src/lib/export/reminders-backup.ts`.
|
||||
const remindersCleared = await restoreRemindersData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
skips,
|
||||
);
|
||||
|
||||
// Visits, the address book and the three link tables. After the
|
||||
// documents and the reminders on purpose: a link is written only
|
||||
// when both of its ends exist, and the encounter's reminder
|
||||
// reference remaps against the rows the branch above has just
|
||||
// written. Both ends of this section live in
|
||||
// `src/lib/export/visits-backup.ts`, so a grep of THIS file alone
|
||||
// will not find them — the same delegation as the cycle, profile,
|
||||
// intraday and score restores above.
|
||||
const visitsCleared = await restoreVisitsData(
|
||||
tx,
|
||||
ownerId,
|
||||
@@ -1343,10 +1774,10 @@ const handler = apiHandler(
|
||||
);
|
||||
|
||||
// The immunization log, after the visits: a dose remaps its
|
||||
// practitioner and its encounter against rows the branch above has
|
||||
// just written, and its document link needs the documents that
|
||||
// landed before that. Both ends of this section live in
|
||||
// `src/lib/export/vaccinations-backup.ts`.
|
||||
// practitioner, its encounter and its booster reminder against rows
|
||||
// the branches above have just written, and its document link needs
|
||||
// the documents that landed before that. Both ends of this section
|
||||
// live in `src/lib/export/vaccinations-backup.ts`.
|
||||
const vaccinationsCleared = await restoreVaccinationsData(
|
||||
tx,
|
||||
ownerId,
|
||||
@@ -1354,6 +1785,94 @@ const handler = apiHandler(
|
||||
skips,
|
||||
);
|
||||
|
||||
// The Coach's threads, turns and document links. AFTER the
|
||||
// documents on purpose: an attachment carries a `documentId` that
|
||||
// is a foreign key against them, so running earlier would make
|
||||
// every attachment unresolvable and drop the provenance that makes
|
||||
// an old answer checkable. The id set is threaded in rather than
|
||||
// re-queried so the function stays a pure reader of this
|
||||
// transaction. Both ends of this section live in
|
||||
// `src/lib/export/coach-backup.ts`.
|
||||
const coachCleared = await restoreCoachData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(payload.documents.map((document) => document.id)),
|
||||
skips,
|
||||
);
|
||||
|
||||
// What the Coach keeps between threads. AFTER the transcript,
|
||||
// because all three carry a `sourceConversationId` that is resolved
|
||||
// against the threads the branch above has just written — resolving
|
||||
// earlier would cut every fact loose from the conversation it came
|
||||
// out of and still report success.
|
||||
const coachMemoryCleared = await restoreCoachMemoryData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(payload.coachConversations.map((c) => c.id)),
|
||||
skips,
|
||||
);
|
||||
|
||||
// The screener history and the consent record. Neither references
|
||||
// anything but the account, so the position here is free; it sits
|
||||
// last because that is where the section was added, not because
|
||||
// anything above it matters to these two.
|
||||
const sensitiveCleared = await restoreSensitiveData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
// The bests and the badges. AFTER the measurements, and this one is
|
||||
// not a preference: `PersonalRecord.sourceMeasurementId` is a real
|
||||
// foreign key against `measurements` (migration 0054) even though
|
||||
// `prisma/schema.prisma` declares no relation for it, so a pointer
|
||||
// resolved before the measurements exist would not drop quietly.
|
||||
// Postgres would refuse the insert and roll the whole restore back
|
||||
// over one provenance column. The id set is the measurements this
|
||||
// transaction actually wrote with a stable id, threaded in rather
|
||||
// than re-queried so the function stays a pure reader of it. Both
|
||||
// ends of this section live in `src/lib/export/awards-backup.ts`.
|
||||
const awardsCleared = await restoreAwardsData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(stableRows.map((row) => row.id)),
|
||||
skips,
|
||||
);
|
||||
|
||||
// The ECG strips. AFTER the measurements on purpose: a recording
|
||||
// carries a `measurementId` that is a real foreign key against the
|
||||
// EVENT row it was filed with, so a reference written before that
|
||||
// row exists does not merely dangle — it violates the constraint
|
||||
// and rolls the whole restore back. The id set is the one the
|
||||
// measurement branch actually wrote, threaded in rather than
|
||||
// re-queried so the function stays a pure reader of this
|
||||
// transaction. Both ends of this section live in
|
||||
// `src/lib/export/ecg-backup.ts`.
|
||||
const ecgCleared = await restoreEcgData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(stableRows.map((row) => row.id)),
|
||||
skips,
|
||||
);
|
||||
|
||||
// The per-day readings and the location periods that explain them.
|
||||
// Neither references anything but the account, so this section has
|
||||
// no ordering constraint against any other and sits here beside the
|
||||
// rest. What it does owe is atomicity WITH ITSELF, which is why one
|
||||
// function writes both: readings restored without their periods get
|
||||
// re-resolved to the home location and overwritten by the next
|
||||
// environment refresh. Both ends of this section live in
|
||||
// `src/lib/export/environment-backup.ts`.
|
||||
const environmentCleared = await restoreEnvironmentData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const cleared = {
|
||||
measurements: measurements.count,
|
||||
medications: meds.count,
|
||||
@@ -1373,6 +1892,9 @@ const handler = apiHandler(
|
||||
familyHistory: familyHistory.count,
|
||||
workouts: workouts.count,
|
||||
documents: documents.count,
|
||||
documentConditionLinks:
|
||||
documentFilingCleared.documentConditionLinks,
|
||||
extractedFacts: documentFilingCleared.extractedFacts,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
healthProfileFactRevisions:
|
||||
profileCleared.healthProfileFactRevisions,
|
||||
@@ -1385,6 +1907,23 @@ const handler = apiHandler(
|
||||
encounterLinks: visitsCleared.encounterLinks,
|
||||
vaccinations: vaccinationsCleared.vaccinations,
|
||||
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
|
||||
measurementReminders: remindersCleared.measurementReminders,
|
||||
measurementReminderEvents:
|
||||
remindersCleared.measurementReminderEvents,
|
||||
coachConversations: coachCleared.coachConversations,
|
||||
coachMessages: coachCleared.coachMessages,
|
||||
coachConversationDocuments: coachCleared.coachConversationDocuments,
|
||||
coachFacts: coachMemoryCleared.coachFacts,
|
||||
coachPlans: coachMemoryCleared.coachPlans,
|
||||
coachReminders: coachMemoryCleared.coachReminders,
|
||||
mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments,
|
||||
consentReceipts: sensitiveCleared.consentReceipts,
|
||||
personalRecords: awardsCleared.personalRecords,
|
||||
userAchievements: awardsCleared.userAchievements,
|
||||
environmentContexts: environmentCleared.environmentContexts,
|
||||
environmentTravelLocations:
|
||||
environmentCleared.environmentTravelLocations,
|
||||
ecgRecordings: ecgCleared.ecgRecordings,
|
||||
};
|
||||
return { cleared, skipped: summarizeRestoreSkips(skips) };
|
||||
},
|
||||
@@ -1532,6 +2071,13 @@ const handler = apiHandler(
|
||||
customMetrics: summary.customMetrics,
|
||||
customMetricEntries: summary.customMetricEntries,
|
||||
correlationPatterns: summary.correlationPatterns,
|
||||
practitioners: summary.practitioners,
|
||||
encounters: summary.encounters,
|
||||
encounterLinks: summary.encounterLinks,
|
||||
vaccinations: summary.vaccinations,
|
||||
vaccinationLinks: summary.vaccinationLinks,
|
||||
measurementReminders: summary.measurementReminders,
|
||||
measurementReminderEvents: summary.measurementReminderEvents,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: the summary read decrypts and validates the
|
||||
* stored file through the SAME helpers the restore uses, and only ever
|
||||
* reads. Watched red: written against the route before its annotate landed;
|
||||
* the not-found and decrypt-failure arms were asserted first.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const findUniqueMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
const decryptMock = vi.fn();
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (...a: unknown[]) => decryptMock(...a),
|
||||
}));
|
||||
|
||||
const parseMock = vi.fn();
|
||||
const summarizeMock = vi.fn();
|
||||
vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
|
||||
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
|
||||
isCompatibleSchemaVersion: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/admin/backups/b1/summary",
|
||||
);
|
||||
const params = { params: Promise.resolve({ id: "b1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUniqueMock.mockResolvedValue({
|
||||
id: "b1",
|
||||
userId: "u1",
|
||||
data: "ciphertext",
|
||||
createdAt: new Date("2026-08-01T00:00:00Z"),
|
||||
user: { id: "u1", username: "self-hoster" },
|
||||
});
|
||||
decryptMock.mockReturnValue("{}");
|
||||
parseMock.mockReturnValue({ schemaVersion: 3 });
|
||||
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups/[id]/summary", () => {
|
||||
it("answers the file's counts without writing anything", async () => {
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
|
||||
expect(body.data.owner).toBe("self-hoster");
|
||||
});
|
||||
|
||||
it("404s an unknown backup id", async () => {
|
||||
findUniqueMock.mockResolvedValue(null);
|
||||
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("degrades to an error response when the file cannot be decrypted", async () => {
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("bad key");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("422s a file that fails schema validation", async () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
|
||||
* operator confirms a restore.
|
||||
*
|
||||
* The restore dialog used to ask for the typed confirmation on nothing but
|
||||
* a filename and a date; the counts only appeared in the audit row after
|
||||
* the rows were already replaced. This read decrypts and schema-validates
|
||||
* the stored file exactly the way the restore itself does (same decrypt,
|
||||
* same `parseBackupPayload`, same compatibility gate) and answers with the
|
||||
* `summarizeBackup` counts — so what the operator confirms is what the
|
||||
* restore will write, derived from the same code path. Read-only; nothing
|
||||
* is touched.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
parseBackupPayload,
|
||||
summarizeBackup,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
void admin;
|
||||
const { id } = await params;
|
||||
|
||||
const backup = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
});
|
||||
if (!backup) {
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
} catch {
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = parseBackupPayload(plaintext);
|
||||
} catch {
|
||||
return apiError("Backup payload failed schema validation", 422);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
return apiError(
|
||||
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.backups.summary" },
|
||||
meta: { backupId: id, ownerId: backup.userId },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
summary,
|
||||
owner: backup.user?.username ?? null,
|
||||
createdAt: backup.createdAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
resolveProviderForTest,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
/**
|
||||
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
||||
@@ -39,32 +39,6 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const overrideSchema = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum([
|
||||
"OPENAI",
|
||||
"ANTHROPIC",
|
||||
"LOCAL",
|
||||
"OPENAI_COMPATIBLE",
|
||||
"CHATGPT_OAUTH",
|
||||
])
|
||||
.optional()
|
||||
.nullable(),
|
||||
model: z.string().min(1).max(120).optional().nullable(),
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
openaiKey: z.string().min(1).max(500).optional().nullable(),
|
||||
// v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept
|
||||
// separate from `baseUrl` / `openaiKey` so testing an unsaved gateway
|
||||
// config can never reach the pinned OpenAI arm and vice versa.
|
||||
compatBaseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
compatKey: z.string().min(1).max(500).optional().nullable(),
|
||||
compatModel: z.string().min(1).max(120).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
@@ -93,7 +67,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (error) return error;
|
||||
const parsed = overrideSchema.safeParse(data);
|
||||
const parsed = aiTestOverrideSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid override payload", 422);
|
||||
}
|
||||
|
||||
@@ -329,7 +329,6 @@ describe("GET /api/analytics", () => {
|
||||
r2_7: 0.4,
|
||||
slope30: -0.02,
|
||||
r2_30: 0.3,
|
||||
slope90: -0.01,
|
||||
r2_90: 0.2,
|
||||
},
|
||||
] as never)
|
||||
@@ -451,6 +450,52 @@ describe("GET /api/analytics", () => {
|
||||
// slice critical path).
|
||||
});
|
||||
|
||||
/**
|
||||
* `?slice=` is a closed set, and the cost of it not being one was the point.
|
||||
*
|
||||
* The route compared the raw string against `"summaries"` and fell through to
|
||||
* the DEFAULT body on anything else, so `?slice=summary` answered 200 after
|
||||
* running the heaviest chain on the surface. A client could sit on that typo
|
||||
* indefinitely and see nothing but a slow, correct-looking response — the
|
||||
* failure mode was invisible and expensive at the same time.
|
||||
*/
|
||||
describe("GET /api/analytics — the slice parameter is a closed set", () => {
|
||||
const call = (url: string) =>
|
||||
(GET as unknown as (request: Request) => Promise<Response>)(
|
||||
new Request(url),
|
||||
);
|
||||
|
||||
it.each(["summary", "SUMMARIES", "default", ""])(
|
||||
"refuses ?slice=%s rather than serving the expensive default body",
|
||||
async (value) => {
|
||||
const res = await call(
|
||||
`http://localhost/api/analytics?slice=${encodeURIComponent(value)}`,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { errorCode?: string };
|
||||
details?: { issues?: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("analytics.invalid_query");
|
||||
expect(body.details?.issues?.map((i) => i.path)).toContain("slice");
|
||||
},
|
||||
);
|
||||
|
||||
it("still serves the default body when the parameter is absent", async () => {
|
||||
const res = await call("http://localhost/api/analytics");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("still serves the slim body for the one legal value", async () => {
|
||||
const res = await call("http://localhost/api/analytics?slice=summaries");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The slim slice is recognisable by what it does NOT carry.
|
||||
expect(body.data).not.toHaveProperty("healthScore");
|
||||
expect(body.data).toHaveProperty("summaries");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
@@ -46,13 +47,17 @@ export const dynamic = "force-dynamic";
|
||||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||||
* so the slim slice branch is reachable from both call shapes.
|
||||
*
|
||||
* Returns the object the query schema parses: an absent parameter is an
|
||||
* absent KEY, not an empty string, so `slice` stays optional rather than
|
||||
* failing the enum on every unparameterised call.
|
||||
*/
|
||||
function readSliceParam(request: Request | undefined): string | null {
|
||||
if (!request) return null;
|
||||
function readQueryParams(request: Request | undefined): Record<string, string> {
|
||||
if (!request) return {};
|
||||
try {
|
||||
return new URL(request.url).searchParams.get("slice");
|
||||
return Object.fromEntries(new URL(request.url).searchParams);
|
||||
} catch {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +65,21 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// A `slice` outside the closed set is refused rather than served the
|
||||
// default body. Falling through was the expensive failure: `?slice=summary`
|
||||
// ran the full 30-query chain and answered 200, so a client could sit on a
|
||||
// typo indefinitely while paying for the heaviest read on the surface.
|
||||
const parsedQuery = analyticsQuerySchema.safeParse(readQueryParams(request));
|
||||
if (!parsedQuery.success) {
|
||||
annotate({
|
||||
action: { name: "analytics.get" },
|
||||
meta: { outcome: "invalid_query" },
|
||||
});
|
||||
return returnAllZodIssues(parsedQuery.error, 422, {
|
||||
errorCode: "analytics.invalid_query",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||||
// The default path runs 30+ chunked findMany reads to feed
|
||||
// `summarize()` per type; under `?slice=summaries` the route
|
||||
@@ -72,7 +92,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
if (parsedQuery.data.slice === "summaries") {
|
||||
// v1.4.34 IW-G — read-through the analytics cache keyed on
|
||||
// (userId, slice). The slim slice is the dashboard tile strip's hot
|
||||
// path; multiple dashboard mounts inside a 60-second TTL all hit a
|
||||
@@ -233,19 +253,21 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// identical to the previous fan-out output for every field the
|
||||
// dashboard actually consumes:
|
||||
// - `count / latest / min / max / mean` from DAY buckets
|
||||
// - `avg7 / avg30 / slope7 / slope30 / slope90` from the narrow query
|
||||
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
|
||||
// - `avg30LastMonth` from the same narrow query (added in this
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the `DISTINCT ON (type)` latest read
|
||||
//
|
||||
// The two fields the slim path leaves at default values are
|
||||
// `anomalyCount` (always 0) — the insights pipeline consumes it from
|
||||
// its own `comprehensive-aggregator` narrow query, never from this
|
||||
// route — and `avg30LastYear` on types with no WMY coverage, which
|
||||
// matches the pre-fix behaviour for tenants whose year-ago window
|
||||
// happened to fall outside the 425-day floor.
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
// client component read the latter — the insights pipeline computes
|
||||
// both from its own `summarize()` / comprehensive-aggregator reads.
|
||||
// The one field the slim path leaves at a default value is
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
const results = slim.summaries;
|
||||
const lastSeenByType = slim.lastSeenByType;
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
* field-by-field write (no mass assignment). Idempotent — always returns the
|
||||
* resolved next state so the client can hard-set the optimistic update.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -36,10 +34,7 @@ import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
documentsAutoAiRead: z.boolean(),
|
||||
});
|
||||
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -87,7 +82,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
const parsed = documentsAutoAiReadPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" },
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -33,10 +31,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
INJECTION_SITE_VALUES,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { injectionSitePrefsPatchSchema } from "@/lib/validations/user-prefs";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -44,18 +42,6 @@ export const dynamic = "force-dynamic";
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
// Dedup at write time; cap at the full enum size. An empty array
|
||||
// clears the exclusion.
|
||||
globalExcludedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.meta({
|
||||
description:
|
||||
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface InjectionSitePrefsResponse {
|
||||
globalExcludedInjectionSites: InjectionSiteValue[];
|
||||
}
|
||||
@@ -96,7 +82,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
const parsed = injectionSitePrefsPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* field-by-field write (no mass assignment). Idempotent — always returns the
|
||||
* resolved next state so the client can hard-set the optimistic update.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
labsLocalOcrEnabled: z.boolean(),
|
||||
});
|
||||
import { labsLocalOcrPatchSchema } from "@/lib/validations/user-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -81,7 +76,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
const parsed = labsLocalOcrPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
@@ -110,6 +110,10 @@ export const GET = apiHandler(async () => {
|
||||
// when never acknowledged; the onboarding welcome gate reads this to
|
||||
// decide whether to require the acknowledgment before "Get started".
|
||||
disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
|
||||
// The version that acknowledgment was given for. The welcome gate
|
||||
// compares it against the current DISCLAIMER_VERSION so a revised
|
||||
// disclaimer re-prompts instead of riding an old acknowledgment.
|
||||
disclaimerAcknowledgedVersion: user.disclaimerAcknowledgedVersion,
|
||||
// v1.18.6 — resumable module-tour progress. Null when the user has
|
||||
// not started the tour; otherwise the resume point the launcher
|
||||
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
|
||||
|
||||
@@ -23,6 +23,14 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.37.19 (A6-12) — the zone change enqueues a compliance re-fold.
|
||||
const refoldMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ enqueued: true, error: null })),
|
||||
);
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: refoldMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
@@ -96,6 +104,34 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
// Watched red: with the re-fold enqueue removed from the route (the
|
||||
// pre-v1.37.19 write), the changed-zone case fails — the compliance
|
||||
// rollup's day keys were minted under the old zone and nothing ever
|
||||
// re-bucketed them (the coverage probe compares counts, not keys).
|
||||
it("enqueues a compliance re-fold when the zone actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not re-fold when the zone is unchanged", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -60,6 +61,24 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserTimezone(user.id);
|
||||
|
||||
// v1.37.19 (A6-12) — the medication-compliance rollup keys its rows by
|
||||
// the LOCAL day the dose fell on, minted under the timezone in force at
|
||||
// write time. A zone change re-buckets those days, so the stored keys go
|
||||
// stale (and the coverage probe compares counts, not keys — it cannot
|
||||
// notice). Re-fold the trailing window through the ordinary backfill
|
||||
// job; best-effort, the singleton key collapses rapid re-picks.
|
||||
if ((before?.timezone ?? null) !== tz) {
|
||||
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_compliance_refold_enqueued: refold.enqueued,
|
||||
...(refold.error
|
||||
? { timezone_compliance_refold_error: refold.error }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("user.timezone.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -148,7 +148,15 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
// The three cases below pin the capped `safeJson` read. Before it, the
|
||||
// handler called `request.json()` bare: malformed JSON came back 422 rather
|
||||
// than the 400 every sibling scalar answers, a wrong content type was parsed
|
||||
// rather than refused, and there was NO body-size limit — this was the one
|
||||
// endpoint in the family that would materialise whatever it was handed. The
|
||||
// malformed-JSON assertion used to say 422 and was changed deliberately: it
|
||||
// pinned the inconsistency rather than a contract, and the only caller
|
||||
// branches on `res.ok` alone.
|
||||
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
@@ -156,7 +164,36 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a body over the 1 KB cap with 413", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Valid JSON, valid enum value, and far too big: the cap has to be what
|
||||
// rejects it, not the schema.
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
unitPreference: "imperial",
|
||||
padding: "x".repeat(2048),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a non-JSON content type with 415", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: "unitPreference=imperial",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,25 +15,29 @@
|
||||
* Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate
|
||||
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment).
|
||||
*
|
||||
* The body goes through `safeJson` with a 1 KB cap, like the
|
||||
* `documents-auto-ai-read` scalar beside it. It used to read a bare
|
||||
* `request.json()`, which applied no size limit at all — the one endpoint in
|
||||
* this family that would materialise whatever it was sent — and answered a
|
||||
* malformed body with 422 where every sibling answers 400. Both were
|
||||
* accidents of the original copy, not a contract: the only caller checks
|
||||
* `res.ok` and never the status.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
});
|
||||
import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -78,14 +82,14 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "unit-preference.body.invalid_json");
|
||||
}
|
||||
// The body is a single enum value — bound the parse so a malformed or
|
||||
// oversized payload is rejected before it is materialised.
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
const parsed = unitPreferencePatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.unit-preference.patch.invalid_shape" },
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* `returnAllZodIssues`, audit-log row, field-by-field write (no mass
|
||||
* assignment). Idempotent — always returns the resolved next state.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
useCentralCodex: z.boolean(),
|
||||
});
|
||||
import { useCentralCodexPatchSchema } from "@/lib/validations/user-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -79,7 +74,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
const parsed = useCentralCodexPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
|
||||
|
||||
@@ -49,7 +49,11 @@ describe("GET /api/auth/native/login", () => {
|
||||
it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => {
|
||||
const res = await GET(req());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain("/auth/login?flow=native");
|
||||
// Exact, not `toContain`. The looser form was what let iOS #96 ship: it is
|
||||
// satisfied by "http://0.0.0.0:3000/auth/login?flow=native" just as
|
||||
// happily, so it asserted the path was in there rather than that a host
|
||||
// was not.
|
||||
expect(location).toBe("/auth/login?flow=native");
|
||||
// No custom scheme on the success path.
|
||||
expect(location.startsWith("healthlog://")).toBe(false);
|
||||
|
||||
@@ -81,6 +85,35 @@ describe("GET /api/auth/native/login", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("behind a proxy that drops Host, the redirect still points at the caller's origin", async () => {
|
||||
// What a proxy hop looks like from inside the handler: the request the
|
||||
// route sees names the address the Node process bound to, because the
|
||||
// public Host header never made it through. Building the redirect out of
|
||||
// that URL is how iOS #96 happened, and `ASWebAuthenticationSession`
|
||||
// refuses the resulting address outright.
|
||||
//
|
||||
// Every other test in this file builds `http://localhost/...`, where the
|
||||
// host is harmless and the absolute URL looked correct. That is why the
|
||||
// route shipped broken.
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://0.0.0.0:3000/api/auth/native/login?code_challenge=${VALID_CHALLENGE}`,
|
||||
),
|
||||
);
|
||||
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toBe("/auth/login?flow=native");
|
||||
expect(
|
||||
location,
|
||||
"a relative Location resolves against the address the client actually called",
|
||||
).not.toMatch(/^https?:\/\//);
|
||||
expect(location).not.toContain("0.0.0.0");
|
||||
|
||||
// The state cookie still rides the refusal-free path, since the whole
|
||||
// point is that the success branch keeps working behind a proxy.
|
||||
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rate-limited → scheme error, no state cookie, no DB write", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* carries no code or session, so it leaks nothing.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
@@ -58,9 +59,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// `Session.createdAt` (also DB-side) at completion, with no app/DB skew.
|
||||
const startedAt = await nativeHandoffDbNow();
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/auth/login?flow=native", req.url),
|
||||
);
|
||||
const response = relativeRedirect("/auth/login?flow=native");
|
||||
response.cookies.set(
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
encodeNativeHandoffState({
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `PATCH /api/auth/passkeys/{id}` — the rename's refusal has to name the field.
|
||||
*
|
||||
* The handler used to answer `apiError("Invalid request", 422)` and throw the
|
||||
* Zod issues away, which is the one thing every sibling body-parsing route in
|
||||
* this tree does not do. A person renaming a passkey to an empty string and a
|
||||
* person pasting 200 characters got the same three words, so the form had
|
||||
* nothing to put beside the field and no way to say which rule was broken.
|
||||
*
|
||||
* These cases pin the multi-issue envelope by its CONTENT — the issue list has
|
||||
* to be present and has to point at `name` — rather than by status alone. A
|
||||
* status-only assertion would have passed against the flat error it replaced.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
passkey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
count: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
|
||||
|
||||
const ROW = {
|
||||
id: "pk-1",
|
||||
name: "Phone",
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
|
||||
type IssueEnvelope = {
|
||||
error: string;
|
||||
details?: { issues?: Array<{ path?: unknown; message?: string }> };
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/passkeys/pk-1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const params = Promise.resolve({ id: "pk-1" });
|
||||
|
||||
type Handler = (
|
||||
r: Request,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => {
|
||||
it("returns the multi-issue envelope for an empty name", async () => {
|
||||
const res = await (PATCH as unknown as Handler)(mkPatch({ name: "" }), {
|
||||
params,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as IssueEnvelope;
|
||||
// The flat refusal this replaces said "Invalid request" and carried no
|
||||
// `details` at all, so both halves matter.
|
||||
expect(env.error).toBe("Validation failed");
|
||||
expect(env.details?.issues?.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(env.details?.issues)).toContain("name");
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the multi-issue envelope for a name over 64 characters", async () => {
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "x".repeat(65) }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as IssueEnvelope;
|
||||
expect(env.details?.issues?.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(env.details?.issues)).toContain("name");
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes the two refusals from each other", async () => {
|
||||
// The point of the change: a client can tell WHICH rule was broken. Under
|
||||
// the flat error both bodies were byte-identical.
|
||||
const tooShort = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "" }),
|
||||
{ params },
|
||||
);
|
||||
const tooLong = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "x".repeat(65) }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(JSON.stringify(await tooShort.json())).not.toBe(
|
||||
JSON.stringify(await tooLong.json()),
|
||||
);
|
||||
});
|
||||
|
||||
it("still renames on a valid name, trimming it first", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
|
||||
userId: USER.id,
|
||||
} as never);
|
||||
vi.mocked(prisma.passkey.update).mockResolvedValue({
|
||||
...ROW,
|
||||
name: "Work laptop",
|
||||
} as never);
|
||||
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: " Work laptop " }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.passkey.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: "Work laptop" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak another account's passkey through the rename", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
|
||||
userId: "someone-else",
|
||||
} as never);
|
||||
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "Mine now" }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
|
||||
import { passkeyRenameSchema } from "@/lib/validations/auth";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (
|
||||
@@ -29,7 +27,12 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = passkeyRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
// Multi-issue, like every sibling that parses a body. The flat
|
||||
// `apiError("Invalid request", 422)` this replaces discarded the issues,
|
||||
// so a person renaming a passkey could not tell an empty name from one
|
||||
// over the 64-character limit — the form had nothing to put beside the
|
||||
// field.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
|
||||
@@ -37,7 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/scheduling", () => ({
|
||||
computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")),
|
||||
// Now-anchored: the fixed 2027 date was a fuse — on that day the mocked
|
||||
// next-due flipped from future to past and every due/overdue assertion
|
||||
// downstream inverted.
|
||||
computeReminderNextDueAt: vi.fn(
|
||||
() => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/dto", () => ({
|
||||
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
|
||||
|
||||
@@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics"
|
||||
*
|
||||
* PATCH applies a partial edit (`data` built field-by-field; an explicit `null`
|
||||
* on `note` clears it, an omitted key leaves the column untouched). DELETE
|
||||
* hard-deletes the value. Every read/write is narrowed by both `userId` and the
|
||||
* parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404.
|
||||
* tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry
|
||||
* surface; the sibling `restore` route clears the tombstone for the toast's
|
||||
* Undo). Every read/write is narrowed by both `userId` and the parent
|
||||
* `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces
|
||||
* as 404.
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; entryId: string }> };
|
||||
@@ -34,7 +37,12 @@ export const PATCH = apiHandler(
|
||||
const { id, entryId } = await params;
|
||||
|
||||
const existing = await prisma.customMetricEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id, customMetricId: id },
|
||||
where: {
|
||||
id: entryId,
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Custom metric entry not found", 404);
|
||||
@@ -104,13 +112,22 @@ export const DELETE = apiHandler(
|
||||
const { id, entryId } = await params;
|
||||
|
||||
const existing = await prisma.customMetricEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id, customMetricId: id },
|
||||
where: {
|
||||
id: entryId,
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Custom metric entry not found", 404);
|
||||
}
|
||||
|
||||
await prisma.customMetricEntry.delete({ where: { id: entryId } });
|
||||
// Tombstone, not a hard delete: the Undo toast's restore clears it.
|
||||
await prisma.customMetricEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await auditLog("customMetricEntry.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* v1.37.20 (A3-11) — custom-metric entries move from hard delete to the
|
||||
* tombstone + Undo contract every peer entry surface carries.
|
||||
*
|
||||
* Watched red: before the conversion the DELETE verb called
|
||||
* `customMetricEntry.delete` and no restore route existed — the first two
|
||||
* suites below fail against that state (no `update` with a `deletedAt`
|
||||
* stamp; module not found), which is the proof the assertions bite.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetricEntry: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
},
|
||||
}));
|
||||
|
||||
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/cache/invalidate", () => ({
|
||||
invalidateUserCorrelationPatterns: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../[entryId]/route";
|
||||
import { POST as RESTORE } from "../restore/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const ENTRY = {
|
||||
id: "e-1",
|
||||
userId: "user-1",
|
||||
customMetricId: "cm-1",
|
||||
value: 42,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
note: null,
|
||||
createdAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
|
||||
it("tombstones instead of hard-deleting", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(
|
||||
ENTRY as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0];
|
||||
expect(call.where).toEqual({ id: "e-1" });
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
|
||||
.calls[0]![0]!.where!;
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/custom-metrics/[id]/entries/restore", () => {
|
||||
function restoreRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/custom-metrics/cm-1/entries/restore",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("clears the tombstone scoped to owner + parent metric", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ restored: 1 });
|
||||
const call = vi.mocked(prisma.customMetricEntry.updateMany).mock
|
||||
.calls[0][0];
|
||||
expect(call.where).toEqual({
|
||||
id: { in: ["e-1"] },
|
||||
userId: "user-1",
|
||||
customMetricId: "cm-1",
|
||||
deletedAt: { not: null },
|
||||
});
|
||||
expect(call.data).toEqual({ deletedAt: null });
|
||||
});
|
||||
|
||||
it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ restored: 0 });
|
||||
});
|
||||
|
||||
it("422s an empty id list", async () => {
|
||||
const res = await RESTORE(restoreRequest({ ids: [] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the
|
||||
* entry delete-Undo affordance (v1.37.20, A3-11).
|
||||
*
|
||||
* Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the
|
||||
* parent metric.
|
||||
*
|
||||
* Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric
|
||||
* in one `updateMany`, so the value re-surfaces in normal reads. A forged /
|
||||
* foreign / live / mismatched-parent id is a silent no-op (never a 404
|
||||
* existence leak): the mutation `where` pins `userId` and `customMetricId`,
|
||||
* so it only ever touches the caller's rows. Mirrors `/api/labs/restore`.
|
||||
* Same session auth as the delete beside it — custom-metric entry writes are
|
||||
* not delegable.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
|
||||
const restoreSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = restoreSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { count } = await prisma.customMetricEntry.updateMany({
|
||||
where: {
|
||||
id: { in: parsed.data.ids },
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
|
||||
if (count > 0) invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
await auditLog("customMetricEntry.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { customMetricId: id, restored: count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "custom-metric.entry.restore" },
|
||||
meta: { customMetricId: id, restored: count },
|
||||
});
|
||||
|
||||
return apiSuccess({ restored: count });
|
||||
},
|
||||
);
|
||||
@@ -57,7 +57,7 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const { limit, offset, sortDir } = parsed.data;
|
||||
const where = { userId: user.id, customMetricId: id };
|
||||
const where = { userId: user.id, customMetricId: id, deletedAt: null };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.customMetricEntry.findMany({
|
||||
|
||||
@@ -36,11 +36,12 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
},
|
||||
_count: { select: { entries: true } },
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) {
|
||||
if (existing) {
|
||||
const [latestEntry, count] = await Promise.all([
|
||||
prisma.customMetricEntry.findFirst({
|
||||
where: { userId: user.id, customMetricId: created.id },
|
||||
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
}),
|
||||
prisma.customMetricEntry.count({
|
||||
where: { userId: user.id, customMetricId: created.id },
|
||||
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
|
||||
}),
|
||||
]);
|
||||
latest = latestEntry ?? null;
|
||||
|
||||
@@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
|
||||
expect(body.data.updatedAt).toBe(advanced.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard widgets — hero primary content", () => {
|
||||
const weightOnly = [
|
||||
{ id: "weight" as const, visible: true, tileVisible: true, order: 0 },
|
||||
];
|
||||
|
||||
it("accepts and persists hero: 'reminders'", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, widgets: weightOnly, hero: "reminders" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { hero?: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
|
||||
|
||||
const body = (await res.json()) as { data: { hero?: string } };
|
||||
expect(body.data.hero).toBe("reminders");
|
||||
});
|
||||
|
||||
it("rejects a hero value outside the closed enum with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, widgets: weightOnly, hero: "banner" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the stored hero choice when the client omits the field", async () => {
|
||||
// A layout save from a client that predates the field (the native
|
||||
// client, or a stale web tab) must not silently reset the choice.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: weightOnly,
|
||||
hero: "reminders",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(makeReq({ version: 1, widgets: weightOnly }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { hero?: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
|
||||
});
|
||||
|
||||
it("honours an explicitly sent 'score' over the stored 'reminders'", async () => {
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: weightOnly,
|
||||
hero: "reminders",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({ version: 1, widgets: weightOnly, hero: "score" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { hero?: string } };
|
||||
};
|
||||
// "score" is the default and serializes as an omitted field.
|
||||
expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined();
|
||||
|
||||
const body = (await res.json()) as { data: { hero?: string } };
|
||||
expect(body.data.hero).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
HERO_PRIMARY_CONTENTS,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
@@ -158,6 +159,15 @@ const layoutSchema = z.object({
|
||||
.array(z.enum(PRIORITY_ITEM_KINDS))
|
||||
.max(PRIORITY_ITEM_KINDS.length)
|
||||
.optional(),
|
||||
// Hero primary content — what the dashboard hero card leads with:
|
||||
// "score" (default) or "reminders" (the worth-a-look rail promoted into
|
||||
// the hero slot). This widens the stored `dashboardWidgetsJson` shape
|
||||
// ADDITIVELY: the field is optional on the wire and omitted from storage
|
||||
// at the default, so the native client (which mirrors this blob) can
|
||||
// keep round-tripping layouts untouched until it learns the field.
|
||||
// Same preserve-when-absent contract as `comparisonBaseline`; the
|
||||
// resolver drops unknown stored values back to "score" on read.
|
||||
hero: z.enum(HERO_PRIMARY_CONTENTS).optional(),
|
||||
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
|
||||
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
|
||||
// which strips it BEFORE this parse: the token is transport, not a layout
|
||||
|
||||
@@ -631,40 +631,37 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Condition links for the page in ONE grouped query (no N+1).
|
||||
const [linkMap, visitLinkMap] = await Promise.all([
|
||||
loadConditionLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
loadDocumentEncounterLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
// Condition/visit links + the content-index and thumbnail probes for the
|
||||
// page, all in ONE Promise.all — four independent grouped queries (no
|
||||
// N+1, never a ciphertext/blob column), and the vault polls this list
|
||||
// every few seconds so two serialized round-trips were pure added
|
||||
// latency (A5-4).
|
||||
const pageIds = page.map((d) => d.id);
|
||||
const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([
|
||||
loadConditionLinks(user.id, pageIds),
|
||||
loadDocumentEncounterLinks(user.id, pageIds),
|
||||
// Which of the page's documents have a content index (drives the
|
||||
// searchable status + the provenance the UI reads to tell an AI-read
|
||||
// document from a locally-indexed one).
|
||||
page.length > 0
|
||||
? prisma.documentContentIndex.findMany({
|
||||
where: { userId: user.id, documentId: { in: pageIds } },
|
||||
select: { documentId: true, source: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// Which of the page's documents have a preview thumbnail (gates the
|
||||
// card's <img>). Grouped on the 1:1 side table.
|
||||
page.length > 0
|
||||
? prisma.documentThumbnail.findMany({
|
||||
where: { userId: user.id, documentId: { in: pageIds } },
|
||||
select: { documentId: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// Which of the page's documents have a content index (drives the searchable
|
||||
// status + the provenance the UI reads to tell an AI-read document from a
|
||||
// locally-indexed one). One grouped query; never the ciphertext.
|
||||
const indexSources = new Map<string, string>();
|
||||
if (page.length > 0) {
|
||||
const indexed = await prisma.documentContentIndex.findMany({
|
||||
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
|
||||
select: { documentId: true, source: true },
|
||||
});
|
||||
for (const row of indexed) indexSources.set(row.documentId, row.source);
|
||||
}
|
||||
|
||||
// Which of the page's documents have a preview thumbnail (gates the card's
|
||||
// <img>). One grouped query on the 1:1 side table; never the blob column.
|
||||
for (const row of indexedRows) indexSources.set(row.documentId, row.source);
|
||||
const thumbnailIds = new Set<string>();
|
||||
if (page.length > 0) {
|
||||
const thumbs = await prisma.documentThumbnail.findMany({
|
||||
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
|
||||
select: { documentId: true },
|
||||
});
|
||||
for (const row of thumbs) thumbnailIds.add(row.documentId);
|
||||
}
|
||||
for (const row of thumbRows) thumbnailIds.add(row.documentId);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.list" },
|
||||
|
||||
@@ -38,6 +38,29 @@ vi.mock("@/lib/db", () => ({
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The bests, the badges, and the environmental history with the location
|
||||
// periods that explain it. Empty for the same reason as the tables above.
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// What a document was filed against, and what was read out of it.
|
||||
// Empty for the same reason as the sections above.
|
||||
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -121,6 +144,31 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.environmentContext.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn(), count: vi.fn() },
|
||||
moodTag: { findMany: vi.fn() },
|
||||
moodTagCategory: { findMany: vi.fn() },
|
||||
moodTagHidden: { findMany: vi.fn() },
|
||||
mentalHealthAssessment: { findMany: vi.fn() },
|
||||
consentReceipt: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
user: { findUnique: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
@@ -56,6 +60,10 @@ vi.mock("@/lib/db", () => ({
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
workout: { findMany: vi.fn() },
|
||||
inboundDocument: { findMany: vi.fn() },
|
||||
// What a document was filed against, and what was read out of it.
|
||||
documentConditionLink: { findMany: vi.fn() },
|
||||
extractedFact: { findMany: vi.fn() },
|
||||
ecgRecording: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -134,6 +142,13 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
@@ -156,6 +171,11 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
|
||||
@@ -36,6 +36,29 @@ vi.mock("@/lib/db", () => ({
|
||||
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
|
||||
// same reason as the visit tables above.
|
||||
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The bests, the badges, and the environmental history with the location
|
||||
// periods that explain it. Empty for the same reason as the tables above.
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// What a document was filed against, and what was read out of it.
|
||||
// Empty for the same reason as the sections above.
|
||||
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -205,6 +205,18 @@ describe("POST /api/export/health-record — validation", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects a selection that admits nothing with 422", async () => {
|
||||
// The panel disables its own submit in that state, so only a hand-built
|
||||
// request gets here. Rendering an empty report and spending a rate-limit
|
||||
// slot on it would be the wrong answer; this route has no documents-only
|
||||
// mode that would make an empty leaf list a legitimate scope.
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(mkReq({ format: "pdf", selection: sel() }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("export.selection.empty");
|
||||
});
|
||||
|
||||
it("rejects a userId smuggled into the body with 422", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
@@ -540,15 +552,11 @@ describe("POST /api/export/health-record — the selection reaches every format"
|
||||
// leaves: the totals must stay in the database.
|
||||
expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]);
|
||||
// And an empty selection — the "I said nothing" case, which used to mean
|
||||
// "give me everything".
|
||||
// "give me everything" and is now refused outright, so the totals cannot
|
||||
// ride out on a scope nobody chose.
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(mkReq({ format: "fhir", selection: sel() }));
|
||||
const bundle = (await res.json()) as {
|
||||
entry: { resource: { resourceType: string } }[];
|
||||
};
|
||||
expect(
|
||||
bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
|
||||
).toEqual([]);
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
// Asked for by name, they are emitted — the data is withheld, not lost.
|
||||
const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE"));
|
||||
|
||||
@@ -129,6 +129,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
const selection = minted.selection;
|
||||
// A selection that admits nothing would render an empty report and still
|
||||
// spend a rate-limit slot on it. The panel disables its own submit button
|
||||
// in that state; a hand-built request has to be refused here, because this
|
||||
// route has no documents-only mode that would make an empty leaf list a
|
||||
// legitimate scope (the share-link route, which does, mints EMPTY itself).
|
||||
if (selection.leaves.length === 0) {
|
||||
annotate({ meta: { empty_selection: true } });
|
||||
return apiError("Select at least one section to export", 422, {
|
||||
errorCode: "export.selection.empty",
|
||||
});
|
||||
}
|
||||
|
||||
const range = normaliseDateRange(input.range ?? undefined);
|
||||
const practiceName = sanitisePracticeName(input.practiceName);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mintFitbitOAuthStateNonce,
|
||||
} from "@/lib/fitbit/oauth-state";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { relativeRedirect } from "@/lib/http/relative-redirect";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
@@ -59,8 +60,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const creds = await getUserFitbitCredentials(user.id);
|
||||
if (!creds) {
|
||||
annotate({ action: { name: "fitbit.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?fitbit=error&reason=nocreds",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,8 +79,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "fitbit.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?fitbit=error&reason=connect", req.url),
|
||||
return relativeRedirect(
|
||||
"/settings/integrations?fitbit=error&reason=connect",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
|
||||
import { syncUserFitbit } from "@/lib/fitbit/sync";
|
||||
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -30,18 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
let fullSync = false;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
const body = JSON.parse(raw);
|
||||
fullSync = body?.fullSync === true;
|
||||
} catch {
|
||||
// no body provided -> default incremental sync
|
||||
}
|
||||
const body = await readSyncTriggerBody(request);
|
||||
if (body.error) return body.error;
|
||||
const { fullSync } = body;
|
||||
|
||||
// The full-history walk is the expensive path (four paginated resource
|
||||
// walkers); cap it well below the incremental bucket so a re-trigger loop
|
||||
|
||||
@@ -6,21 +6,10 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { googleHealthCredentialsSchema } from "@/lib/validations/google-health";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-user Google Health BYO-key credentials. Each self-hoster registers their
|
||||
* own Google Cloud OAuth client (the Restricted-scope brand verification + CASA
|
||||
* assessment is per-OAuth-client, so a single shared app is unworkable for a
|
||||
* multi-operator product) and pastes the client id/secret into Settings. Stored
|
||||
* encrypted on `User`.
|
||||
*/
|
||||
const googleHealthCredentialsSchema = z.object({
|
||||
clientId: z.string().min(1).max(200),
|
||||
clientSecret: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
/**
|
||||
* Check whether the user has Google Health BYO-key credentials stored
|
||||
* (v1.26.0).
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
|
||||
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
|
||||
import type {
|
||||
GoogleHealthReasonCode,
|
||||
@@ -83,18 +84,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
let fullSync = false;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
const body = JSON.parse(raw);
|
||||
fullSync = body?.fullSync === true;
|
||||
} catch {
|
||||
// no body provided -> default incremental sync
|
||||
}
|
||||
const body = await readSyncTriggerBody(request);
|
||||
if (body.error) return body.error;
|
||||
const { fullSync } = body;
|
||||
|
||||
// The full-history walk is the expensive path; cap it well below the
|
||||
// incremental bucket so a re-trigger loop cannot churn the Google quota.
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
@@ -62,16 +63,19 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
function postReq(
|
||||
body: unknown,
|
||||
bearer: string | null = "hlk_test_token",
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/ingest/medication", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_test_token",
|
||||
...(bearer === null ? {} : { authorization: `Bearer ${bearer}` }),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -80,6 +84,10 @@ function postReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-1",
|
||||
@@ -190,6 +198,10 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
|
||||
function wireHappyPath() {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-1",
|
||||
@@ -433,3 +445,136 @@ describe("POST /api/ingest/medication — idempotency-key stability floor", () =
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A bridge is identified by its TOKEN, not by the address it dials from.
|
||||
*
|
||||
* The bucket used to be `ingest:${ip}`, so a household running a chat bridge,
|
||||
* a button and a home-automation rule — the normal deployment for this route,
|
||||
* not an edge case — put all three in one 60/min cell and let the chattiest
|
||||
* of them starve the other two. What makes it a defect rather than a
|
||||
* trade-off is that the route already resolves a per-caller identity two
|
||||
* lines further down and threw it away.
|
||||
*
|
||||
* The fallback matters as much as the fix: a request that resolves no token
|
||||
* has no identity but its address, and must stay capped on it. Keying the
|
||||
* failure path on anything shared — or on nothing — would hand an
|
||||
* unauthenticated flood a free pass, or collapse every anonymous caller in
|
||||
* the world into one cell, which is worse than what it replaces.
|
||||
*/
|
||||
describe("POST /api/ingest/medication — the rate-limit bucket", () => {
|
||||
it("keys the bucket on the token, not on the client IP", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
|
||||
await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-001" }),
|
||||
);
|
||||
|
||||
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledTimes(1);
|
||||
const [key, limit, windowMs] = vi.mocked(checkRateLimit).mock.calls[0];
|
||||
expect(key).toBe("ingest:token:tok-1");
|
||||
expect(limit).toBe(60);
|
||||
expect(windowMs).toBe(60_000);
|
||||
// The address must not appear in the key at all — that is the whole
|
||||
// point. A NAT'd sibling on the same address gets its own cell.
|
||||
expect(key).not.toContain("203.0.113");
|
||||
});
|
||||
|
||||
it("gives two tokens on one address two independent buckets", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.apiToken.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
id: "tok-bridge",
|
||||
userId: "user-1",
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
id: "tok-button",
|
||||
userId: "user-1",
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" }));
|
||||
await POST(postReq({ medicationName: "B", idempotencyKey: "k-bbbb-001" }));
|
||||
|
||||
const keys = vi.mocked(checkRateLimit).mock.calls.map(([k]) => k);
|
||||
expect(keys).toEqual([
|
||||
"ingest:token:tok-bridge",
|
||||
"ingest:token:tok-button",
|
||||
]);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses an exhausted token bucket with the standard envelope", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-002" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
// The 429 was hand-rolled through `NextResponse.json` and carried no
|
||||
// `meta`, so a client branching on `errorCode` could not see it.
|
||||
expect(body.meta?.errorCode).toBe("ingest.rate_limited");
|
||||
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the per-IP bucket when no token is presented", async () => {
|
||||
const res = await POST(
|
||||
postReq(
|
||||
{ medicationName: "Metformin", idempotencyKey: "k-anon-0001" },
|
||||
null,
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
// The anonymous path must still be capped, and on the address — the only
|
||||
// identity such a request has.
|
||||
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
|
||||
const [, prefix, limit, windowMs] = vi.mocked(checkAuthSurfaceRateLimit)
|
||||
.mock.calls[0];
|
||||
expect(prefix).toBe("ingest:ip");
|
||||
expect(limit).toBe(60);
|
||||
expect(windowMs).toBe(60_000);
|
||||
// No token resolved, so no token bucket was charged.
|
||||
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the per-IP bucket when the token does not resolve", async () => {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-bogus-001" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps an invalid-token flood before it can answer 401 forever", async () => {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-flood-001" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
type RateLimitResult,
|
||||
} from "@/lib/rate-limit";
|
||||
import { externalIntakeSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
@@ -26,13 +31,49 @@ import {
|
||||
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const RATE_LIMIT = 60;
|
||||
const RATE_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/** The 429, with the standard headers and the standard error envelope. */
|
||||
function tooManyRequests(rl: RateLimitResult) {
|
||||
const response = apiError("Rate limit exceeded", 429, {
|
||||
errorCode: "ingest.rate_limited",
|
||||
});
|
||||
for (const [key, value] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a caller that presented no usable token, capped on the way out.
|
||||
*
|
||||
* The bucket is the caller's ADDRESS, because that is the only identity an
|
||||
* unauthenticated request has. `checkAuthSurfaceRateLimit` is the shared
|
||||
* helper for exactly this shape: it keys on the trusted-proxy IP normally,
|
||||
* and when the proxy chain does not match the configured hop count it routes
|
||||
* every anonymous caller into one tight global bucket instead of the
|
||||
* free-for-all that a shared `"unknown"` cell would be.
|
||||
*/
|
||||
async function refuseUnauthenticated(request: NextRequest, message: string) {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"ingest:ip",
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return tooManyRequests(rl);
|
||||
return apiError(message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* External medication ingest endpoint.
|
||||
* Auth: Bearer token (hashed and looked up in api_tokens table).
|
||||
* Idempotent via idempotencyKey.
|
||||
* Rate limit: 60 requests per minute per IP.
|
||||
* Rate limit: 60 requests per minute per TOKEN (per client IP for a request
|
||||
* that presents no valid one).
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "ingest.medication" } });
|
||||
@@ -43,19 +84,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Rate limiting: 60 requests per minute per IP
|
||||
const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
// Extract bearer token
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return apiError("Authorization header required", 401);
|
||||
return refuseUnauthenticated(request, "Authorization header required");
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
@@ -74,13 +106,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (!apiToken || apiToken.revoked) {
|
||||
return apiError("Invalid or revoked token", 401);
|
||||
return refuseUnauthenticated(request, "Invalid or revoked token");
|
||||
}
|
||||
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
return apiError("Token expired", 401);
|
||||
return refuseUnauthenticated(request, "Token expired");
|
||||
}
|
||||
|
||||
// A bridge is identified by its TOKEN, not by the address it dials from.
|
||||
// The bucket used to be `ingest:${ip}`, so every integration behind one
|
||||
// NAT shared a single 60/min cell and one chatty bridge starved the rest —
|
||||
// and a household running a chat bridge, a button and a home-automation
|
||||
// rule is the normal deployment here, not an edge case.
|
||||
//
|
||||
// The identity is only known after the lookup, so the lookup now runs
|
||||
// before the limiter: one indexed single-row read ahead of what was
|
||||
// already an upsert against `rate_limits`. A caller whose token does not
|
||||
// resolve is still capped on the per-IP bucket it was always capped on,
|
||||
// one step earlier than it reaches anything expensive.
|
||||
const rl = await checkRateLimit(
|
||||
`ingest:token:${apiToken.id}`,
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) return tooManyRequests(rl);
|
||||
|
||||
if (
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes("medication:ingest")
|
||||
|
||||
@@ -253,7 +253,7 @@ export const GET = apiHandler(async () => {
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
|
||||
medications: medicationCompliance,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* REGRESSION — when the Coach's numeric grounding guard is armed, and when it
|
||||
* deliberately is not.
|
||||
*
|
||||
* The guard rewrites any figure in the reply that the turn's Grounding Ledger
|
||||
* cannot account for. Its activation used to key solely off `loop.toolResults`,
|
||||
* and a tool that found nothing resolves to `{ present: false, reason: "none" }`
|
||||
* with no `available` payload, which `loop.ts` drops (pinned in
|
||||
* `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly
|
||||
* when the record was empty, and a fabricated figure shipped verbatim with no
|
||||
* withheld-figure marker.
|
||||
*
|
||||
* `toolTrace` now discriminates the two cases the old condition conflated:
|
||||
*
|
||||
* - tools RAN and every one missed -> grade. An empty ledger is the finding,
|
||||
* not a reason to skip, so every magnitude is unreconciled. A reply that
|
||||
* loses a figure this way is replaced with the honest "nothing recorded"
|
||||
* answer rather than shipping elided prose that still asserts a series.
|
||||
* - the model called NO tool -> stay dormant. This is the v1.32.1
|
||||
* contract: the tool-mode base prompt deliberately carries no pre-computed
|
||||
* figures, and flagging a legitimately recalled one was a real regression.
|
||||
*/
|
||||
|
||||
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/redact", () => ({
|
||||
redactSecrets: (s: string) => s,
|
||||
redactOptional: (s: unknown) => s,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
|
||||
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runStreamingRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain,
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
const { assertConsentForChain } = vi.hoisted(() => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/types", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/lib/ai/coach/types",
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
const { fetchConversationWithMessages } = vi.hoisted(() => ({
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(async () => ({ id: "m1" })),
|
||||
createConversation: vi.fn(async () => ({ id: "c1" })),
|
||||
fetchConversationWithMessages,
|
||||
listConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
storeDeterministicFacts: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
|
||||
// unit-tested in the outbound-screen suite.
|
||||
vi.mock("@/lib/medications/scheduled-doses", () => ({
|
||||
getScheduledDoseValues: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-23"),
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
|
||||
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
screenCoachReply: vi.fn(() => ({ block: false })),
|
||||
coachOutboundFallback: vi.fn(() => "fallback"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: vi.fn(async () => ({
|
||||
snapshotJson: SNAPSHOT_JSON,
|
||||
sections: { bloodPressure: { aggregate: { mean: 128 } } },
|
||||
provenance: { windows: ["last30days"], metrics: ["bp"] },
|
||||
referenceGrounding: "REFERENCE RANGES",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/hr-series", () => ({
|
||||
buildWorkoutHrSeries: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/zones", () => ({
|
||||
computeZones: vi.fn(() => null),
|
||||
hrMaxFromAge: vi.fn(() => 185),
|
||||
parseWhoopZoneDurations: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/sport-context", () => ({
|
||||
buildSportContext: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
renderFocusHint,
|
||||
runCoachToolLoop,
|
||||
} = vi.hoisted(() => ({
|
||||
buildCoachDataInventory: vi.fn(async () => ({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: ["bp"], window: "last30days" },
|
||||
})),
|
||||
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
|
||||
renderFocusHint: vi.fn(() => ""),
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
|
||||
MAX_ROUNDS: 3,
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
renderFocusHint,
|
||||
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
|
||||
runCoachToolLoop,
|
||||
}));
|
||||
|
||||
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
|
||||
parseKeyValuesSentinel: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
|
||||
const { parseSuggestReminder } = vi.hoisted(() => ({
|
||||
parseSuggestReminder: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
|
||||
const { appendMessage } = await import("@/lib/ai/coach/persistence");
|
||||
|
||||
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
sse.done = Promise.resolve(
|
||||
producer({ signal: { aborted: false }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Force the model's assembled prose through the sentinel/suggest parsers. */
|
||||
function stubReply(prose: string, toolResults: unknown[]): void {
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose });
|
||||
runCoachToolLoop.mockImplementation(async () => ({
|
||||
result: { content: prose, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
toolResults,
|
||||
}));
|
||||
}
|
||||
|
||||
function assistantContent(): string {
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (assistant?.[0] as { content: string }).content;
|
||||
}
|
||||
|
||||
function assistantProvenance(): Record<string, unknown> | null | undefined {
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistant = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
return (
|
||||
assistant?.[0] as {
|
||||
metricSource?: Record<string, unknown> | null;
|
||||
}
|
||||
).metricSource;
|
||||
}
|
||||
|
||||
describe("coach chat — grounding-guard activation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
|
||||
detectRefusal.mockReturnValue({ refuse: false });
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
assertConsentForChain.mockResolvedValue(undefined);
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "c1",
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
/** Drive the loop with an explicit trace so a MISS is modelled faithfully. */
|
||||
function stubMissedTurn(prose: string): void {
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose });
|
||||
runCoachToolLoop.mockImplementation(async () => ({
|
||||
result: { content: prose, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
// The tool ran and found nothing; `loop.ts` drops the pure miss from
|
||||
// `toolResults` but the trace still records the call.
|
||||
toolTrace: [{ name: "get_sleep", present: false }],
|
||||
toolResults: [],
|
||||
}));
|
||||
}
|
||||
|
||||
it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => {
|
||||
stubReply("Your sleep averaged 432 minutes last week.", [
|
||||
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
|
||||
]);
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
expect(assistantContent()).not.toContain("432");
|
||||
expect(assistantContent()).toContain("[…]");
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
|
||||
});
|
||||
|
||||
it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => {
|
||||
stubMissedTurn("Your sleep averaged 432 minutes last week.");
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
const content = assistantContent();
|
||||
expect(content).not.toContain("432");
|
||||
// Not the elided prose either — the sentence asserted a series the record
|
||||
// does not hold, so the whole turn is replaced with the honest answer.
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).toContain("recorded readings");
|
||||
// The replacement copy says it in words, so the count-based notice is off.
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
|
||||
});
|
||||
|
||||
it("every tool missed but the figure is grounded by a prior turn: left alone", async () => {
|
||||
// Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn
|
||||
// every tool misses, but recalling 128 still reconciles, so nothing is
|
||||
// stripped and the reply is NOT replaced.
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "c1",
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [
|
||||
{ role: "user", content: "How is my BP?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Your systolic averaged 128.",
|
||||
metricSource: { groundedFigures: [128] },
|
||||
},
|
||||
],
|
||||
});
|
||||
stubMissedTurn(
|
||||
"No sleep readings yet. Your systolic 128 average is unrelated to that.",
|
||||
);
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
const content = assistantContent();
|
||||
expect(content).toContain("128");
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).not.toContain("recorded readings");
|
||||
});
|
||||
|
||||
it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => {
|
||||
// Deliberately unchanged behaviour. The tool-mode base prompt carries no
|
||||
// pre-computed figures, so a number here came from the transcript or the
|
||||
// model's own recall, and grading it off the counts-only inventory flagged
|
||||
// legitimate figures as ungrounded. `toolTrace` is empty, so the widened
|
||||
// activation above does not reach this case.
|
||||
const prose = "Your resting heart rate has been averaging 58 bpm.";
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose });
|
||||
runCoachToolLoop.mockImplementation(async () => ({
|
||||
result: { content: prose, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
toolTrace: [],
|
||||
toolResults: [],
|
||||
}));
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "how is my RHR?" }));
|
||||
await sse.done;
|
||||
|
||||
expect(assistantContent()).toBe(prose);
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block
|
||||
// was not re-sent, so there is no fresh authoritative set to grade against.
|
||||
let noToolsSnapshotPayloads: unknown[] = [];
|
||||
// The DATA INVENTORY manifest (per-domain sample counts) that rode this
|
||||
// turn's tool-mode prompt. Hoisted out of the tool branch because the
|
||||
// all-missed activation below needs it as a WIDENER: the counts were in
|
||||
// front of the model, so a plain count restatement ("you've logged 42 BP
|
||||
// readings") must stay grounded even on a turn whose every tool missed.
|
||||
// Never an ACTIVATOR on its own — see the v1.32.1 note below.
|
||||
let inventoryPayloads: unknown[] = [];
|
||||
let totalTokensSpent: number;
|
||||
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
|
||||
// input the user did not re-pay for must not be billed to the daily meter).
|
||||
@@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// out-of-window aggregate rule 3 lets the model cite. Ground it.
|
||||
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
|
||||
];
|
||||
inventoryPayloads = [inventory.entries];
|
||||
toolResultPayloads =
|
||||
presentToolPayloads.length > 0
|
||||
? [...presentToolPayloads, inventory.entries]
|
||||
@@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// active grading — they never ACTIVATE it, so a snapshot figure the model
|
||||
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
|
||||
// holds). Assistant prose is never a ledger source (D3).
|
||||
//
|
||||
// The turn that CALLED tools and got nothing back is graded too. It used to
|
||||
// be the one turn the verifier sat out: a pure miss carries no `data` and no
|
||||
// `available`, `runCoachToolLoop` drops that shape from `toolResults`, and
|
||||
// the no-tools snapshot fallback below is populated only in the non-tool
|
||||
// branch — so `activatingPayloads` came out empty and every figure in the
|
||||
// reply shipped unchecked. That is backwards. A turn whose every tool
|
||||
// reported `{ present: false }` is not an absence of evidence about the
|
||||
// reply, it is evidence that the record holds nothing to cite, which is
|
||||
// exactly when a fabricated figure is both likeliest and most harmful.
|
||||
//
|
||||
// `toolTrace` is the discriminant, because it records every tool that ran
|
||||
// INCLUDING the pure misses. It separates the two cases the old condition
|
||||
// conflated: tools ran and found nothing (grade — the model was told the
|
||||
// record is empty and answered with numbers anyway), versus the model
|
||||
// answered without calling a tool at all (stay dormant — the v1.32.1
|
||||
// regression, where the base prompt deliberately carries no pre-computed
|
||||
// figures and flagging a legitimately recalled one was a real defect).
|
||||
const missedEveryTool =
|
||||
toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0;
|
||||
const activatingPayloads =
|
||||
toolResultPayloads.length > 0
|
||||
? toolResultPayloads
|
||||
: noToolsSnapshotPayloads;
|
||||
: missedEveryTool
|
||||
? inventoryPayloads
|
||||
: noToolsSnapshotPayloads;
|
||||
let groundedFigures: number[] = [];
|
||||
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
|
||||
// elision mark). Hoisted out of the guard block so it can ride the provenance
|
||||
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
|
||||
// (the guard is skipped, its fallback prose carries no figures).
|
||||
let unverifiedStripped = 0;
|
||||
if (activatingPayloads.length > 0) {
|
||||
// True when the guard stripped a figure on a turn whose every tool missed —
|
||||
// i.e. the model put a number on a record that holds none. Drives the
|
||||
// honest-replacement below.
|
||||
let fabricatedOnEmptyRecord = false;
|
||||
if (activatingPayloads.length > 0 || missedEveryTool) {
|
||||
const ledger = buildGroundingLedger({
|
||||
toolPayloads: activatingPayloads,
|
||||
priorToolFigures,
|
||||
@@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
replyText,
|
||||
ledger,
|
||||
locale,
|
||||
// On an all-missed turn an EMPTY ledger is the finding, not a reason
|
||||
// to skip: nothing was retrievable, so nothing can reconcile.
|
||||
{ gradeAgainstEmptyLedger: missedEveryTool },
|
||||
);
|
||||
if (unverified.length > 0) {
|
||||
const { prose: corrected, stripped } = stripUnverifiedNumbers(
|
||||
@@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
replyText = corrected;
|
||||
unverifiedStripped = stripped;
|
||||
fabricatedOnEmptyRecord = missedEveryTool && stripped > 0;
|
||||
annotate({
|
||||
action: { name: "coach.prose.number_unverified" },
|
||||
meta: {
|
||||
@@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// A stripped reply on an empty record is not worth sending. Eliding the
|
||||
// digits leaves the sentences that carried them — "your sleep averaged […]
|
||||
// minutes, up from […]" still asserts a series, an average and a direction
|
||||
// for a metric the tools just reported has no readings at all. The false
|
||||
// claim survives the strip; only its precision goes. So replace the turn
|
||||
// with the honest answer instead of shipping the elided mush.
|
||||
//
|
||||
// Scoped tightly: this fires only when every tool missed AND the guard
|
||||
// actually stripped something. A reply that cited a legitimately grounded
|
||||
// figure (a prior turn's tool result, an inventory count) reconciles
|
||||
// against the ledger, strips nothing, and is left alone.
|
||||
//
|
||||
// REPLACE, not withhold — the same policy the outbound screen uses on this
|
||||
// surface, for the same reason: the user is waiting on a synchronous answer
|
||||
// and silence reads as a failure.
|
||||
//
|
||||
// Accepted trade-off: a reply mixing one grounded recall with one
|
||||
// fabrication is replaced wholesale, losing the good half. That is the
|
||||
// right way round — the alternative leaves an invented figure's sentence
|
||||
// on screen.
|
||||
if (fabricatedOnEmptyRecord) {
|
||||
replyText = getServerTranslator(locale).t("coach.noRecordedData");
|
||||
// The reply now carries no figures at all, so the withheld-figure notice
|
||||
// would be describing prose the reader can no longer see. The replacement
|
||||
// copy states the same thing in plain words.
|
||||
unverifiedStripped = 0;
|
||||
annotate({
|
||||
action: { name: "coach.prose.empty_record_replaced" },
|
||||
meta: { promptVersion: PROMPT_VERSION },
|
||||
});
|
||||
await auditLog("insights.coach.empty_record_replaced", {
|
||||
userId,
|
||||
details: { conversationId: workingConversationId },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
|
||||
// model to only link a published `/learn/<slug>`, but that is guidance, not
|
||||
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* GET /api/insights/coach/nudge-status — the NEXT_APP_OPEN evaluation
|
||||
* hook. This poll mounts with the app chrome, which makes it the
|
||||
* app-open signal, so the route runs the context evaluation before it
|
||||
* computes the unread flag (owner only — a delegate opening the record
|
||||
* is not the owner opening their app).
|
||||
*
|
||||
* Watched red: with the `evaluateCoachContextReminders` call removed
|
||||
* from the route the first test fails on the hook assertion — the
|
||||
* pre-fix state where NEXT_APP_OPEN reminders could never fire.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
|
||||
requireRecordAuth: vi.fn(),
|
||||
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
|
||||
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireRecordAuth,
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/nudge-status", () => ({
|
||||
readCoachNudgeStatus: readStatus,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/context-reminders", () => ({
|
||||
evaluateCoachContextReminders: evaluateContext,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { __tag: "prisma" } }));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
|
||||
});
|
||||
|
||||
describe("nudge-status — NEXT_APP_OPEN hook", () => {
|
||||
it("runs the app-open evaluation for the owner BEFORE computing the unread flag", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
|
||||
await (GET as unknown as () => Promise<unknown>)();
|
||||
|
||||
expect(evaluateContext).toHaveBeenCalledTimes(1);
|
||||
const [prismaArg, userId, trigger] = evaluateContext.mock
|
||||
.calls[0] as unknown as [{ __tag: string }, string, string];
|
||||
expect(prismaArg.__tag).toBe("prisma");
|
||||
expect(userId).toBe("u1");
|
||||
expect(trigger).toBe("app-open");
|
||||
// Ordering: the surfaced message must be visible to this very read.
|
||||
expect(evaluateContext.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
readStatus.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it("never satisfies the cue for a switched caller", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "owner" },
|
||||
actor: { id: "delegate" },
|
||||
});
|
||||
|
||||
await (GET as unknown as () => Promise<unknown>)();
|
||||
|
||||
expect(evaluateContext).not.toHaveBeenCalled();
|
||||
expect(readStatus).toHaveBeenCalledWith("owner");
|
||||
});
|
||||
|
||||
it("keeps the badge alive when the evaluator fails", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
evaluateContext.mockRejectedValueOnce(new Error("evaluator down"));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
|
||||
error: null;
|
||||
};
|
||||
expect(res.error).toBeNull();
|
||||
expect(readStatus).toHaveBeenCalledWith("u1");
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 529 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user